@mycadet/gateway 0.12.10 → 0.13.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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
- package/src/__tests__/credential-requests.test.ts +4 -4
- package/src/__tests__/devices-mine.test.ts +187 -0
- package/src/__tests__/edge-auth.test.ts +1 -20
- package/src/__tests__/edge-guardian-auth.test.ts +1 -17
- package/src/__tests__/feature-flag-resolver.test.ts +2 -30
- package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
- package/src/__tests__/feature-flags-route.test.ts +2 -191
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
- package/src/__tests__/guardian-integrity.test.ts +16 -20
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
- package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
- package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
- package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
- package/src/__tests__/refresh-device-binding.test.ts +0 -12
- package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/speech-relay-websocket.test.ts +4 -4
- package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
- package/src/__tests__/twilio-webhooks.test.ts +2 -2
- package/src/auth/actor-token-revocation.ts +34 -9
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +95 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/pairing.ts +81 -6
- package/src/contacts-mirror-op-reporter.ts +19 -36
- package/src/db/connection.ts +0 -10
- package/src/feature-flag-resolver.ts +2 -7
- package/src/feature-flag-watcher.ts +1 -14
- package/src/guardian-integrity-reporter.ts +19 -47
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/devices-mine.ts +103 -0
- package/src/http/routes/feature-flags.ts +1 -8
- package/src/http/routes/guardian-pin.ts +2 -11
- package/src/http/routes/ipc-runtime-proxy.ts +1 -1
- package/src/http/routes/live-voice-websocket.ts +1 -1
- package/src/http/routes/runtime-audio-stream.ts +1 -1
- package/src/http/routes/runtime-proxy.ts +2 -2
- package/src/http/routes/speech-relay-websocket.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
- package/src/index.ts +11 -198
- package/src/ipc/feature-flag-handlers.ts +2 -9
- package/src/remote-web/pairing-challenge-store.ts +15 -0
- package/src/risk/command-registry/commands/assistant.ts +0 -24
- package/src/risk/command-registry.test.ts +2 -4
- package/src/schema.ts +44 -372
- package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
- package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
- package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
- package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
- package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
- package/src/auth-fallback-count-tracker.ts +0 -113
- package/src/auth-fallback-reporter.ts +0 -127
- package/src/backup/backup-key.ts +0 -138
- package/src/backup/backup-routes.ts +0 -161
- package/src/backup/backup-worker.ts +0 -425
- package/src/backup/list-snapshots.ts +0 -97
- package/src/backup/local-writer.ts +0 -87
- package/src/backup/offsite-writer.ts +0 -182
- package/src/backup/paths.ts +0 -113
- package/src/backup/stream-crypt.ts +0 -258
- package/src/device-id.ts +0 -18
- package/src/feature-flag-remote-store.ts +0 -166
- package/src/http/routes/migration-proxy.ts +0 -636
- package/src/internal-telemetry-client.ts +0 -39
- package/src/ipc/debug-export-handlers.test.ts +0 -102
- package/src/ipc/debug-export-handlers.ts +0 -81
- package/src/remote-feature-flag-sync.ts +0 -440
- package/src/watchdog-reporter.ts +0 -110
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
beforeAll,
|
|
18
18
|
beforeEach,
|
|
19
19
|
afterAll,
|
|
20
|
-
afterEach,
|
|
21
20
|
mock,
|
|
22
21
|
} from "bun:test";
|
|
23
22
|
|
|
@@ -48,25 +47,33 @@ mock.module("../ipc/assistant-client.js", () => ({
|
|
|
48
47
|
|
|
49
48
|
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
50
49
|
|
|
51
|
-
import { ContactStore } from "../db/contact-store.js";
|
|
52
50
|
import {
|
|
53
51
|
initGatewayDb,
|
|
54
52
|
getGatewayDb,
|
|
55
53
|
resetGatewayDb,
|
|
56
54
|
} from "../db/connection.js";
|
|
57
55
|
import { contacts, contactChannels } from "../db/schema.js";
|
|
58
|
-
import
|
|
59
|
-
MIRROR_OP_MISSING_CHECK_NAME,
|
|
60
|
-
flushMirrorOpReporterForTesting,
|
|
61
|
-
resetMirrorOpReporterForTesting,
|
|
62
|
-
setMirrorOpReporterOverridesForTesting,
|
|
63
|
-
} from "../contacts-mirror-op-reporter.js";
|
|
56
|
+
import * as mirrorOpReporter from "../contacts-mirror-op-reporter.js";
|
|
64
57
|
|
|
65
58
|
// ── Setup ────────────────────────────────────────────────────────────────────
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
// The reporter's report fns carry the detail payload; capture them by
|
|
61
|
+
// re-registering the reporter module with a recorder (its real sibling
|
|
62
|
+
// exports — the unknown-method classifier — stay live for the store).
|
|
63
|
+
const reportCalls: { op: string; detail: Record<string, unknown> }[] = [];
|
|
64
|
+
|
|
65
|
+
mock.module("../contacts-mirror-op-reporter.js", () => ({
|
|
66
|
+
...mirrorOpReporter,
|
|
67
|
+
reportMirrorOpMissing: (op: string, detail: Record<string, unknown>) => {
|
|
68
|
+
reportCalls.push({ op, detail });
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// The SUT must load AFTER the mock registrations above — a static import
|
|
73
|
+
// would bind contact-store to the REAL ipc client + reporter before either
|
|
74
|
+
// mock.module runs (bun mock.module is not retroactive for modules already
|
|
75
|
+
// imported), and the escalations would never reach the recorder.
|
|
76
|
+
const { ContactStore } = await import("../db/contact-store.js");
|
|
70
77
|
|
|
71
78
|
beforeAll(async () => {
|
|
72
79
|
await initGatewayDb();
|
|
@@ -88,30 +95,7 @@ beforeEach(() => {
|
|
|
88
95
|
return {};
|
|
89
96
|
});
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
warnLogs.length = 0;
|
|
93
|
-
telemetryBodies = [];
|
|
94
|
-
resetMirrorOpReporterForTesting();
|
|
95
|
-
setMirrorOpReporterOverridesForTesting({
|
|
96
|
-
fetchImpl: async (_url, init) => {
|
|
97
|
-
telemetryBodies.push(JSON.parse(String(init?.body)));
|
|
98
|
-
return new Response("{}");
|
|
99
|
-
},
|
|
100
|
-
mintToken: () => "svc-token",
|
|
101
|
-
baseUrl: "http://127.0.0.1:7821",
|
|
102
|
-
log: {
|
|
103
|
-
error: (detail, msg) => {
|
|
104
|
-
errorLogs.push([detail, msg]);
|
|
105
|
-
},
|
|
106
|
-
warn: (detail, msg) => {
|
|
107
|
-
warnLogs.push([detail, msg]);
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
afterEach(() => {
|
|
114
|
-
resetMirrorOpReporterForTesting();
|
|
98
|
+
reportCalls.length = 0;
|
|
115
99
|
});
|
|
116
100
|
|
|
117
101
|
afterAll(() => {
|
|
@@ -161,7 +145,7 @@ function unknownMethodError(method: string): Error {
|
|
|
161
145
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
162
146
|
|
|
163
147
|
describe("mergeContacts — unknown mirror op escalation", () => {
|
|
164
|
-
test("unknown method →
|
|
148
|
+
test("unknown method → fail-loud report; gateway merge still applied", async () => {
|
|
165
149
|
seedContact("ct_keep");
|
|
166
150
|
seedContact("ct_merge");
|
|
167
151
|
seedChannel("ch_1", "ct_merge");
|
|
@@ -175,7 +159,6 @@ describe("mergeContacts — unknown mirror op escalation", () => {
|
|
|
175
159
|
"ct_keep",
|
|
176
160
|
"ct_merge",
|
|
177
161
|
);
|
|
178
|
-
await flushMirrorOpReporterForTesting();
|
|
179
162
|
|
|
180
163
|
// Gateway (source of truth) merge stands: donor gone, channel moved.
|
|
181
164
|
expect(contact!.id).toBe("ct_keep");
|
|
@@ -196,26 +179,16 @@ describe("mergeContacts — unknown mirror op escalation", () => {
|
|
|
196
179
|
.find((c) => c.id === "ch_1")?.contactId,
|
|
197
180
|
).toBe("ct_keep");
|
|
198
181
|
|
|
199
|
-
// Escalated: one
|
|
200
|
-
expect(
|
|
201
|
-
expect(errorLogs[0][0]).toMatchObject({
|
|
202
|
-
op: "contacts_mirror_merge_contact",
|
|
203
|
-
keepId: "ct_keep",
|
|
204
|
-
mergeId: "ct_merge",
|
|
205
|
-
});
|
|
206
|
-
expect(telemetryBodies).toEqual([
|
|
182
|
+
// Escalated: exactly one fail-loud report with the op + detail.
|
|
183
|
+
expect(reportCalls).toEqual([
|
|
207
184
|
{
|
|
208
|
-
|
|
209
|
-
detail: {
|
|
210
|
-
op: "contacts_mirror_merge_contact",
|
|
211
|
-
keepId: "ct_keep",
|
|
212
|
-
mergeId: "ct_merge",
|
|
213
|
-
},
|
|
185
|
+
op: "contacts_mirror_merge_contact",
|
|
186
|
+
detail: { keepId: "ct_keep", mergeId: "ct_merge" },
|
|
214
187
|
},
|
|
215
188
|
]);
|
|
216
189
|
});
|
|
217
190
|
|
|
218
|
-
test("other IPC failure →
|
|
191
|
+
test("other IPC failure → no report, same gateway state", async () => {
|
|
219
192
|
seedContact("ct_keep");
|
|
220
193
|
seedContact("ct_merge");
|
|
221
194
|
seedChannel("ch_1", "ct_merge");
|
|
@@ -229,7 +202,6 @@ describe("mergeContacts — unknown mirror op escalation", () => {
|
|
|
229
202
|
"ct_keep",
|
|
230
203
|
"ct_merge",
|
|
231
204
|
);
|
|
232
|
-
await flushMirrorOpReporterForTesting();
|
|
233
205
|
|
|
234
206
|
expect(contact!.id).toBe("ct_keep");
|
|
235
207
|
expect(mirrored).toBe(false);
|
|
@@ -241,14 +213,13 @@ describe("mergeContacts — unknown mirror op escalation", () => {
|
|
|
241
213
|
.find((c) => c.id === "ct_merge"),
|
|
242
214
|
).toBeUndefined();
|
|
243
215
|
|
|
244
|
-
// No escalation:
|
|
245
|
-
expect(
|
|
246
|
-
expect(telemetryBodies).toEqual([]);
|
|
216
|
+
// No escalation: the report never fires.
|
|
217
|
+
expect(reportCalls).toEqual([]);
|
|
247
218
|
});
|
|
248
219
|
});
|
|
249
220
|
|
|
250
221
|
describe("upsertContact — unknown mirror op escalation", () => {
|
|
251
|
-
test("unknown method →
|
|
222
|
+
test("unknown method → fail-loud report; gateway upsert still applied", async () => {
|
|
252
223
|
ipcThrowOn.set(
|
|
253
224
|
"contacts_mirror_upsert_full",
|
|
254
225
|
unknownMethodError("contacts_mirror_upsert_full"),
|
|
@@ -259,7 +230,6 @@ describe("upsertContact — unknown mirror op escalation", () => {
|
|
|
259
230
|
displayName: "Alice",
|
|
260
231
|
channels: [{ type: "slack", address: "U12345" }],
|
|
261
232
|
});
|
|
262
|
-
await flushMirrorOpReporterForTesting();
|
|
263
233
|
|
|
264
234
|
// Gateway write stands.
|
|
265
235
|
expect(created).toBe(true);
|
|
@@ -272,20 +242,15 @@ describe("upsertContact — unknown mirror op escalation", () => {
|
|
|
272
242
|
).toBeDefined();
|
|
273
243
|
expect(store.getChannelsForContact(contact.id)).toHaveLength(1);
|
|
274
244
|
|
|
275
|
-
expect(
|
|
276
|
-
expect(errorLogs[0][0]).toMatchObject({
|
|
277
|
-
op: "contacts_mirror_upsert_full",
|
|
278
|
-
contactId: contact.id,
|
|
279
|
-
});
|
|
280
|
-
expect(telemetryBodies).toEqual([
|
|
245
|
+
expect(reportCalls).toEqual([
|
|
281
246
|
{
|
|
282
|
-
|
|
283
|
-
detail: {
|
|
247
|
+
op: "contacts_mirror_upsert_full",
|
|
248
|
+
detail: { contactId: contact.id },
|
|
284
249
|
},
|
|
285
250
|
]);
|
|
286
251
|
});
|
|
287
252
|
|
|
288
|
-
test("other IPC failure →
|
|
253
|
+
test("other IPC failure → no report, same gateway state", async () => {
|
|
289
254
|
ipcThrowOn.set("contacts_mirror_upsert_full", new Error("socket closed"));
|
|
290
255
|
|
|
291
256
|
const store = new ContactStore();
|
|
@@ -293,11 +258,9 @@ describe("upsertContact — unknown mirror op escalation", () => {
|
|
|
293
258
|
displayName: "Bob",
|
|
294
259
|
channels: [{ type: "slack", address: "U67890" }],
|
|
295
260
|
});
|
|
296
|
-
await flushMirrorOpReporterForTesting();
|
|
297
261
|
|
|
298
262
|
expect(created).toBe(true);
|
|
299
263
|
expect(store.getChannelsForContact(contact.id)).toHaveLength(1);
|
|
300
|
-
expect(
|
|
301
|
-
expect(telemetryBodies).toEqual([]);
|
|
264
|
+
expect(reportCalls).toEqual([]);
|
|
302
265
|
});
|
|
303
266
|
});
|
|
@@ -1,121 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fail-loud mirror-op-missing reporter (contacts-mirror-op-reporter.ts):
|
|
3
3
|
*
|
|
4
|
-
* - First report per op emits one error log
|
|
5
|
-
* the `contacts_mirror_op_missing` check name and `{ op, ...detail }`.
|
|
4
|
+
* - First report per op emits one error log carrying `{ op, ...detail }`.
|
|
6
5
|
* - Repeat reports for the same op inside the hourly window are dropped; a
|
|
7
6
|
* DIFFERENT op still reports (per-op rate limit).
|
|
8
|
-
* - Relay failures never throw out of the caller.
|
|
9
7
|
* - `isUnknownIpcMethodError` matches only the IPC server's unknown-method
|
|
10
8
|
* rejection shape.
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Logs are observed by mocking the logger module (a side-effect boundary —
|
|
11
|
+
* the same convention as sleep-wake-detector.test.ts). Per-file CI truth:
|
|
12
|
+
* the mock is process-global, so this file pins nothing else.
|
|
14
13
|
*/
|
|
15
|
-
import { beforeEach,
|
|
14
|
+
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
16
15
|
|
|
17
|
-
const errorLogs: unknown[] = [];
|
|
18
|
-
|
|
16
|
+
const errorLogs: [Record<string, unknown>, string][] = [];
|
|
17
|
+
|
|
18
|
+
mock.module("../logger.js", () => ({
|
|
19
|
+
getLogger: () => ({
|
|
20
|
+
error: (detail: Record<string, unknown>, msg: string) => {
|
|
21
|
+
errorLogs.push([detail, msg]);
|
|
22
|
+
},
|
|
23
|
+
warn: () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
19
26
|
|
|
20
27
|
await import("./test-preload.js");
|
|
21
|
-
const {
|
|
22
|
-
|
|
23
|
-
flushMirrorOpReporterForTesting,
|
|
24
|
-
isUnknownIpcMethodError,
|
|
25
|
-
reportMirrorOpMissing,
|
|
26
|
-
resetMirrorOpReporterForTesting,
|
|
27
|
-
setMirrorOpReporterOverridesForTesting,
|
|
28
|
-
} = await import("../contacts-mirror-op-reporter.js");
|
|
28
|
+
const { isUnknownIpcMethodError, reportMirrorOpMissing } =
|
|
29
|
+
await import("../contacts-mirror-op-reporter.js");
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
let fetchCalls: FetchCall[] = [];
|
|
32
|
-
let fetchResult: () => Promise<Response> = async () => new Response("{}");
|
|
31
|
+
let fakeNow = 1_000_000_000_000;
|
|
33
32
|
|
|
34
33
|
beforeEach(() => {
|
|
35
|
-
fetchCalls = [];
|
|
36
|
-
fetchResult = async () => new Response("{}");
|
|
37
34
|
errorLogs.length = 0;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
setMirrorOpReporterOverridesForTesting({
|
|
41
|
-
fetchImpl: async (url, init) => {
|
|
42
|
-
fetchCalls.push({ url: String(url), init: init ?? {} });
|
|
43
|
-
return fetchResult();
|
|
44
|
-
},
|
|
45
|
-
mintToken: () => "svc-token",
|
|
46
|
-
baseUrl: "http://127.0.0.1:7821",
|
|
47
|
-
log: {
|
|
48
|
-
error: (detail, msg) => {
|
|
49
|
-
errorLogs.push([detail, msg]);
|
|
50
|
-
},
|
|
51
|
-
warn: (detail, msg) => {
|
|
52
|
-
warnLogs.push([detail, msg]);
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterEach(() => {
|
|
59
|
-
resetMirrorOpReporterForTesting();
|
|
35
|
+
fakeNow += 24 * 60 * 60 * 1000; // start each test outside prior windows
|
|
36
|
+
spyOn(Date, "now").mockImplementation(() => fakeNow);
|
|
60
37
|
});
|
|
61
38
|
|
|
62
39
|
describe("reportMirrorOpMissing", () => {
|
|
63
|
-
test("first report logs
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
mergeId: "ct_merge",
|
|
67
|
-
});
|
|
68
|
-
await flushMirrorOpReporterForTesting();
|
|
69
|
-
|
|
70
|
-
expect(errorLogs.length).toBe(1);
|
|
71
|
-
expect(fetchCalls.length).toBe(1);
|
|
72
|
-
expect(fetchCalls[0].url).toBe(
|
|
73
|
-
"http://127.0.0.1:7821/v1/internal/telemetry/watchdog",
|
|
74
|
-
);
|
|
75
|
-
const headers = fetchCalls[0].init.headers as Record<string, string>;
|
|
76
|
-
expect(headers.Authorization).toBe("Bearer svc-token");
|
|
77
|
-
expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({
|
|
78
|
-
check_name: MIRROR_OP_MISSING_CHECK_NAME,
|
|
79
|
-
detail: {
|
|
80
|
-
op: "contacts_mirror_merge_contact",
|
|
40
|
+
test("first report logs one error with the op + detail", () => {
|
|
41
|
+
expect(() =>
|
|
42
|
+
reportMirrorOpMissing("contacts_mirror_merge_contact", {
|
|
81
43
|
keepId: "ct_keep",
|
|
82
44
|
mergeId: "ct_merge",
|
|
83
|
-
},
|
|
45
|
+
}),
|
|
46
|
+
).not.toThrow();
|
|
47
|
+
|
|
48
|
+
expect(errorLogs.length).toBe(1);
|
|
49
|
+
expect(errorLogs[0]![0]).toMatchObject({
|
|
50
|
+
op: "contacts_mirror_merge_contact",
|
|
51
|
+
keepId: "ct_keep",
|
|
52
|
+
mergeId: "ct_merge",
|
|
84
53
|
});
|
|
54
|
+
expect(errorLogs[0]![1]).toContain("divergence until the daemon updates");
|
|
85
55
|
});
|
|
86
56
|
|
|
87
|
-
test("repeat reports for the same op are dropped; a different op still fires",
|
|
57
|
+
test("repeat reports for the same op are dropped; a different op still fires", () => {
|
|
88
58
|
reportMirrorOpMissing("contacts_mirror_merge_contact", { keepId: "a" });
|
|
89
59
|
reportMirrorOpMissing("contacts_mirror_merge_contact", { keepId: "b" });
|
|
90
60
|
reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "c" });
|
|
91
|
-
await flushMirrorOpReporterForTesting();
|
|
92
61
|
|
|
93
62
|
expect(errorLogs.length).toBe(2);
|
|
94
|
-
expect(fetchCalls.length).toBe(2);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("a rejected relay never throws out of the caller", async () => {
|
|
98
|
-
fetchResult = async () => {
|
|
99
|
-
throw new Error("daemon unreachable");
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
expect(() =>
|
|
103
|
-
reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "x" }),
|
|
104
|
-
).not.toThrow();
|
|
105
|
-
await flushMirrorOpReporterForTesting();
|
|
106
|
-
|
|
107
|
-
expect(errorLogs.length).toBe(1);
|
|
108
|
-
expect(warnLogs.length).toBe(1);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("a non-ok relay response is swallowed with a warning", async () => {
|
|
112
|
-
fetchResult = async () => new Response("nope", { status: 500 });
|
|
113
|
-
|
|
114
|
-
reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "x" });
|
|
115
|
-
await flushMirrorOpReporterForTesting();
|
|
116
|
-
|
|
117
|
-
expect(fetchCalls.length).toBe(1);
|
|
118
|
-
expect(warnLogs.length).toBe(1);
|
|
119
63
|
});
|
|
120
64
|
});
|
|
121
65
|
|
|
@@ -121,7 +121,7 @@ describe("credential-request mint (IPC)", () => {
|
|
|
121
121
|
test("makes public ingress live on the successful mint path", async () => {
|
|
122
122
|
const ensurePublicIngressLive = mock(async () => {});
|
|
123
123
|
const result = await createCredentialRequest(
|
|
124
|
-
fakeGatewayConfig("https://tunnel
|
|
124
|
+
fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
|
|
125
125
|
fakeConfigFile(undefined),
|
|
126
126
|
{ service: "github", field: "api_token" },
|
|
127
127
|
{
|
|
@@ -152,7 +152,7 @@ describe("credential-request mint (IPC)", () => {
|
|
|
152
152
|
test("does not read the cloud assistant id when the config URL resolves", async () => {
|
|
153
153
|
const getCloudAssistantId = mock(async () => "assistant-123");
|
|
154
154
|
const result = await createCredentialRequest(
|
|
155
|
-
fakeGatewayConfig("https://tunnel
|
|
155
|
+
fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
|
|
156
156
|
fakeConfigFile("https://assistant.example.com"),
|
|
157
157
|
{ service: "github", field: "api_token" },
|
|
158
158
|
{ getCloudAssistantId },
|
|
@@ -165,7 +165,7 @@ describe("credential-request mint (IPC)", () => {
|
|
|
165
165
|
// ingress.publicBaseUrl is Tunnel-managed and cleared on tunnel flaps, so
|
|
166
166
|
// the mint must still resolve a URL from tunnelBaseUrl + the assistant id.
|
|
167
167
|
const result = await createCredentialRequest(
|
|
168
|
-
fakeGatewayConfig("https://tunnel
|
|
168
|
+
fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
|
|
169
169
|
fakeConfigFile(undefined),
|
|
170
170
|
{ service: "github", field: "api_token" },
|
|
171
171
|
{ getCloudAssistantId: async () => "assistant-123" },
|
|
@@ -173,7 +173,7 @@ describe("credential-request mint (IPC)", () => {
|
|
|
173
173
|
|
|
174
174
|
if (!result.ok) throw new Error(`mint failed: ${result.error}`);
|
|
175
175
|
expect(result.url).toBe(
|
|
176
|
-
`https://tunnel
|
|
176
|
+
`https://dev-tunnel.mycadet.ai/assistant-123/assistant/credentials/enter#token=${encodeURIComponent(result.token)}`,
|
|
177
177
|
);
|
|
178
178
|
});
|
|
179
179
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GET /v1/devices/mine — the caller's own paired-device row. The
|
|
3
|
+
* bearer IS the credential (browser-reachable by design), and the response
|
|
4
|
+
* mirrors the /v1/devices projection for the caller's device so a
|
|
5
|
+
* `devices/mine` answer matches that device's row in the host-side
|
|
6
|
+
* `cadet devices` list field for field — including the cross-status
|
|
7
|
+
* max(lastUsedAt) that keeps activity history across token rotation.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
15
|
+
|
|
16
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
17
|
+
|
|
18
|
+
initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
|
|
19
|
+
|
|
20
|
+
// The pair guardian-lookup reads the gateway DB; the assistant DB proxy is
|
|
21
|
+
// mocked so any incidental assistant access stays inert in tests.
|
|
22
|
+
mock.module("../db/assistant-db-proxy.js", () => ({
|
|
23
|
+
assistantDbQuery: mock(),
|
|
24
|
+
assistantDbRun: mock(),
|
|
25
|
+
assistantDbExec: mock(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { initGatewayDb, resetGatewayDb, getGatewayDb } = await import(
|
|
29
|
+
"../db/connection.js"
|
|
30
|
+
);
|
|
31
|
+
const { actorTokenRecords, contacts, contactChannels } = await import(
|
|
32
|
+
"../db/schema.js"
|
|
33
|
+
);
|
|
34
|
+
const { hashToken, mintAndRecordBrowserTokenPair } = await import(
|
|
35
|
+
"../auth/guardian-bootstrap.js"
|
|
36
|
+
);
|
|
37
|
+
const { handleGetMyDevice } = await import("../http/routes/devices-mine.js");
|
|
38
|
+
|
|
39
|
+
const GUARDIAN_ID = "guardian-001";
|
|
40
|
+
|
|
41
|
+
let testRoot: string;
|
|
42
|
+
|
|
43
|
+
function mineRequest(bearer: string | null): Request {
|
|
44
|
+
const headers = new Headers({ host: "localhost:7830" });
|
|
45
|
+
if (bearer) {
|
|
46
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
47
|
+
}
|
|
48
|
+
return new Request("http://localhost:7830/v1/devices/mine", {
|
|
49
|
+
method: "GET",
|
|
50
|
+
headers,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rowForToken(token: string) {
|
|
55
|
+
return getGatewayDb()
|
|
56
|
+
.select()
|
|
57
|
+
.from(actorTokenRecords)
|
|
58
|
+
.where(eq(actorTokenRecords.tokenHash, hashToken(token)))
|
|
59
|
+
.get();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
testRoot = mkdtempSync(join(tmpdir(), "devices-mine-test-"));
|
|
64
|
+
const securityDir = join(testRoot, "protected");
|
|
65
|
+
mkdirSync(securityDir, { recursive: true });
|
|
66
|
+
process.env.GATEWAY_SECURITY_DIR = securityDir;
|
|
67
|
+
await initGatewayDb();
|
|
68
|
+
|
|
69
|
+
// The route answers for any actor-token row; the guardian contact seed
|
|
70
|
+
// mirrors the devices.test.ts fixture shape.
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
getGatewayDb()
|
|
73
|
+
.insert(contacts)
|
|
74
|
+
.values({
|
|
75
|
+
id: GUARDIAN_ID,
|
|
76
|
+
displayName: "Guardian",
|
|
77
|
+
role: "guardian",
|
|
78
|
+
principalId: GUARDIAN_ID,
|
|
79
|
+
createdAt: now,
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
})
|
|
82
|
+
.run();
|
|
83
|
+
getGatewayDb()
|
|
84
|
+
.insert(contactChannels)
|
|
85
|
+
.values({
|
|
86
|
+
id: `ch-${GUARDIAN_ID}`,
|
|
87
|
+
contactId: GUARDIAN_ID,
|
|
88
|
+
type: "cadet",
|
|
89
|
+
address: "guardian-cadet",
|
|
90
|
+
isPrimary: false,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
})
|
|
94
|
+
.run();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
resetGatewayDb();
|
|
99
|
+
delete process.env.GATEWAY_SECURITY_DIR;
|
|
100
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("GET /v1/devices/mine", () => {
|
|
104
|
+
test("returns the caller's own device row", async () => {
|
|
105
|
+
const pair = mintAndRecordBrowserTokenPair({
|
|
106
|
+
guardianPrincipalId: GUARDIAN_ID,
|
|
107
|
+
platform: "web",
|
|
108
|
+
browserRefreshCookiePath: "/",
|
|
109
|
+
identity: { pairingUserAgent: "Mozilla/5.0 TestBrowser" },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const response = await handleGetMyDevice(mineRequest(pair.accessToken));
|
|
113
|
+
expect(response.status).toBe(200);
|
|
114
|
+
|
|
115
|
+
const body = (await response.json()) as { device: Record<string, unknown> };
|
|
116
|
+
const stored = rowForToken(pair.accessToken);
|
|
117
|
+
expect(stored).toBeTruthy();
|
|
118
|
+
expect(body.device.hashedDeviceId).toBe(stored!.hashedDeviceId);
|
|
119
|
+
expect(body.device.platform).toBe("web");
|
|
120
|
+
expect(body.device.pairingUserAgent).toBe("Mozilla/5.0 TestBrowser");
|
|
121
|
+
expect(body.device.issuedAt).toBe(stored!.issuedAt);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("lastUsedAt is the cross-status max for the device", async () => {
|
|
125
|
+
const pair = mintAndRecordBrowserTokenPair({
|
|
126
|
+
guardianPrincipalId: GUARDIAN_ID,
|
|
127
|
+
platform: "web",
|
|
128
|
+
browserRefreshCookiePath: "/",
|
|
129
|
+
});
|
|
130
|
+
const stored = rowForToken(pair.accessToken);
|
|
131
|
+
expect(stored).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
// A rotation revokes the old row and mints an unstamped replacement,
|
|
134
|
+
// so the newest activity stamp can live on the revoked row — the same
|
|
135
|
+
// rule the /v1/devices list applies.
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
getGatewayDb()
|
|
138
|
+
.insert(actorTokenRecords)
|
|
139
|
+
.values({
|
|
140
|
+
id: randomUUID(),
|
|
141
|
+
tokenHash: hashToken(`rotated-${stored!.hashedDeviceId}`),
|
|
142
|
+
guardianPrincipalId: GUARDIAN_ID,
|
|
143
|
+
hashedDeviceId: stored!.hashedDeviceId,
|
|
144
|
+
platform: "web",
|
|
145
|
+
status: "revoked",
|
|
146
|
+
issuedAt: now - 86_400_000,
|
|
147
|
+
expiresAt: now + 86_400_000,
|
|
148
|
+
lastUsedAt: now - 60_000,
|
|
149
|
+
createdAt: now,
|
|
150
|
+
updatedAt: now,
|
|
151
|
+
})
|
|
152
|
+
.run();
|
|
153
|
+
|
|
154
|
+
const response = await handleGetMyDevice(mineRequest(pair.accessToken));
|
|
155
|
+
expect(response.status).toBe(200);
|
|
156
|
+
const body = (await response.json()) as { device: { lastUsedAt: number | null } };
|
|
157
|
+
expect(body.device.lastUsedAt).toBe(now - 60_000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an unknown bearer answers 404, not an error", async () => {
|
|
161
|
+
const response = await handleGetMyDevice(
|
|
162
|
+
mineRequest("not-a-token-anyone-minted"),
|
|
163
|
+
);
|
|
164
|
+
expect(response.status).toBe(404);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a cloud-hosted assistant answers 404 before any row lookup", async () => {
|
|
168
|
+
process.env.DISABLE_HTTP_AUTH = "true";
|
|
169
|
+
process.env.IS_CLOUD = "true";
|
|
170
|
+
try {
|
|
171
|
+
const response = await handleGetMyDevice(mineRequest(null));
|
|
172
|
+
expect(response.status).toBe(404);
|
|
173
|
+
const body = (await response.json()) as { error: { code: string } };
|
|
174
|
+
expect(body.error.code).toBe("NOT_AVAILABLE_ON_CLOUD");
|
|
175
|
+
} finally {
|
|
176
|
+
delete process.env.DISABLE_HTTP_AUTH;
|
|
177
|
+
delete process.env.IS_CLOUD;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("non-GET answers method not allowed", async () => {
|
|
182
|
+
const response = await handleGetMyDevice(
|
|
183
|
+
new Request("http://localhost:7830/v1/devices/mine", { method: "POST" }),
|
|
184
|
+
);
|
|
185
|
+
expect(response.status).toBe(405);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -64,8 +64,7 @@ mock.module("../auth/token-exchange.js", () => ({
|
|
|
64
64
|
}));
|
|
65
65
|
|
|
66
66
|
const { AuthRateLimiter } = await import("../auth-rate-limiter.js");
|
|
67
|
-
const { createAuthMiddleware
|
|
68
|
-
await import("../http/middleware/auth.js");
|
|
67
|
+
const { createAuthMiddleware } = await import("../http/middleware/auth.js");
|
|
69
68
|
const { initGatewayDb, resetGatewayDb, getGatewayDb } =
|
|
70
69
|
await import("../db/connection.js");
|
|
71
70
|
const { actorTokenRecords } = await import("../db/schema.js");
|
|
@@ -96,7 +95,6 @@ beforeEach(() => {
|
|
|
96
95
|
mockReadCredential = mock(async () => undefined);
|
|
97
96
|
mockPlatformUserIdUnreachable = false;
|
|
98
97
|
mockValidateEdgeToken = mock(() => ({ ok: false, reason: "noop" }));
|
|
99
|
-
loopbackFallbackCountTracker.reset();
|
|
100
98
|
});
|
|
101
99
|
|
|
102
100
|
afterEach(() => {
|
|
@@ -238,21 +236,6 @@ describe("requireEdgeAuth — JWT mode", () => {
|
|
|
238
236
|
);
|
|
239
237
|
expect(res?.status).toBe(401);
|
|
240
238
|
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
241
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test("a loopback fallback is counted by (guard, path, failureKind)", async () => {
|
|
245
|
-
const { requireEdgeAuth } = makeMiddleware();
|
|
246
|
-
await requireEdgeAuth(makeReq(), makeLoopbackServer());
|
|
247
|
-
await requireEdgeAuth(makeReq(), makeLoopbackServer());
|
|
248
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([
|
|
249
|
-
{
|
|
250
|
-
guard: "edge",
|
|
251
|
-
path: "/v1/something",
|
|
252
|
-
failureKind: "missing_authorization",
|
|
253
|
-
count: 2,
|
|
254
|
-
},
|
|
255
|
-
]);
|
|
256
239
|
});
|
|
257
240
|
|
|
258
241
|
test("null on valid bearer token", async () => {
|
|
@@ -389,7 +372,6 @@ describe("requireEdgeAuthWithScope — JWT mode", () => {
|
|
|
389
372
|
);
|
|
390
373
|
expect(res?.status).toBe(401);
|
|
391
374
|
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
392
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
393
375
|
});
|
|
394
376
|
|
|
395
377
|
test("403 when token's scope_profile lacks the required scope", async () => {
|
|
@@ -512,7 +494,6 @@ describe("edge auth refuses a single-route grant", () => {
|
|
|
512
494
|
makeLoopbackServer(),
|
|
513
495
|
);
|
|
514
496
|
expect(res?.status).toBe(401);
|
|
515
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
516
497
|
});
|
|
517
498
|
|
|
518
499
|
test("requireEdgeAuthWithScope 401s the grant before the scope check", async () => {
|
|
@@ -53,8 +53,7 @@ mock.module("../auth/token-exchange.js", () => ({
|
|
|
53
53
|
}));
|
|
54
54
|
|
|
55
55
|
const { AuthRateLimiter } = await import("../auth-rate-limiter.js");
|
|
56
|
-
const { createAuthMiddleware
|
|
57
|
-
await import("../http/middleware/auth.js");
|
|
56
|
+
const { createAuthMiddleware } = await import("../http/middleware/auth.js");
|
|
58
57
|
|
|
59
58
|
const PLATFORM_USER_ID = "user-abc-123";
|
|
60
59
|
const GUARDIAN_PRINCIPAL = "actor-guardian-xyz";
|
|
@@ -82,7 +81,6 @@ beforeEach(() => {
|
|
|
82
81
|
mockPlatformUserIdUnreachable = false;
|
|
83
82
|
mockFindCadetGuardian = mock(async () => null);
|
|
84
83
|
mockValidateEdgeToken = mock(() => ({ ok: false, reason: "noop" }));
|
|
85
|
-
loopbackFallbackCountTracker.reset();
|
|
86
84
|
});
|
|
87
85
|
|
|
88
86
|
afterEach(() => {
|
|
@@ -210,7 +208,6 @@ describe("requireEdgeGuardianAuth — actor principal mode", () => {
|
|
|
210
208
|
expect(res?.status).toBe(401);
|
|
211
209
|
expect(mockValidateEdgeToken).not.toHaveBeenCalled();
|
|
212
210
|
expect(mockFindCadetGuardian).not.toHaveBeenCalled();
|
|
213
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
|
|
214
211
|
});
|
|
215
212
|
|
|
216
213
|
test("invalid bearer token falls back to loopback", async () => {
|
|
@@ -224,19 +221,6 @@ describe("requireEdgeGuardianAuth — actor principal mode", () => {
|
|
|
224
221
|
expect(mockFindCadetGuardian).not.toHaveBeenCalled();
|
|
225
222
|
});
|
|
226
223
|
|
|
227
|
-
test("a guardian loopback fallback is counted under the edge-guardian guard", async () => {
|
|
228
|
-
const { requireEdgeGuardianAuth } = makeMiddleware();
|
|
229
|
-
await requireEdgeGuardianAuth(makeReq(), makeLoopbackServer());
|
|
230
|
-
expect(loopbackFallbackCountTracker.snapshot()).toEqual([
|
|
231
|
-
{
|
|
232
|
-
guard: "edge-guardian",
|
|
233
|
-
path: "/v1/contact-channels/abc/verify",
|
|
234
|
-
failureKind: "missing_authorization",
|
|
235
|
-
count: 1,
|
|
236
|
-
},
|
|
237
|
-
]);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
224
|
test("returns 503 when findCadetGuardian throws (transient assistant DB outage)", async () => {
|
|
241
225
|
mockValidateEdgeToken = mock(() => ({
|
|
242
226
|
ok: true,
|