Skip to content

Commit e2fb96f

Browse files
committed
test(runtime): pin the device registry faces on /notifications and their request-schema gate case (red)
Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f4867b commit e2fb96f

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The device registry's two faces on the `/notifications` domain (ADR-0012
5+
* §6): `POST /devices` and `DELETE /devices/:deviceId`, driven at the
6+
* dispatcher facade with a recording service.
7+
*
8+
* - anonymous is refused `401 UNAUTHENTICATED` on both, ahead of the service
9+
* (ADR-0138 D2, the domain's first statement);
10+
* - the owner is the session's user, never a body member: a body naming
11+
* another `userId` registers for the caller;
12+
* - a device id the caller does not hold surfaces the service's
13+
* `NOT_FOUND` throw (the dispatcher answers it 404);
14+
* - the device faces probe their OWN members: a provider without them
15+
* answers the 501 capability disclosure, and a provider with them and no
16+
* inbox still serves them;
17+
* - the path segment is decoded, and a malformed escape is passed raw (an
18+
* unknown id) rather than thrown on.
19+
*
20+
* The wire-level proof (real composition, real registry, real auth) lives in
21+
* `packages/qa/dogfood/test/push-device-registry.dogfood.test.ts`.
22+
*/
23+
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { HttpDispatcher } from '../http-dispatcher.js';
26+
27+
function makeDispatcher(service: Record<string, unknown> | null) {
28+
const kernel: any = {
29+
getService: (name: string) => (name === 'notification' ? service : null),
30+
getServiceAsync: async (name: string) => (name === 'notification' ? service : null),
31+
context: { getService: (name: string) => (name === 'notification' ? service : null) },
32+
};
33+
return new HttpDispatcher(kernel);
34+
}
35+
36+
function deviceService() {
37+
return {
38+
listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })),
39+
registerDevice: vi.fn(async () => ({ deviceId: 'install-1', success: true })),
40+
unregisterDevice: vi.fn(async (_userId: string, deviceId: string) => {
41+
if (deviceId !== 'mine') {
42+
const err = new Error('Device not found.') as Error & { code?: string; status?: number };
43+
err.code = 'NOT_FOUND';
44+
err.status = 404;
45+
throw err;
46+
}
47+
return { success: true };
48+
}),
49+
};
50+
}
51+
52+
const SIGNED_IN = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any);
53+
const ANONYMOUS = () => ({ request: {}, executionContext: {} } as any);
54+
55+
async function thrown(p: Promise<unknown>): Promise<any> {
56+
try {
57+
await p;
58+
} catch (e) {
59+
return e;
60+
}
61+
throw new Error('the request was served; a refusal was expected');
62+
}
63+
64+
describe('/notifications/devices — the device registry faces', () => {
65+
it.each([
66+
['POST', '/devices', { token: 't', platform: 'ios' }],
67+
['DELETE', '/devices/mine', undefined],
68+
])('anonymous %s %s is refused 401 UNAUTHENTICATED before the service is asked', async (method, path, body) => {
69+
const service = deviceService();
70+
const result = await makeDispatcher(service).handleNotification(path, method, body, {}, ANONYMOUS());
71+
expect(result.handled).toBe(true);
72+
expect(result.response?.status).toBe(401);
73+
expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED');
74+
expect(service.registerDevice).not.toHaveBeenCalled();
75+
expect(service.unregisterDevice).not.toHaveBeenCalled();
76+
});
77+
78+
it('registers for the session user; a body userId is not read', async () => {
79+
const service = deviceService();
80+
const body = { token: 't', platform: 'ios', deviceId: 'install-1', userId: 'someone_else' };
81+
const result = await makeDispatcher(service).handleNotification('/devices', 'POST', body, {}, SIGNED_IN());
82+
expect(result.response?.status).toBe(200);
83+
expect(result.response?.body?.data).toEqual({ deviceId: 'install-1', success: true });
84+
expect(service.registerDevice).toHaveBeenCalledTimes(1);
85+
const [userId, request] = service.registerDevice.mock.calls[0] as unknown as [string, Record<string, unknown>];
86+
expect(userId).toBe('user_1');
87+
expect(request).toEqual({ token: 't', platform: 'ios', deviceId: 'install-1' });
88+
});
89+
90+
it('unregisters the caller\'s own device, and surfaces NOT_FOUND (404) for one it does not hold', async () => {
91+
const service = deviceService();
92+
const dispatcher = makeDispatcher(service);
93+
const ok = await dispatcher.handleNotification('/devices/mine', 'DELETE', undefined, {}, SIGNED_IN());
94+
expect(ok.response?.status).toBe(200);
95+
expect(ok.response?.body?.data).toEqual({ success: true });
96+
expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', 'mine');
97+
98+
const refused = await thrown(dispatcher.handleNotification('/devices/someone-elses', 'DELETE', undefined, {}, SIGNED_IN()));
99+
expect(refused.code).toBe('NOT_FOUND');
100+
expect(refused.status).toBe(404);
101+
});
102+
103+
it('decodes the device id segment, and passes a malformed escape through raw', async () => {
104+
const service = deviceService();
105+
const dispatcher = makeDispatcher(service);
106+
await thrown(dispatcher.handleNotification('/devices/a%2Fb', 'DELETE', undefined, {}, SIGNED_IN()));
107+
expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', 'a/b');
108+
await thrown(dispatcher.handleNotification('/devices/%E0%A4%A', 'DELETE', undefined, {}, SIGNED_IN()));
109+
expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', '%E0%A4%A');
110+
});
111+
112+
it('answers 501 when the provider has no device registry, and serves one that has no inbox', async () => {
113+
const inboxOnly = { listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })) };
114+
const noRegistry = await makeDispatcher(inboxOnly)
115+
.handleNotification('/devices', 'POST', { token: 't', platform: 'ios' }, {}, SIGNED_IN());
116+
expect(noRegistry.response?.status).toBe(501);
117+
const noUnregister = await makeDispatcher(inboxOnly)
118+
.handleNotification('/devices/mine', 'DELETE', undefined, {}, SIGNED_IN());
119+
expect(noUnregister.response?.status).toBe(501);
120+
121+
const { listInbox: _dropped, ...registryOnly } = deviceService();
122+
const served = await makeDispatcher(registryOnly)
123+
.handleNotification('/devices', 'POST', { token: 't', platform: 'android' }, {}, SIGNED_IN());
124+
expect(served.response?.status).toBe(200);
125+
});
126+
127+
it('does not serve a verb or sub-path the two faces do not name', async () => {
128+
const service = deviceService();
129+
const dispatcher = makeDispatcher(service);
130+
for (const [method, path] of [['GET', '/devices'], ['PUT', '/devices'], ['POST', '/devices/mine'], ['DELETE', '/devices/a/b']]) {
131+
const result = await dispatcher.handleNotification(path, method, undefined, {}, SIGNED_IN());
132+
expect(result.handled, `${method} ${path}`).toBe(false);
133+
}
134+
expect(service.registerDevice).not.toHaveBeenCalled();
135+
expect(service.unregisterDevice).not.toHaveBeenCalled();
136+
});
137+
});

