Skip to content

Commit 875a7bd

Browse files
committed
test(runtime,rest,cloud-connection): pin the ADR-0069 refusal at each door group
One pin file per site group. Each drives a held session under both postures (refused 403 with the gate's code and message, no effect), the same grants without a posture (served unchanged), and the allow-listed case: the dispatcher's /health and REST's discovery document answer a held session as an un-held one, and the fallback's anti-bypass case proves a declared path under an allow-listed host prefix is still refused. The fallback and the publish door also pin the 503 answer for an active gate whose session read fails. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09bfe11 commit 875a7bd

3 files changed

Lines changed: 997 additions & 0 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0069 at cloud-connection's raw-app plugins: a session the authentication
5+
* policy HOLDS (an expired password, enforced MFA not yet enrolled) is refused
6+
* by every door that resolves a session, with REST's answer (`403`, the gate's
7+
* `code` and `message`), before any door logic runs.
8+
*
9+
* ## The two plugins that resolve a session, and where each consults
10+
*
11+
* - `CloudConnectionPlugin` reads the session in ONE place
12+
* (`sessionFromAuthService`, under both resolution modes). Seven of its
13+
* eight doors call it; `/status` reads no session at all.
14+
* - `MarketplaceInstallLocalPlugin` reads it in ONE place
15+
* (`resolveInstallPrincipal`), and all five doors open with the same
16+
* admission (`admitInstallCaller`).
17+
*
18+
* Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request
19+
* path, so the shared allow-list can grant no exemption. No route either plugin
20+
* mounts is allow-listed (asserted below), so there is no exemption to keep.
21+
*
22+
* `MarketplaceProxyPlugin` is not here: it resolves no session. It forwards the
23+
* public catalog with no credential, and every caller gets the same answer.
24+
*
25+
* ## The cases, per plugin
26+
*
27+
* - HELD: under both postures, every session door answers `403` with the
28+
* gate's `code` and `message`, and no effect fires (no control-plane call,
29+
* no credential cleared; no registry, schema, ledger, seed or delete write).
30+
* - UN-HELD: the same caller and grants, without a posture, are served.
31+
* - Anonymous is refused `401` as before, and `/status` is untouched.
32+
*/
33+
34+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
35+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
36+
import { join } from 'node:path';
37+
import { tmpdir } from 'node:os';
38+
import { isAuthGateAllowlisted } from '@objectstack/core';
39+
40+
const seedCalls: unknown[] = [];
41+
vi.mock('@objectstack/runtime', () => ({
42+
SeedLoaderService: class {
43+
async load(request: unknown) {
44+
seedCalls.push(request);
45+
return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
46+
}
47+
async buildDependencyGraph(objectNames: string[]) {
48+
return {
49+
nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })),
50+
insertOrder: objectNames,
51+
circularDependencies: [],
52+
};
53+
}
54+
},
55+
recordSeedOutcome: vi.fn(),
56+
}));
57+
58+
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
59+
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';
60+
import { ConnectionCredentialStore } from './connection-credential-store.js';
61+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
62+
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
63+
64+
const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' };
65+
const MFA_REQUIRED = {
66+
code: 'MFA_REQUIRED',
67+
message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.',
68+
};
69+
const HELD = [
70+
{ user: 'u_held_pw', gate: PASSWORD_EXPIRED },
71+
{ user: 'u_held_mfa', gate: MFA_REQUIRED },
72+
] as const;
73+
/** The posture each test user's session carries. The grants are the same for all three. */
74+
const POSTURES: Record<string, { code: string; message: string } | undefined> = {
75+
u_held_pw: PASSWORD_EXPIRED,
76+
u_held_mfa: MFA_REQUIRED,
77+
u_member: undefined,
78+
};
79+
80+
/** A better-auth session read keyed by the `x-test-user` header, counting its calls. */
81+
function sessionAuth() {
82+
const reads = { count: 0 };
83+
return {
84+
reads,
85+
api: {
86+
getSession: async ({ headers }: { headers: Headers }) => {
87+
reads.count++;
88+
const uid = headers?.get?.('x-test-user');
89+
if (!uid || !(uid in POSTURES)) return null;
90+
const authGate = POSTURES[uid];
91+
return { user: { id: uid, ...(authGate ? { authGate } : {}) }, session: {} };
92+
},
93+
},
94+
};
95+
}
96+
97+
type Handler = (c: any) => Promise<any>;
98+
99+
function makeRawApp() {
100+
const routes = new Map<string, Handler>();
101+
return {
102+
routes,
103+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
104+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
105+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
106+
};
107+
}
108+
109+
function makeC(url: string, user: string | undefined, body: unknown = {}, manifestId?: string) {
110+
const headers = new Headers(user ? { 'x-test-user': user } : {});
111+
return {
112+
req: {
113+
url,
114+
raw: new Request(url, { headers }),
115+
header: (n: string) => headers.get(n) ?? undefined,
116+
json: async () => body,
117+
param: () => manifestId,
118+
},
119+
json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }),
120+
};
121+
}
122+
123+
let dir: string;
124+
beforeEach(() => {
125+
dir = mkdtempSync(join(tmpdir(), 'cc-gate-pin-'));
126+
seedCalls.length = 0;
127+
});
128+
afterEach(() => {
129+
rmSync(dir, { recursive: true, force: true });
130+
vi.unstubAllGlobals();
131+
vi.restoreAllMocks();
132+
});
133+
134+
// ---------------------------------------------------------------------------
135+
// CloudConnectionPlugin
136+
// ---------------------------------------------------------------------------
137+
138+
const CC = '/api/v1/cloud-connection';
139+
const ORIGIN = 'https://env.example.test';
140+
141+
/** The seven doors that resolve a session, each with what it needs to reach the control plane. */
142+
const CC_SESSION_DOORS = [
143+
{ route: `POST ${CC}/bind/start`, url: `${ORIGIN}${CC}/bind/start`, body: {} },
144+
{ route: `POST ${CC}/bind/poll`, url: `${ORIGIN}${CC}/bind/poll`, body: { device_code: 'dc_1' } },
145+
{ route: `POST ${CC}/unbind`, url: `${ORIGIN}${CC}/unbind`, body: {} },
146+
{ route: `POST ${CC}/install`, url: `${ORIGIN}${CC}/install`, body: { package_id: 'pkg_1' } },
147+
{ route: `GET ${CC}/installation`, url: `${ORIGIN}${CC}/installation?package_id=pkg_1`, body: {} },
148+
{ route: `GET ${CC}/installed`, url: `${ORIGIN}${CC}/installed`, body: {} },
149+
{ route: `GET ${CC}/org-packages`, url: `${ORIGIN}${CC}/org-packages`, body: {} },
150+
] as const;
151+
152+
async function mountCloudConnection(mode: 'single' | 'multi') {
153+
const rawApp = makeRawApp();
154+
const hooks = new Map<string, any>();
155+
const auth = sessionAuth();
156+
const credentialPath = join(dir, 'cloud-connection.json');
157+
const store = new ConnectionCredentialStore(credentialPath);
158+
store.write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' });
159+
const services: Record<string, unknown> = mode === 'single'
160+
? { auth }
161+
: {
162+
'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) },
163+
'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) },
164+
};
165+
const ctx = {
166+
hook: (e: string, h: any) => hooks.set(e, h),
167+
getService: (name: string) => {
168+
if (name === 'http-server') return { getRawApp: () => rawApp };
169+
if (name in services) return services[name];
170+
throw new Error(`service ${name} not registered`);
171+
},
172+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
173+
};
174+
const fetchCalls: string[] = [];
175+
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
176+
fetchCalls.push(String(url));
177+
// One body that every door can read: the device-token grant reads
178+
// `access_token` at the top level, the others read `data`.
179+
return new Response(JSON.stringify({ success: true, access_token: 'at_1', data: { installed: false, packages: [], items: [] } }), {
180+
status: 200,
181+
headers: { 'content-type': 'application/json' },
182+
});
183+
}));
184+
await new CloudConnectionPlugin({
185+
controlPlaneUrl: 'http://cloud.test',
186+
controlPlaneApiKey: 'svc-key',
187+
credentialPath,
188+
...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}),
189+
}).start(ctx as any);
190+
await hooks.get('kernel:ready')?.();
191+
return { rawApp, auth, fetchCalls, credentialPath };
192+
}
193+
194+
describe('ADR-0069 at CloudConnectionPlugin', () => {
195+
it('census: eight routes, none allow-listed; seven resolve a session and /status resolves none', async () => {
196+
const { rawApp, auth } = await mountCloudConnection('single');
197+
const keys = [...rawApp.routes.keys()];
198+
expect(keys).toHaveLength(8);
199+
for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false);
200+
expect(keys.filter((k) => k !== `GET ${CC}/status`).sort()).toEqual(CC_SESSION_DOORS.map((d) => d.route).sort());
201+
await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw'));
202+
expect(auth.reads.count, '/status read a session').toBe(0);
203+
});
204+
205+
for (const mode of ['single', 'multi'] as const) {
206+
for (const { user, gate } of HELD) {
207+
it(`HELD (${gate.code}, ${mode}-environment): all seven session doors answer 403 with the gate's code, and nothing reaches the control plane`, async () => {
208+
const { rawApp, fetchCalls, credentialPath } = await mountCloudConnection(mode);
209+
for (const door of CC_SESSION_DOORS) {
210+
const res = await rawApp.routes.get(door.route)!(makeC(door.url, user, door.body));
211+
expect(res.status, door.route).toBe(403);
212+
expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } });
213+
}
214+
expect(fetchCalls, 'a held session reached the control plane').toEqual([]);
215+
expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken, 'unbind cleared the credential').toBe('oscc_pin');
216+
});
217+
}
218+
}
219+
220+
it('UN-HELD: the same caller without a posture is served by all seven doors', async () => {
221+
const { rawApp, fetchCalls } = await mountCloudConnection('single');
222+
for (const door of CC_SESSION_DOORS) {
223+
const before = fetchCalls.length;
224+
const res = await rawApp.routes.get(door.route)!(makeC(door.url, 'u_member', door.body));
225+
expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(200);
226+
expect(fetchCalls.length, `${door.route} did not reach the control plane`).toBeGreaterThan(before);
227+
}
228+
});
229+
230+
it('anonymous: every session door still answers 401 UNAUTHENTICATED', async () => {
231+
const { rawApp, fetchCalls } = await mountCloudConnection('single');
232+
for (const door of CC_SESSION_DOORS) {
233+
const res = await rawApp.routes.get(door.route)!(makeC(door.url, undefined, door.body));
234+
expect(res.status, door.route).toBe(401);
235+
expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED');
236+
}
237+
expect(fetchCalls).toEqual([]);
238+
});
239+
});
240+
241+
// ---------------------------------------------------------------------------
242+
// MarketplaceInstallLocalPlugin
243+
// ---------------------------------------------------------------------------
244+
245+
const IL = '/api/v1/marketplace/install-local';
246+
const APP = {
247+
id: 'com.acme.gated',
248+
namespace: 'gated',
249+
version: '1.0.0',
250+
objects: [{ name: 'widget', fields: { code: { type: 'text' } } }],
251+
data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }],
252+
};
253+
const LEDGER_FILE = 'com.acme.gated.json';
254+
255+
/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */
256+
function grantRows(): Record<string, any[]> {
257+
return {
258+
sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })),
259+
sys_member: [],
260+
sys_user_position: [],
261+
sys_position: [],
262+
sys_metadata_activation: [],
263+
sys_position_permission_set: [],
264+
sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({
265+
id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null,
266+
})),
267+
sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }],
268+
};
269+
}
270+
271+
async function mountInstallLocal(storageDir: string) {
272+
const register = vi.fn(async () => undefined);
273+
const syncSchemas = vi.fn(async () => undefined);
274+
const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }];
275+
const engineDelete = vi.fn(async (object: string, options?: any) => {
276+
const dispatch = assertEngineDeleteDispatch(options);
277+
if (object !== 'widget' || dispatch.kind !== 'by-id') return false;
278+
const at = widgets.findIndex((w) => w.id === dispatch.id);
279+
if (at < 0) return false;
280+
widgets.splice(at, 1);
281+
return true;
282+
});
283+
const rawApp = makeRawApp();
284+
const hooks = new Map<string, any>();
285+
const rows = grantRows();
286+
const services: Record<string, any> = {
287+
manifest: { register },
288+
auth: sessionAuth(),
289+
objectql: bindCatalogFromTables({
290+
syncSchemas,
291+
find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []),
292+
delete: engineDelete,
293+
}, rows),
294+
metadata: { getObject: async () => ({ name: 'widget', fields: {} }) },
295+
};
296+
const ctx: any = {
297+
hook: (e: string, h: any) => hooks.set(e, h),
298+
getService: (name: string) => {
299+
if (name === 'http-server') return { getRawApp: () => rawApp };
300+
const svc = services[name];
301+
if (svc === undefined) throw new Error(`no ${name}`);
302+
return svc;
303+
},
304+
registerService: () => undefined,
305+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
306+
};
307+
await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }).start(ctx);
308+
await hooks.get('kernel:ready')?.();
309+
// `kernel:ready` rehydrates the ledger (registry, schema sync, seed heal),
310+
// so every counter starts AFTER it: the observation window is the request.
311+
register.mockClear();
312+
syncSchemas.mockClear();
313+
engineDelete.mockClear();
314+
seedCalls.length = 0;
315+
return {
316+
rawApp,
317+
effects: () => register.mock.calls.length + syncSchemas.mock.calls.length + seedCalls.length + engineDelete.mock.calls.length,
318+
};
319+
}
320+
321+
/** The five doors, addressed with what each needs to act. */
322+
const IL_DOORS = [
323+
{ route: `POST ${IL}`, body: { manifest: APP } },
324+
{ route: `GET ${IL}`, body: {} },
325+
{ route: `DELETE ${IL}/:manifestId`, body: {} },
326+
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
327+
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
328+
] as const;
329+
330+
/** Install the app as the un-held operator, so the follow-on doors have a target. */
331+
async function seedInstall(storageDir: string) {
332+
const { rawApp } = await mountInstallLocal(storageDir);
333+
const res = await rawApp.routes.get(`POST ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member', { manifest: APP }));
334+
expect(res.payload.success, JSON.stringify(res.payload)).toBe(true);
335+
expect(existsSync(join(storageDir, LEDGER_FILE))).toBe(true);
336+
}
337+
338+
describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => {
339+
it('census: five routes, none allow-listed, all five covered below', async () => {
340+
const { rawApp } = await mountInstallLocal(dir);
341+
const keys = [...rawApp.routes.keys()].sort();
342+
expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort());
343+
for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false);
344+
});
345+
346+
for (const { user, gate } of HELD) {
347+
it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => {
348+
await seedInstall(dir);
349+
const ledgerBefore = existsSync(join(dir, LEDGER_FILE));
350+
const { rawApp, effects } = await mountInstallLocal(dir);
351+
for (const door of IL_DOORS) {
352+
const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, user, door.body, APP.id));
353+
expect(res.status, door.route).toBe(403);
354+
expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } });
355+
}
356+
expect(effects(), 'a held session caused an effect').toBe(0);
357+
expect(existsSync(join(dir, LEDGER_FILE))).toBe(ledgerBefore);
358+
});
359+
}
360+
361+
it('UN-HELD: the same operator without a posture is served on every door', async () => {
362+
await seedInstall(dir);
363+
const { rawApp, effects } = await mountInstallLocal(dir);
364+
const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member'));
365+
expect(list.status).toBe(200);
366+
expect(list.payload.success).toBe(true);
367+
for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) {
368+
const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id));
369+
expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200);
370+
}
371+
expect(effects()).toBeGreaterThan(0);
372+
expect(existsSync(join(dir, LEDGER_FILE)), 'the uninstall removed the ledger entry').toBe(false);
373+
});
374+
375+
it('anonymous: every door still answers 401 UNAUTHENTICATED', async () => {
376+
const { rawApp, effects } = await mountInstallLocal(dir);
377+
for (const door of IL_DOORS) {
378+
const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, undefined, door.body, APP.id));
379+
expect(res.status, door.route).toBe(401);
380+
expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED');
381+
}
382+
expect(effects()).toBe(0);
383+
});
384+
});

0 commit comments

Comments
 (0)