@bongos/core 1.19.1080 → 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 +78 -28
- 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/module-api-changelog.md +2 -0
- 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 +1 -1
- package/release-notes.json +10 -0
- 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/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,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);
|