‎packages/runtime/src/request-schema-gate.conformance.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ const DISPATCHER_GROUPS = [
4444
function makeDispatcher() {
4545
const spies = {
4646
markRead: vi.fn(async () => ({ success: true, readCount: 1 })),
47+
registerDevice: vi.fn(async () => ({ deviceId: 'install-1', success: true })),
4748
listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })),
4849
query: vi.fn(async () => ({ data: [] })),
4950
getMeta: vi.fn(async () => ({ cubes: [] })),
5051
};
5152
const services: Record<string, unknown> = {
52-
notification: { listInbox: spies.listInbox, markRead: spies.markRead },
53+
notification: { listInbox: spies.listInbox, markRead: spies.markRead, registerDevice: spies.registerDevice },
5354
analytics: { query: spies.query, getMeta: spies.getMeta },
5455
};
5556
const resolve = (name: string) => services[name];
@@ -90,6 +91,21 @@ const CASES: GateCase[] = [
9091
drive: (d, body) => d.handleNotification('/read', 'POST', body, {}, CTX),
9192
spy: (s) => s.markRead,
9293
},
94+
{
95+
route: 'POST /api/v1/notifications/devices',
96+
// A missing token, a platform outside the three, a non-string token,
97+
// a vendor outside the pair, and a body that is not an object.
98+
violating: [
99+
{ platform: 'ios' },
100+
{ token: 't', platform: 'blackberry' },
101+
{ token: 42, platform: 'ios' },
102+
{ token: 't', platform: 'web', vendor: 'huawei' },
103+
'garbage',
104+
],
105+
valid: { token: 't', platform: 'ios' },
106+
drive: (d, body) => d.handleNotification('/devices', 'POST', body, {}, CTX),
107+
spy: (s) => s.registerDevice,
108+
},
93109
{
94110
route: 'POST /api/v1/analytics/query',
95111
// Missing measures / retired envelope key — the #3878 shapes.

0 commit comments

Comments
 (0)