@dooer/dooer-test-env 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/account.js +82 -63
- package/lib/api.js +103 -0
- package/lib/command/service.js +4 -1
- package/lib/compose/generate.js +8 -1
- package/package.json +1 -1
package/lib/account.js
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
const crypto = require('crypto')
|
|
17
17
|
const { Client } = require('pg')
|
|
18
|
+
const { apiRequest, serviceToken } = require('./api')
|
|
18
19
|
|
|
19
|
-
// Ghost Inspector's active subscription set on staging (Jimmy 2026-09-01).
|
|
20
|
-
|
|
20
|
+
// Ghost Inspector's active subscription set on staging (Jimmy 2026-09-01). ORDER MATTERS: service-accounts
|
|
21
|
+
// rejects `billing` unless a `sales` subscription already exists (prerequisite-subscription-not-found), so
|
|
22
|
+
// sales is created first — a rule the old direct-INSERT path silently bypassed.
|
|
23
|
+
const SUBSCRIPTION_TYPES = ['accounting', 'sales', 'billing', 'salaries', 'prebook']
|
|
21
24
|
|
|
22
25
|
// The partner every new test account is placed under by default (Jimmy 2026-09-01).
|
|
23
26
|
const DEFAULT_PARTNER_DOMAIN = 'dooer'
|
|
@@ -85,7 +88,7 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
85
88
|
])
|
|
86
89
|
if (!partner.rowCount) throw new Error(`partner '${DEFAULT_PARTNER_DOMAIN}' not found in the local DB`)
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
let orgId = uuid()
|
|
89
92
|
const shortName = await uniqueSlug(client, name)
|
|
90
93
|
const plan = {
|
|
91
94
|
orgId,
|
|
@@ -104,51 +107,75 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
104
107
|
return plan
|
|
105
108
|
}
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
[
|
|
121
|
-
orgId,
|
|
110
|
+
// ── Provision through the product APIs, NOT direct SQL ──────────────────────────────────────────
|
|
111
|
+
// API writes publish domain events; service-closing generates accounting periods from
|
|
112
|
+
// `company-created` and `fiscal-year-created`, and VAT periods follow the same way. A direct INSERT
|
|
113
|
+
// yields rows with none of that ("no period exists"). (Jimmy 2026-09-03.)
|
|
114
|
+
const token = serviceToken()
|
|
115
|
+
const acct = (method, path, body, label) =>
|
|
116
|
+
apiRequest({ service: 'service-accounts', method, path, body, token, label })
|
|
117
|
+
|
|
118
|
+
// 1. the organization + owner (fires company-created → period generator)
|
|
119
|
+
const created = acct(
|
|
120
|
+
'POST',
|
|
121
|
+
'/v1/organizations',
|
|
122
|
+
{
|
|
122
123
|
name,
|
|
123
124
|
shortName,
|
|
124
|
-
orgNumber(),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
fiscalYear ?
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
125
|
+
organizationNumber: orgNumber(),
|
|
126
|
+
vatNumber: null,
|
|
127
|
+
countryCode: 'SE',
|
|
128
|
+
companyType: 'AB',
|
|
129
|
+
address: { addressLines: ['Testgatan 1'], postalCode: '11111', city: 'Stockholm', country: 'SE' },
|
|
130
|
+
bookkeepingStartDate: fiscalYear ? fy.start : null,
|
|
131
|
+
bookkeepingEndDate: null,
|
|
132
|
+
bookkeepingMethod: 'invoiceBased',
|
|
133
|
+
businessDescription: null,
|
|
134
|
+
isEmployer: !!fiscalYear,
|
|
135
|
+
isInactivated: false,
|
|
136
|
+
ownerUserId,
|
|
137
|
+
language: 'sv',
|
|
138
|
+
timeZone: 'Europe/Stockholm',
|
|
139
|
+
sniCodes: [],
|
|
140
|
+
tags: [],
|
|
141
|
+
},
|
|
142
|
+
'create organization'
|
|
140
143
|
)
|
|
144
|
+
orgId = (created.json && created.json.id) || orgId
|
|
145
|
+
plan.orgId = orgId
|
|
146
|
+
|
|
147
|
+
// 2. subscriptions
|
|
141
148
|
for (const type of SUBSCRIPTION_TYPES) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
acct(
|
|
150
|
+
'POST',
|
|
151
|
+
`/v1/organizations/${orgId}/subscriptions`,
|
|
152
|
+
{ subscriptionType: type, status: 'active', startDate: fy.start },
|
|
153
|
+
`subscription ${type}`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. registration fields that only PATCH accepts (VAT / F-skatt / SKV fiscal year)
|
|
158
|
+
if (fiscalYear) {
|
|
159
|
+
acct(
|
|
160
|
+
'PATCH',
|
|
161
|
+
`/v1/organizations/${orgId}`,
|
|
162
|
+
{
|
|
163
|
+
isVatRegistered: true,
|
|
164
|
+
vatRegistrationDate: fy.start,
|
|
165
|
+
isFSkattRegistered: true,
|
|
166
|
+
isEmployer: true,
|
|
167
|
+
bookkeepingStartDate: fy.start,
|
|
168
|
+
skvFiscalYear: { startDate: fy.start, endDate: fy.end },
|
|
169
|
+
},
|
|
170
|
+
'organization information'
|
|
147
171
|
)
|
|
148
172
|
}
|
|
173
|
+
|
|
174
|
+
// 4. the partner link — no dedicated API route, and nothing derives from it
|
|
149
175
|
await client.query(
|
|
150
176
|
`INSERT INTO service_accounts."partnerOrganization" (id, "partnerId", "organizationId", "createdAt", "updatedAt")
|
|
151
|
-
VALUES ($1,$2,$3,now(),now())
|
|
177
|
+
VALUES ($1,$2,$3,now(),now())
|
|
178
|
+
ON CONFLICT DO NOTHING`,
|
|
152
179
|
[uuid(), partner.rows[0].id, orgId]
|
|
153
180
|
)
|
|
154
181
|
// Pre-accept the current Terms of Service so the account is ready to use (sumify otherwise prompts and
|
|
@@ -166,15 +193,20 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
166
193
|
plan.tosAccepted = activeTos.rowCount
|
|
167
194
|
|
|
168
195
|
if (fiscalYear) {
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
// 6. the fiscal year, through service-ledger (fires fiscal-year-created → service-closing generates
|
|
197
|
+
// the accounting periods; a direct INSERT is what produced "no period exists").
|
|
198
|
+
apiRequest({
|
|
199
|
+
service: 'service-ledger',
|
|
200
|
+
method: 'POST',
|
|
201
|
+
path: `/v1/organizations/${orgId}/fiscal-years`,
|
|
202
|
+
body: { startDate: fy.start, endDate: fy.end, chartOfAccountsName: 'dooer-ab' },
|
|
203
|
+
token,
|
|
204
|
+
label: 'create fiscal year',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// 7. the temporal key/values the platform reads. `fiscalYear` carries the period in both the value
|
|
208
|
+
// and the row's own start/endDate (matching how ibSync writes it); the registration flags are
|
|
209
|
+
// open-ended from the start of the year.
|
|
178
210
|
const tkv = [
|
|
179
211
|
{ key: 'fiscalYear', value: { start: fy.start, end: fy.end }, startDate: fy.start, endDate: fy.end },
|
|
180
212
|
{ key: 'hasVatRegistration', value: true, startDate: fy.start, endDate: null },
|
|
@@ -182,25 +214,12 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
182
214
|
{ key: 'hasEmployeeRegistration', value: true, startDate: fy.start, endDate: null },
|
|
183
215
|
]
|
|
184
216
|
for (const t of tkv) {
|
|
185
|
-
|
|
186
|
-
`INSERT INTO service_accounts."temporalKeyValue"
|
|
187
|
-
(id, "organizationId", key, value, "startDate", "endDate", "setBySource", "createdAt", "updatedAt")
|
|
188
|
-
VALUES ($1,$2,$3,$4::jsonb,$5,$6,'user',now(),now())`,
|
|
189
|
-
[uuid(), orgId, t.key, JSON.stringify(t.value), t.startDate, t.endDate]
|
|
190
|
-
)
|
|
217
|
+
acct('POST', `/v1/organizations/${orgId}/temporal-key-values`, t, `temporal key ${t.key}`)
|
|
191
218
|
}
|
|
192
219
|
plan.temporalKeys = tkv.map((t) => t.key)
|
|
193
220
|
}
|
|
194
221
|
|
|
195
|
-
await client.query('COMMIT')
|
|
196
222
|
return plan
|
|
197
|
-
} catch (e) {
|
|
198
|
-
try {
|
|
199
|
-
await client.query('ROLLBACK')
|
|
200
|
-
} catch (_) {
|
|
201
|
-
/* ignore */
|
|
202
|
-
}
|
|
203
|
-
throw e
|
|
204
223
|
} finally {
|
|
205
224
|
await client.end()
|
|
206
225
|
}
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Call a running service's own HTTP API from the host.
|
|
2
|
+
//
|
|
3
|
+
// Backend services are NOT published to the host (only public-facade/service-graphql are), and the facade
|
|
4
|
+
// only proxies a whitelisted route set — so we talk to a service the same way its peers do: from INSIDE the
|
|
5
|
+
// compose network, by exec'ing into that service's container and calling its localhost:3000.
|
|
6
|
+
//
|
|
7
|
+
// Why the API and not direct SQL: writes through the API publish domain events. service-closing generates
|
|
8
|
+
// accounting periods from `company-created` and `fiscal-year-created`; VAT periods and other derived state
|
|
9
|
+
// come the same way. A direct INSERT produces a row with none of that, so the account looks provisioned but
|
|
10
|
+
// has "no period exists" everywhere. (Jimmy 2026-09-03.)
|
|
11
|
+
|
|
12
|
+
const { spawnSync } = require('child_process')
|
|
13
|
+
const { mint } = require('./service-token')
|
|
14
|
+
const { AUTH_PRIVATE_KEY_B64 } = require('./auth-dev-keys')
|
|
15
|
+
|
|
16
|
+
const containerFor = (service) => `dooer-test-env-${service}-1`
|
|
17
|
+
|
|
18
|
+
// A service token is accepted by every service and bypasses per-user authorization.
|
|
19
|
+
function serviceToken(serviceId = 'dooer-test-env') {
|
|
20
|
+
return mint({ serviceId, privateKeyPem: Buffer.from(AUTH_PRIVATE_KEY_B64, 'base64').toString('utf8') })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The request runs in the target container: payload/token/path go in as env vars (no quoting hazards).
|
|
24
|
+
const REQUEST_SCRIPT = `
|
|
25
|
+
const http = require('http')
|
|
26
|
+
const body = process.env.DTE_BODY || ''
|
|
27
|
+
const req = http.request(
|
|
28
|
+
{
|
|
29
|
+
host: 'localhost',
|
|
30
|
+
port: 3000,
|
|
31
|
+
method: process.env.DTE_METHOD,
|
|
32
|
+
path: process.env.DTE_PATH,
|
|
33
|
+
headers: Object.assign(
|
|
34
|
+
{ authorization: 'Bearer ' + process.env.DTE_TOKEN, 'x-dooer-client': 'dooer-test-env@0' },
|
|
35
|
+
body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {}
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
(res) => {
|
|
39
|
+
let d = ''
|
|
40
|
+
res.on('data', (c) => (d += c))
|
|
41
|
+
res.on('end', () => {
|
|
42
|
+
process.stdout.write(JSON.stringify({ status: res.statusCode, body: d }))
|
|
43
|
+
process.exit(0)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
req.on('error', (e) => {
|
|
48
|
+
process.stdout.write(JSON.stringify({ status: 0, body: String(e.message) }))
|
|
49
|
+
process.exit(0)
|
|
50
|
+
})
|
|
51
|
+
if (body) req.write(body)
|
|
52
|
+
req.end()
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
// Perform one request. Returns { status, json }. Throws on a non-2xx (with the service's own error body).
|
|
56
|
+
function apiRequest({ service, method, path, body, token, label }) {
|
|
57
|
+
const payload = body === undefined ? '' : JSON.stringify(body)
|
|
58
|
+
const r = spawnSync(
|
|
59
|
+
'docker',
|
|
60
|
+
[
|
|
61
|
+
'exec',
|
|
62
|
+
'-e',
|
|
63
|
+
`DTE_METHOD=${method}`,
|
|
64
|
+
'-e',
|
|
65
|
+
`DTE_PATH=${path}`,
|
|
66
|
+
'-e',
|
|
67
|
+
`DTE_TOKEN=${token}`,
|
|
68
|
+
'-e',
|
|
69
|
+
`DTE_BODY=${payload}`,
|
|
70
|
+
containerFor(service),
|
|
71
|
+
'node',
|
|
72
|
+
'-e',
|
|
73
|
+
REQUEST_SCRIPT,
|
|
74
|
+
],
|
|
75
|
+
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }
|
|
76
|
+
)
|
|
77
|
+
if (r.status !== 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`cannot reach ${service} (is the env up? \`dooer-test-env up\`): ${(r.stderr || '').trim().slice(0, 300)}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
// The container prints our JSON last; ignore any logging the app wrote to stdout first.
|
|
83
|
+
const out = (r.stdout || '').trim()
|
|
84
|
+
const start = out.lastIndexOf('{"status"')
|
|
85
|
+
let parsed
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(start >= 0 ? out.slice(start) : out)
|
|
88
|
+
} catch (_) {
|
|
89
|
+
throw new Error(`${label || path}: unreadable response from ${service}: ${out.slice(0, 300)}`)
|
|
90
|
+
}
|
|
91
|
+
if (parsed.status < 200 || parsed.status >= 300) {
|
|
92
|
+
throw new Error(`${label || `${method} ${path}`} failed (${parsed.status}): ${String(parsed.body).slice(0, 500)}`)
|
|
93
|
+
}
|
|
94
|
+
let json = null
|
|
95
|
+
try {
|
|
96
|
+
json = parsed.body ? JSON.parse(parsed.body) : null
|
|
97
|
+
} catch (_) {
|
|
98
|
+
/* empty/non-JSON body (e.g. 204) */
|
|
99
|
+
}
|
|
100
|
+
return { status: parsed.status, json }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { apiRequest, serviceToken, containerFor }
|
package/lib/command/service.js
CHANGED
|
@@ -89,7 +89,10 @@ module.exports = {
|
|
|
89
89
|
if (fs.existsSync(rt.OVERRIDE_FILE))
|
|
90
90
|
ov = yaml.safeLoad(fs.readFileSync(rt.OVERRIDE_FILE, 'utf8')) || { services: {} }
|
|
91
91
|
ov.services = ov.services || {}
|
|
92
|
-
|
|
92
|
+
// Keep DOOER_SERVICE_VERSION in sync with the tag: the logging library stamps it on every log
|
|
93
|
+
// line (and the applicationChain), so a bare image bump leaves logs reporting the manifest's
|
|
94
|
+
// stale version — the same drift deploy-staging.sh exists to prevent.
|
|
95
|
+
ov.services[a.name] = { image, environment: { DOOER_SERVICE_VERSION: a.tag } }
|
|
93
96
|
rt.ensureRunDir()
|
|
94
97
|
fs.writeFileSync(rt.OVERRIDE_FILE, yaml.safeDump(ov))
|
|
95
98
|
const st = rt.readState()
|
package/lib/compose/generate.js
CHANGED
|
@@ -38,6 +38,11 @@ const DEV = {
|
|
|
38
38
|
sqlPassword: 'dooer',
|
|
39
39
|
minioUser: 'minioadmin',
|
|
40
40
|
minioPassword: 'minioadmin',
|
|
41
|
+
// Redis MUST have a password locally, like staging (the redis-password secret). service-graphql's
|
|
42
|
+
// pubsub.js does `password ? createRedisClient(...).then(c => new RedisPubSub(...)) : async () => null`
|
|
43
|
+
// — with no password it exports a FUNCTION, and `await fn` yields the function itself, so every GraphQL
|
|
44
|
+
// subscription dies on `pubsubInstance.asyncIterator is not a function`. (Jimmy 2026-09-03.)
|
|
45
|
+
redisPassword: 'dooer',
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// Applied to EVERY dooer service, last, so they win over the manifest values.
|
|
@@ -138,7 +143,7 @@ const OMIT = Symbol('omit-env')
|
|
|
138
143
|
function secretDefault(name) {
|
|
139
144
|
if (name === 'DOOER_SQL_APPLICATION_PASSWORD') return DEV.sqlPassword
|
|
140
145
|
if (name === 'DOOER_SQL_MIGRATION_PASSWORD') return DEV.sqlPassword
|
|
141
|
-
if (name === 'SECRET_DOOER_REDIS_PASSWORD') return
|
|
146
|
+
if (name === 'SECRET_DOOER_REDIS_PASSWORD') return DEV.redisPassword // local redis runs with --requirepass
|
|
142
147
|
if (/ACCESS_KEY_ID$/.test(name)) return DEV.minioUser
|
|
143
148
|
if (/SECRET_ACCESS_KEY$/.test(name)) return DEV.minioPassword
|
|
144
149
|
return OMIT
|
|
@@ -231,6 +236,8 @@ function infraServices() {
|
|
|
231
236
|
},
|
|
232
237
|
redis: {
|
|
233
238
|
image: 'redis:7-alpine',
|
|
239
|
+
// password-protected like staging — see DEV.redisPassword for why it matters (GraphQL subscriptions).
|
|
240
|
+
command: ['redis-server', '--requirepass', DEV.redisPassword],
|
|
234
241
|
ports: ['6379:6379'],
|
|
235
242
|
restart: 'unless-stopped',
|
|
236
243
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": "Dooer/cli-dooer-test-env",
|