@bongos/core 1.19.1079 → 1.19.1081
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/.bongos-core.json +86 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
- package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +126 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +7 -1
- package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-standup.js +91 -0
- package/modules/provisioning/routes/render-standup.js +90 -0
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +176 -46
- package/scripts/gds/provision-render.js +158 -0
- package/scripts/gds/provision-teardown.js +2 -0
- package/scripts/gds/provision.js +2 -2
- package/scripts/gds/render-api.js +178 -0
- package/scripts/gds/render-payload.js +110 -0
- package/src/module-api.js +1 -1
- package/tests/module_update.mjs +232 -0
- package/tests/provision_render.mjs +269 -0
- package/tests/provisioning_render_route.mjs +216 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_api.mjs +153 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// tests/provisioning_render_route.mjs — POST /provisioning/instances/:id/render and its
|
|
2
|
+
// queue seam (task 1004183; ADR 0327).
|
|
3
|
+
//
|
|
4
|
+
// The load-bearing assertions:
|
|
5
|
+
// • OWNER ONLY: the key and the Render account are the owner's, so an archon on someone
|
|
6
|
+
// else's project gets the same 404 as a stranger, and nothing is encrypted or queued
|
|
7
|
+
// • only CIPHERTEXT reaches the queue — the plaintext key is never passed on, never in
|
|
8
|
+
// the reply, never in the audit event
|
|
9
|
+
// • the shape is checked before anything is kept; the web tier makes no Render call
|
|
10
|
+
// • no secret box → 503, and the key is not kept
|
|
11
|
+
// • a conflicting open intent → 409, and the key is not kept
|
|
12
|
+
// • the queue seam writes the intent AND the row in ONE statement, so a collision on
|
|
13
|
+
// the open-intent index stores nothing at all
|
|
14
|
+
//
|
|
15
|
+
// DB-free: data access is stubbed on the cached module objects, over a real mounted
|
|
16
|
+
// router. No network beyond localhost. Run: node tests/provisioning_render_route.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
25
|
+
const renderStandup = require('../modules/provisioning/render-standup.js');
|
|
26
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
27
|
+
const guard = require('../modules/provisioning/control-plane-guard.js');
|
|
28
|
+
|
|
29
|
+
let passed = 0, failed = 0;
|
|
30
|
+
async function ta(name, fn) {
|
|
31
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
32
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
36
|
+
let instance = null, enqueueCalls = [], enqueueResult = null, events = [], boxOn = true;
|
|
37
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
38
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
39
|
+
const realEnqueue = renderStandup.enqueueRenderStandup;
|
|
40
|
+
renderStandup.enqueueRenderStandup = async (_db, id, args) => {
|
|
41
|
+
enqueueCalls.push({ id, ...args });
|
|
42
|
+
return enqueueResult || { intent: { id: 9, action: 'render-standup', state: 'pending' } };
|
|
43
|
+
};
|
|
44
|
+
secretBox.isConfigured = () => boxOn;
|
|
45
|
+
secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
|
|
46
|
+
|
|
47
|
+
const api = require('../src/module-api.js');
|
|
48
|
+
let builder = null;
|
|
49
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
50
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
51
|
+
|
|
52
|
+
const express = require('express');
|
|
53
|
+
const buildRouter = require('../modules/provisioning/routes/render-standup.js');
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use((_req, res, next) => {
|
|
57
|
+
res.fail = (code, opts, details) => {
|
|
58
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
59
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
|
|
60
|
+
};
|
|
61
|
+
next();
|
|
62
|
+
});
|
|
63
|
+
app.use('/api/bongos', buildRouter());
|
|
64
|
+
const server = app.listen(0);
|
|
65
|
+
await new Promise((r) => server.once('listening', r));
|
|
66
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
67
|
+
|
|
68
|
+
function post(path, body) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const payload = JSON.stringify(body);
|
|
71
|
+
const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
|
|
72
|
+
let d = '';
|
|
73
|
+
res.on('data', (c) => { d += c; });
|
|
74
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
75
|
+
});
|
|
76
|
+
req.on('error', reject);
|
|
77
|
+
req.end(payload);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
|
|
82
|
+
function reset(patch = {}) {
|
|
83
|
+
instance = { ...LIVE, ...patch };
|
|
84
|
+
enqueueCalls = []; enqueueResult = null; events = []; boxOn = true;
|
|
85
|
+
builder = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
86
|
+
}
|
|
87
|
+
const PATH = '/provisioning/instances/18/render';
|
|
88
|
+
const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield' };
|
|
89
|
+
const noKeyAnywhere = (...xs) => xs.forEach((x) => assert.ok(!JSON.stringify(x).includes(KEY), 'the plaintext key leaked: ' + JSON.stringify(x)));
|
|
90
|
+
|
|
91
|
+
console.log('who may ask:');
|
|
92
|
+
await ta('the owner queues it: ciphertext only, the request recorded on the row, one audit event', async () => {
|
|
93
|
+
reset();
|
|
94
|
+
const r = await post(PATH, { ...GREEN, web_plan: 'standard' });
|
|
95
|
+
assert.equal(r.status, 200, r.raw);
|
|
96
|
+
assert.equal(enqueueCalls.length, 1);
|
|
97
|
+
assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
|
|
98
|
+
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, webPlan: 'standard' });
|
|
99
|
+
assert.equal(events.length, 1);
|
|
100
|
+
noKeyAnywhere(enqueueCalls, events, r.body);
|
|
101
|
+
});
|
|
102
|
+
await ta('an ARCHON on someone else\'s project gets 404 — nothing encrypted, queued or recorded', async () => {
|
|
103
|
+
reset();
|
|
104
|
+
builder = { id: 42, github_login: 'arch', rank: 'archon' };
|
|
105
|
+
let sealed = 0; const enc = secretBox.encrypt; secretBox.encrypt = (p) => { sealed++; return enc(p); };
|
|
106
|
+
const r = await post(PATH, GREEN);
|
|
107
|
+
secretBox.encrypt = enc;
|
|
108
|
+
assert.equal(r.status, 404, r.raw);
|
|
109
|
+
assert.equal(sealed, 0);
|
|
110
|
+
assert.equal(enqueueCalls.length, 0);
|
|
111
|
+
assert.equal(events.length, 0);
|
|
112
|
+
});
|
|
113
|
+
await ta('a missing project is 404', async () => {
|
|
114
|
+
reset(); instance = null;
|
|
115
|
+
assert.equal((await post(PATH, GREEN)).status, 404);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
console.log('\nthe shape is checked first:');
|
|
119
|
+
for (const [name, body, re] of [
|
|
120
|
+
['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],
|
|
121
|
+
['a workspace id of the wrong shape', { ...GREEN, owner_id: 'srv-abcdefgh1234' }, /workspace/],
|
|
122
|
+
['an unknown mode', { ...GREEN, mode: 'migrate' }, /link a Render service/],
|
|
123
|
+
['adopt with no service', { ...GREEN, mode: 'adopt' }, /service to link/],
|
|
124
|
+
['greenfield naming a service', { ...GREEN, service_id: 'srv-abcdefgh1234' }, /leave the service out/],
|
|
125
|
+
['an unknown plan', { ...GREEN, db_plan: 'free' }, /not a Render database plan/],
|
|
126
|
+
]) {
|
|
127
|
+
await ta(`${name} → 400, nothing kept`, async () => {
|
|
128
|
+
reset();
|
|
129
|
+
const r = await post(PATH, body);
|
|
130
|
+
assert.equal(r.status, 400, r.raw);
|
|
131
|
+
assert.match(r.body.error.message, re);
|
|
132
|
+
assert.equal(enqueueCalls.length, 0);
|
|
133
|
+
noKeyAnywhere(r.body);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
await ta('an unknown field is refused by the strict validator', async () => {
|
|
137
|
+
reset();
|
|
138
|
+
const r = await post(PATH, { ...GREEN, value: 'x' });
|
|
139
|
+
assert.equal(r.status, 400, r.raw);
|
|
140
|
+
assert.equal(enqueueCalls.length, 0);
|
|
141
|
+
});
|
|
142
|
+
await ta('adopt with a service id is accepted and records it', async () => {
|
|
143
|
+
reset();
|
|
144
|
+
const r = await post(PATH, { ...GREEN, mode: 'adopt', service_id: 'srv-abcdefgh1234' });
|
|
145
|
+
assert.equal(r.status, 200, r.raw);
|
|
146
|
+
assert.equal(enqueueCalls[0].appHost.serviceId, 'srv-abcdefgh1234');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
console.log('\nrefusals keep nothing:');
|
|
150
|
+
await ta('greenfield with no repository → 409', async () => {
|
|
151
|
+
reset({ target_ref: null });
|
|
152
|
+
const r = await post(PATH, GREEN);
|
|
153
|
+
assert.equal(r.status, 409, r.raw);
|
|
154
|
+
assert.equal(enqueueCalls.length, 0);
|
|
155
|
+
});
|
|
156
|
+
await ta('a removed project → 409', async () => {
|
|
157
|
+
reset({ status: 'torn_down' });
|
|
158
|
+
assert.equal((await post(PATH, GREEN)).status, 409);
|
|
159
|
+
assert.equal(enqueueCalls.length, 0);
|
|
160
|
+
});
|
|
161
|
+
await ta('no secret box → 503, and the key is not kept', async () => {
|
|
162
|
+
reset(); boxOn = false;
|
|
163
|
+
const r = await post(PATH, GREEN);
|
|
164
|
+
assert.equal(r.status, 503, r.raw);
|
|
165
|
+
assert.equal(enqueueCalls.length, 0);
|
|
166
|
+
});
|
|
167
|
+
await ta('something already open → 409 saying the key was not kept', async () => {
|
|
168
|
+
reset(); enqueueResult = { conflict: 'open_intent', intent: { action: 'provision', state: 'running' } };
|
|
169
|
+
const r = await post(PATH, GREEN);
|
|
170
|
+
assert.equal(r.status, 409, r.raw);
|
|
171
|
+
assert.match(r.body.error.message, /not kept/);
|
|
172
|
+
assert.equal(events.length, 0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
console.log('\nthe queue seam:');
|
|
176
|
+
const realGuard = guard.assertIntentAllowed;
|
|
177
|
+
guard.assertIntentAllowed = async () => {};
|
|
178
|
+
await ta('one statement writes the intent with the ciphertext and merges the row', async () => {
|
|
179
|
+
const sqls = [];
|
|
180
|
+
const db = { query: async (sql, params) => { sqls.push({ sql, params }); return { rows: [{ id: 5, action: 'render-standup', state: 'pending' }] }; } };
|
|
181
|
+
const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234' }, requestedBy: 'api:self' });
|
|
182
|
+
assert.equal(out.intent.id, 5);
|
|
183
|
+
assert.equal(sqls.length, 1, 'a single statement — both land or neither does');
|
|
184
|
+
assert.match(sqls[0].sql, /INSERT INTO provisioning_intents[\s\S]*borrowed_key_enc[\s\S]*UPDATE provisioning_instances[\s\S]*app_host/);
|
|
185
|
+
assert.equal(sqls[0].params[2], 'v1.x');
|
|
186
|
+
assert.doesNotMatch(sqls[0].sql, /RETURNING \*[\s\S]*SELECT i\.\*/, 'the ciphertext is not read back');
|
|
187
|
+
});
|
|
188
|
+
await ta('a collision on the open-intent index stores nothing and names the blocker', async () => {
|
|
189
|
+
let n = 0;
|
|
190
|
+
const db = { query: async () => { if (n++ === 0) throw Object.assign(new Error('dup'), { code: '23505' }); return { rows: [{ id: 3, action: 'teardown', state: 'pending' }] }; } };
|
|
191
|
+
const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
|
|
192
|
+
assert.equal(out.conflict, 'open_intent');
|
|
193
|
+
assert.equal(out.intent.action, 'teardown');
|
|
194
|
+
});
|
|
195
|
+
guard.assertIntentAllowed = realGuard;
|
|
196
|
+
|
|
197
|
+
console.log('\nwiring:');
|
|
198
|
+
await ta('module.json mounts the route; the action is in the vocabulary and the newest CHECK', () => {
|
|
199
|
+
const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
|
|
200
|
+
assert.ok(manifest.contributes.routes.includes('render-standup'));
|
|
201
|
+
assert.ok(provisioning.INTENT_ACTIONS.includes('render-standup'));
|
|
202
|
+
const mig = readFileSync(new URL('../modules/provisioning/migrations/provisioning_028_render_standup.sql', import.meta.url), 'utf8');
|
|
203
|
+
assert.match(mig, /borrowed_key_enc IS NULL OR state IN \('pending', 'running'\)/, 'a resolved intent cannot keep a key');
|
|
204
|
+
});
|
|
205
|
+
await ta('every path that resolves an intent clears the key', () => {
|
|
206
|
+
const src = readFileSync(new URL('../modules/provisioning/provisioning.js', import.meta.url), 'utf8');
|
|
207
|
+
const resolve = src.slice(src.indexOf('async function resolveIntent'), src.indexOf('async function resolveIntent') + 400);
|
|
208
|
+
assert.match(resolve, /borrowed_key_enc = NULL/);
|
|
209
|
+
const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
|
|
210
|
+
assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
|
|
211
|
+
assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
server.close();
|
|
215
|
+
console.log(`\nprovisioning_render_route: ${passed} passed, ${failed} failed`);
|
|
216
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
|
|
|
169
169
|
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
170
170
|
assert.equal(r.created, true);
|
|
171
171
|
assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
|
|
172
|
-
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
172
|
+
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
// ── the route, over a real mounted router (stubbed data access) ─────────────────
|
|
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
|
|
|
31
31
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
32
|
|
|
33
33
|
// The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
|
|
34
|
-
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
|
|
34
|
+
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
|
|
35
35
|
|
|
36
36
|
let passed = 0, failed = 0;
|
|
37
37
|
function t(name, fn) {
|
|
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
|
|
|
81
81
|
// dead work in front of a teardown (task 1003140 widened it to the settings
|
|
82
82
|
// push, task 1003238 to the restart; an open teardown is left alone for the
|
|
83
83
|
// idempotent path).
|
|
84
|
-
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
|
|
84
|
+
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
|
|
85
85
|
updates++;
|
|
86
86
|
// The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
|
|
87
87
|
// guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/render_api.mjs — the Render client's workspace guard (task 1004183; ADR 0327).
|
|
2
|
+
//
|
|
3
|
+
// The load-bearing assertions:
|
|
4
|
+
// • a client cannot exist without the workspace it acts in — never defaulted
|
|
5
|
+
// • a response from ANOTHER workspace is refused (RenderOwnerMismatch), and so is a
|
|
6
|
+
// response carrying no workspace — in both of Render's shapes (service `ownerId`,
|
|
7
|
+
// Postgres `owner.id`) and on every element that carries one
|
|
8
|
+
// • the calls whose answer carries no owner (connection-info, env-var PUT, deploy) are
|
|
9
|
+
// only sent after the parent resource was read through the guard — so a service in
|
|
10
|
+
// another workspace gets NO write
|
|
11
|
+
// • there is no delete, and the verb is refused outright
|
|
12
|
+
// • a dry-run sends nothing and never prints an env var value
|
|
13
|
+
//
|
|
14
|
+
// Hermetic: a recording fake fetch. Run: node tests/render_api.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { RenderApi, RenderApiError, RenderOwnerMismatch, ownerOf } = require('../scripts/gds/render-api.js');
|
|
21
|
+
|
|
22
|
+
let passed = 0, failed = 0;
|
|
23
|
+
async function ta(name, fn) {
|
|
24
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
25
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const OURS = 'tea-ours00000001';
|
|
29
|
+
const THEIRS = 'tea-theirs000001';
|
|
30
|
+
|
|
31
|
+
// A fake Render: `routes` maps "METHOD path" to the JSON it answers.
|
|
32
|
+
function fakeFetch(routes) {
|
|
33
|
+
const calls = [];
|
|
34
|
+
const fetchImpl = async (url, init) => {
|
|
35
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
36
|
+
calls.push({ method: init.method, path, body: init.body ? JSON.parse(init.body) : null, auth: init.headers.Authorization });
|
|
37
|
+
const key = `${init.method} ${path}`;
|
|
38
|
+
if (!(key in routes)) return { ok: false, status: 404, text: async () => JSON.stringify({ message: 'not found' }) };
|
|
39
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(routes[key]) };
|
|
40
|
+
};
|
|
41
|
+
return { fetchImpl, calls };
|
|
42
|
+
}
|
|
43
|
+
const live = (routes, ownerId = OURS) => {
|
|
44
|
+
const f = fakeFetch(routes);
|
|
45
|
+
return { api: new RenderApi({ token: 'rnd_test', ownerId, dryRun: false, fetchImpl: f.fetchImpl }), calls: f.calls };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
console.log('the workspace is required:');
|
|
49
|
+
await ta('no ownerId → the client refuses to exist, even in dry-run', () => {
|
|
50
|
+
assert.throws(() => new RenderApi({ token: 'rnd_x', dryRun: false, fetchImpl: () => {} }), RenderApiError);
|
|
51
|
+
assert.throws(() => new RenderApi({}), /never defaulted/);
|
|
52
|
+
assert.throws(() => new RenderApi({ ownerId: ' ' }), RenderApiError);
|
|
53
|
+
});
|
|
54
|
+
await ta('live with no key → refuses', () => {
|
|
55
|
+
assert.throws(() => new RenderApi({ ownerId: OURS, dryRun: false, fetchImpl: () => {} }), /no API key/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log('\nthe guard is on the RESPONSE:');
|
|
59
|
+
await ta('a service from another workspace is refused', async () => {
|
|
60
|
+
const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
|
|
61
|
+
await assert.rejects(api.getService('srv-1'), RenderOwnerMismatch);
|
|
62
|
+
});
|
|
63
|
+
await ta('a service carrying no workspace is refused', async () => {
|
|
64
|
+
const { api } = live({ 'GET /services/srv-1': { id: 'srv-1' } });
|
|
65
|
+
await assert.rejects(api.getService('srv-1'), /no workspace/);
|
|
66
|
+
});
|
|
67
|
+
await ta('a service from our workspace passes, in either envelope', async () => {
|
|
68
|
+
const { api } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: OURS }, 'GET /services/srv-2': { service: { id: 'srv-2', ownerId: OURS } } });
|
|
69
|
+
assert.equal((await api.getService('srv-1')).id, 'srv-1');
|
|
70
|
+
assert.equal((await api.getService('srv-2')).id, 'srv-2');
|
|
71
|
+
});
|
|
72
|
+
await ta('a Postgres carries its owner as owner.id — a mismatch there is refused too', async () => {
|
|
73
|
+
const { api } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } } });
|
|
74
|
+
await assert.rejects(api.getPostgres('dpg-1'), RenderOwnerMismatch);
|
|
75
|
+
assert.equal(ownerOf({ owner: { id: OURS } }), OURS);
|
|
76
|
+
assert.equal(ownerOf({ ownerId: OURS }), OURS);
|
|
77
|
+
assert.equal(ownerOf({}), null);
|
|
78
|
+
});
|
|
79
|
+
await ta('a create answered from another workspace is refused', async () => {
|
|
80
|
+
const { api } = live({
|
|
81
|
+
'POST /services': { deployId: 'dep-1', service: { id: 'srv-9', ownerId: THEIRS } },
|
|
82
|
+
'POST /postgres': { id: 'dpg-9', owner: { id: THEIRS } },
|
|
83
|
+
});
|
|
84
|
+
await assert.rejects(api.createService({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
|
|
85
|
+
await assert.rejects(api.createPostgres({ ownerId: OURS, name: 'x' }), RenderOwnerMismatch);
|
|
86
|
+
});
|
|
87
|
+
await ta('a payload naming another workspace is never sent', async () => {
|
|
88
|
+
const { api, calls } = live({});
|
|
89
|
+
await assert.rejects(api.createService({ ownerId: THEIRS, name: 'x' }), /this client's workspace/);
|
|
90
|
+
await assert.rejects(api.createPostgres({ name: 'x' }), /this client's workspace/);
|
|
91
|
+
assert.equal(calls.length, 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log('\nownerless answers are reached only through a checked parent:');
|
|
95
|
+
await ta('an env var for a service in another workspace is never PUT', async () => {
|
|
96
|
+
const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS }, 'PUT /services/srv-1/env-vars/K': { key: 'K' } });
|
|
97
|
+
await assert.rejects(api.putEnvVar('srv-1', 'K', 'v'), RenderOwnerMismatch);
|
|
98
|
+
assert.deepEqual(calls.map((c) => c.method), ['GET'], 'the PUT is never sent');
|
|
99
|
+
});
|
|
100
|
+
await ta('a deploy for a service in another workspace is never triggered', async () => {
|
|
101
|
+
const { api, calls } = live({ 'GET /services/srv-1': { id: 'srv-1', ownerId: THEIRS } });
|
|
102
|
+
await assert.rejects(api.triggerDeploy('srv-1'), RenderOwnerMismatch);
|
|
103
|
+
assert.deepEqual(calls.map((c) => c.method), ['GET']);
|
|
104
|
+
});
|
|
105
|
+
await ta('connection-info for a database in another workspace is never read', async () => {
|
|
106
|
+
const { api, calls } = live({ 'GET /postgres/dpg-1': { id: 'dpg-1', owner: { id: THEIRS } }, 'GET /postgres/dpg-1/connection-info': { internalConnectionString: 'postgres://leak' } });
|
|
107
|
+
await assert.rejects(api.postgresConnectionInfo('dpg-1'), RenderOwnerMismatch);
|
|
108
|
+
assert.equal(calls.length, 1);
|
|
109
|
+
});
|
|
110
|
+
await ta('on our own resources they go through, with the key as a bearer', async () => {
|
|
111
|
+
const { api, calls } = live({
|
|
112
|
+
'GET /services/srv-1': { id: 'srv-1', ownerId: OURS },
|
|
113
|
+
'PUT /services/srv-1/env-vars/K': { key: 'K' },
|
|
114
|
+
'POST /services/srv-1/deploys': { id: 'dep-2' },
|
|
115
|
+
});
|
|
116
|
+
await api.putEnvVar('srv-1', 'K', 'v');
|
|
117
|
+
await api.triggerDeploy('srv-1');
|
|
118
|
+
assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /services/srv-1', 'PUT /services/srv-1/env-vars/K', 'GET /services/srv-1', 'POST /services/srv-1/deploys']);
|
|
119
|
+
assert.deepEqual(calls[1].body, { value: 'v' });
|
|
120
|
+
assert.ok(calls.every((c) => c.auth === 'Bearer rnd_test'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
console.log('\nthe workspace list is for membership, never selection:');
|
|
124
|
+
await ta('listOwners returns every workspace the key reaches, unwrapped', async () => {
|
|
125
|
+
const { api } = live({ 'GET /owners?limit=100': [{ owner: { id: THEIRS, type: 'team' }, cursor: 'a' }, { owner: { id: OURS, type: 'team' }, cursor: 'b' }] });
|
|
126
|
+
assert.deepEqual((await api.listOwners()).map((o) => o.id), [THEIRS, OURS]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log('\nno delete:');
|
|
130
|
+
await ta('there is no delete method, and the verb is refused', async () => {
|
|
131
|
+
const { api, calls } = live({});
|
|
132
|
+
const methods = Object.getOwnPropertyNames(RenderApi.prototype);
|
|
133
|
+
assert.ok(!methods.some((m) => /delete|destroy|remove|suspend/i.test(m)), `no destructive method: ${methods.join(', ')}`);
|
|
134
|
+
await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
|
|
135
|
+
assert.equal(calls.length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
console.log('\ndry-run:');
|
|
139
|
+
await ta('dry-run is the default, sends nothing, and prints no env var value', async () => {
|
|
140
|
+
const lines = [];
|
|
141
|
+
let sent = 0;
|
|
142
|
+
const api = new RenderApi({ ownerId: OURS, fetchImpl: () => { sent++; }, log: (l) => lines.push(l) });
|
|
143
|
+
const r = await api.createService({ ownerId: OURS, name: 'demo', envVars: [{ key: 'DATABASE_URL', value: 'postgres://secret' }] });
|
|
144
|
+
assert.equal(sent, 0);
|
|
145
|
+
assert.equal(r.service.ownerId, OURS);
|
|
146
|
+
await api.putEnvVar('srv-1', 'BUILDER_SECRET_KEY', 'deadbeef');
|
|
147
|
+
const out = lines.join('\n');
|
|
148
|
+
assert.ok(!out.includes('postgres://secret') && !out.includes('deadbeef'), out);
|
|
149
|
+
assert.match(out, /DATABASE_URL/, 'the name is still shown');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
153
|
+
if (failed) process.exit(1);
|