@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
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { AuthFallbackCountTracker } from "../auth-fallback-count-tracker.js";
|
|
4
|
-
|
|
5
|
-
describe("AuthFallbackCountTracker", () => {
|
|
6
|
-
test("increments per (guard, path, failureKind) key", () => {
|
|
7
|
-
const t = new AuthFallbackCountTracker(0);
|
|
8
|
-
t.increment("edge", "/v1/chat", "missing_authorization");
|
|
9
|
-
t.increment("edge", "/v1/chat", "missing_authorization");
|
|
10
|
-
t.increment("edge", "/v1/chat", "token_validation_failed");
|
|
11
|
-
t.increment("edge-guardian", "/v1/chat", "missing_authorization");
|
|
12
|
-
|
|
13
|
-
const snap = t.snapshot();
|
|
14
|
-
expect(snap.length).toBe(3);
|
|
15
|
-
const find = (g: string, p: string, f: string) =>
|
|
16
|
-
snap.find((c) => c.guard === g && c.path === p && c.failureKind === f)
|
|
17
|
-
?.count;
|
|
18
|
-
expect(find("edge", "/v1/chat", "missing_authorization")).toBe(2);
|
|
19
|
-
expect(find("edge", "/v1/chat", "token_validation_failed")).toBe(1);
|
|
20
|
-
expect(find("edge-guardian", "/v1/chat", "missing_authorization")).toBe(1);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("drain returns the window + counts and resets", () => {
|
|
24
|
-
const t = new AuthFallbackCountTracker(1000);
|
|
25
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
26
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
27
|
-
|
|
28
|
-
const batch = t.drain(2000);
|
|
29
|
-
expect(batch.windowStart).toBe(1000);
|
|
30
|
-
expect(batch.windowEnd).toBe(2000);
|
|
31
|
-
expect(batch.counts).toEqual([
|
|
32
|
-
{
|
|
33
|
-
guard: "edge",
|
|
34
|
-
path: "/v1/a",
|
|
35
|
-
failureKind: "missing_authorization",
|
|
36
|
-
count: 2,
|
|
37
|
-
},
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
// Drained — tracker is empty and the window is re-anchored to the drain.
|
|
41
|
-
expect(t.snapshot()).toEqual([]);
|
|
42
|
-
const empty = t.drain(3000);
|
|
43
|
-
expect(empty.counts).toEqual([]);
|
|
44
|
-
expect(empty.windowStart).toBe(2000);
|
|
45
|
-
expect(empty.windowEnd).toBe(3000);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("an empty drain leaves the window start anchored", () => {
|
|
49
|
-
const t = new AuthFallbackCountTracker(1000);
|
|
50
|
-
// Nothing recorded yet — draining must not shift windowStart forward.
|
|
51
|
-
expect(t.drain(2000)).toMatchObject({ windowStart: 1000, windowEnd: 2000 });
|
|
52
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
53
|
-
expect(t.drain(3000).windowStart).toBe(1000);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("merge folds a drained batch back in", () => {
|
|
57
|
-
const t = new AuthFallbackCountTracker(0);
|
|
58
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
59
|
-
const batch = t.drain(100);
|
|
60
|
-
|
|
61
|
-
// Simulate a failed flush: counts come back, plus a newer count for the
|
|
62
|
-
// same key that accumulated after the drain.
|
|
63
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
64
|
-
t.merge(batch.counts);
|
|
65
|
-
|
|
66
|
-
const snap = t.snapshot();
|
|
67
|
-
expect(snap.length).toBe(1);
|
|
68
|
-
expect(snap[0].count).toBe(2);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("caps distinct keys but keeps counting existing ones", () => {
|
|
72
|
-
const t = new AuthFallbackCountTracker(0);
|
|
73
|
-
// MAX_TRACKED_KEYS is 10_000; exceed it with distinct paths.
|
|
74
|
-
for (let i = 0; i < 10_050; i++) {
|
|
75
|
-
t.increment("edge", `/v1/p${i}`, "missing_authorization");
|
|
76
|
-
}
|
|
77
|
-
const snap = t.snapshot();
|
|
78
|
-
expect(snap.length).toBe(10_000);
|
|
79
|
-
|
|
80
|
-
// An already-tracked key still increments past the cap.
|
|
81
|
-
t.increment("edge", "/v1/p0", "missing_authorization");
|
|
82
|
-
expect(t.snapshot().find((c) => c.path === "/v1/p0")?.count).toBe(2);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("reset clears counts", () => {
|
|
86
|
-
const t = new AuthFallbackCountTracker(0);
|
|
87
|
-
t.increment("edge", "/v1/a", "missing_authorization");
|
|
88
|
-
t.reset(500);
|
|
89
|
-
expect(t.snapshot()).toEqual([]);
|
|
90
|
-
expect(t.drain(600).windowStart).toBe(500);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { AuthFallbackCountTracker } from "../auth-fallback-count-tracker.js";
|
|
4
|
-
import { AuthFallbackReporter } from "../auth-fallback-reporter.js";
|
|
5
|
-
|
|
6
|
-
const BASE_URL = "http://127.0.0.1:7821";
|
|
7
|
-
|
|
8
|
-
function makeReporter(
|
|
9
|
-
tracker: AuthFallbackCountTracker,
|
|
10
|
-
fetchImpl: typeof import("../fetch.js").fetchImpl,
|
|
11
|
-
) {
|
|
12
|
-
return new AuthFallbackReporter({
|
|
13
|
-
tracker,
|
|
14
|
-
baseUrl: BASE_URL,
|
|
15
|
-
intervalMs: 60_000,
|
|
16
|
-
fetchImpl,
|
|
17
|
-
mintToken: () => "test-service-token",
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe("AuthFallbackReporter", () => {
|
|
22
|
-
test("does nothing when there is nothing to flush", async () => {
|
|
23
|
-
const tracker = new AuthFallbackCountTracker(0);
|
|
24
|
-
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
25
|
-
await makeReporter(tracker, fetchMock as never).flush();
|
|
26
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("drains and POSTs the counts to the daemon route with a service token", async () => {
|
|
30
|
-
const tracker = new AuthFallbackCountTracker(0);
|
|
31
|
-
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
32
|
-
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
33
|
-
tracker.increment("edge-guardian", "/v1/sync", "guardian_mismatch");
|
|
34
|
-
|
|
35
|
-
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
36
|
-
await makeReporter(tracker, fetchMock as never).flush();
|
|
37
|
-
|
|
38
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
39
|
-
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
|
40
|
-
string,
|
|
41
|
-
RequestInit,
|
|
42
|
-
];
|
|
43
|
-
expect(url).toBe(`${BASE_URL}/v1/internal/telemetry/auth-fallback`);
|
|
44
|
-
expect(init.method).toBe("POST");
|
|
45
|
-
const headers = init.headers as Record<string, string>;
|
|
46
|
-
expect(headers["Content-Type"]).toBe("application/json");
|
|
47
|
-
expect(headers["Authorization"]).toBe("Bearer test-service-token");
|
|
48
|
-
|
|
49
|
-
const body = JSON.parse(init.body as string);
|
|
50
|
-
expect(typeof body.window_start).toBe("number");
|
|
51
|
-
expect(typeof body.window_end).toBe("number");
|
|
52
|
-
expect(body.counts).toEqual(
|
|
53
|
-
expect.arrayContaining([
|
|
54
|
-
{
|
|
55
|
-
guard: "edge",
|
|
56
|
-
path: "/v1/chat",
|
|
57
|
-
failure_kind: "missing_authorization",
|
|
58
|
-
count: 2,
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
guard: "edge-guardian",
|
|
62
|
-
path: "/v1/sync",
|
|
63
|
-
failure_kind: "guardian_mismatch",
|
|
64
|
-
count: 1,
|
|
65
|
-
},
|
|
66
|
-
]),
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
// Successful flush drains the tracker.
|
|
70
|
-
expect(tracker.snapshot()).toEqual([]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("re-queues counts when the daemon returns a non-OK status", async () => {
|
|
74
|
-
const tracker = new AuthFallbackCountTracker(0);
|
|
75
|
-
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
76
|
-
|
|
77
|
-
const fetchMock = mock(async () => new Response("nope", { status: 404 }));
|
|
78
|
-
await makeReporter(tracker, fetchMock as never).flush();
|
|
79
|
-
|
|
80
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
81
|
-
// Counts merged back so the next flush retries them.
|
|
82
|
-
const snap = tracker.snapshot();
|
|
83
|
-
expect(snap.length).toBe(1);
|
|
84
|
-
expect(snap[0].count).toBe(1);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("re-queues counts when the POST throws", async () => {
|
|
88
|
-
const tracker = new AuthFallbackCountTracker(0);
|
|
89
|
-
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
90
|
-
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
91
|
-
|
|
92
|
-
const fetchMock = mock(async () => {
|
|
93
|
-
throw new Error("connection refused");
|
|
94
|
-
});
|
|
95
|
-
await makeReporter(tracker, fetchMock as never).flush();
|
|
96
|
-
|
|
97
|
-
const snap = tracker.snapshot();
|
|
98
|
-
expect(snap.length).toBe(1);
|
|
99
|
-
expect(snap[0].count).toBe(2);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { afterEach, expect, mock, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
let exportRequested = false;
|
|
4
|
-
const originalIsPlatform = process.env.IS_CLOUD;
|
|
5
|
-
|
|
6
|
-
mock.module("../config-file-utils.js", () => ({
|
|
7
|
-
readConfigFileOrEmpty: () => ({
|
|
8
|
-
backup: {
|
|
9
|
-
enabled: true,
|
|
10
|
-
intervalHours: 1,
|
|
11
|
-
retention: 3,
|
|
12
|
-
offsite: { enabled: false },
|
|
13
|
-
},
|
|
14
|
-
}),
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
mock.module("../fetch.js", () => ({
|
|
18
|
-
fetchImpl: async () => {
|
|
19
|
-
exportRequested = true;
|
|
20
|
-
throw new Error("Platform backup export must not be requested");
|
|
21
|
-
},
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
if (originalIsPlatform === undefined) {
|
|
26
|
-
delete process.env.IS_CLOUD;
|
|
27
|
-
} else {
|
|
28
|
-
process.env.IS_CLOUD = originalIsPlatform;
|
|
29
|
-
}
|
|
30
|
-
mock.restore();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("platform mode disables the backup worker", async () => {
|
|
34
|
-
process.env.IS_CLOUD = "true";
|
|
35
|
-
const { startBackupWorker } = await import("../backup/backup-worker.js");
|
|
36
|
-
|
|
37
|
-
const worker = startBackupWorker({
|
|
38
|
-
assistantRuntimeBaseUrl: "http://127.0.0.1:7821",
|
|
39
|
-
});
|
|
40
|
-
await worker.runOnce();
|
|
41
|
-
worker.stop();
|
|
42
|
-
|
|
43
|
-
expect(exportRequested).toBeFalse();
|
|
44
|
-
});
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression tests for the export deadline in the backup worker.
|
|
3
|
-
*
|
|
4
|
-
* `fetch` resolves as soon as the response headers arrive; the bundle streams
|
|
5
|
-
* in afterwards. The worker used to clear its abort timer in a `finally` on
|
|
6
|
-
* that call, which left the download — the part that actually takes an hour on
|
|
7
|
-
* a large workspace — with no deadline at all. A body that stalled after
|
|
8
|
-
* headers hung `performBackup` forever, and with it the `snapshotInProgress`
|
|
9
|
-
* mutex, so every later tick logged "snapshot already in progress" until the
|
|
10
|
-
* gateway was restarted.
|
|
11
|
-
*
|
|
12
|
-
* These drive a response whose headers arrive normally and whose body never
|
|
13
|
-
* produces a chunk, and assert the run gives up and lets go of the mutex.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
20
|
-
|
|
21
|
-
let localDir: string;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The failure this file guards against is a hang, which a bare `await` would
|
|
25
|
-
* inherit — a regression would stall the runner instead of reporting. Race
|
|
26
|
-
* every run against a deadline so it fails fast and says why.
|
|
27
|
-
*/
|
|
28
|
-
function withDeadline<T>(work: Promise<T>, label: string): Promise<T> {
|
|
29
|
-
return Promise.race([
|
|
30
|
-
work,
|
|
31
|
-
new Promise<never>((_, reject) =>
|
|
32
|
-
setTimeout(
|
|
33
|
-
() => reject(new Error(`${label} did not settle within 10s`)),
|
|
34
|
-
10_000,
|
|
35
|
-
),
|
|
36
|
-
),
|
|
37
|
-
]);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Records the init the worker passed to fetch, so a test can assert on the
|
|
42
|
-
* deadline options rather than waiting one out.
|
|
43
|
-
*/
|
|
44
|
-
let lastInit: (RequestInit & { timeout?: boolean | number }) | undefined;
|
|
45
|
-
|
|
46
|
-
/** A response that sends headers immediately and then never sends a chunk. */
|
|
47
|
-
function stalledBodyResponse(): Response {
|
|
48
|
-
const body = new ReadableStream<Uint8Array>({
|
|
49
|
-
start(controller) {
|
|
50
|
-
// One chunk so the transfer visibly begins, then silence forever.
|
|
51
|
-
controller.enqueue(new Uint8Array([0x1f, 0x8b]));
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
return new Response(body, { status: 200 });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
beforeEach(async () => {
|
|
58
|
-
localDir = await mkdtemp(join(tmpdir(), "backup-worker-timeout-"));
|
|
59
|
-
|
|
60
|
-
mock.module("../config-file-utils.js", () => ({
|
|
61
|
-
readConfigFileOrEmpty: () => ({
|
|
62
|
-
backup: {
|
|
63
|
-
enabled: true,
|
|
64
|
-
intervalHours: 6,
|
|
65
|
-
retention: 3,
|
|
66
|
-
// Offsite off keeps the run local: no encryption key is needed, so
|
|
67
|
-
// nothing touches the gateway security dir.
|
|
68
|
-
offsite: { enabled: false },
|
|
69
|
-
localDirectory: localDir,
|
|
70
|
-
},
|
|
71
|
-
}),
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
mock.module("../auth/token-exchange.js", () => ({
|
|
75
|
-
mintServiceToken: () => "test-service-token",
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
mock.module("../fetch.js", () => ({
|
|
79
|
-
fetchImpl: async (
|
|
80
|
-
_input: string | URL | Request,
|
|
81
|
-
init?: RequestInit & { timeout?: boolean | number },
|
|
82
|
-
) => {
|
|
83
|
-
lastInit = init;
|
|
84
|
-
init?.signal?.throwIfAborted();
|
|
85
|
-
return stalledBodyResponse();
|
|
86
|
-
},
|
|
87
|
-
}));
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
afterEach(async () => {
|
|
91
|
-
mock.restore();
|
|
92
|
-
await rm(localDir, { recursive: true, force: true });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("backup export deadline", () => {
|
|
96
|
-
test("a body that stalls after headers aborts instead of hanging", async () => {
|
|
97
|
-
const { createSnapshotNow } = await import("../backup/backup-worker.js");
|
|
98
|
-
|
|
99
|
-
const startedAt = Date.now();
|
|
100
|
-
const run = withDeadline(
|
|
101
|
-
createSnapshotNow({
|
|
102
|
-
assistantRuntimeBaseUrl: "http://127.0.0.1:7821",
|
|
103
|
-
exportTimeoutMs: 300,
|
|
104
|
-
}),
|
|
105
|
-
"stalled export",
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
await expect(run).rejects.toThrow(/aborted|abort/i);
|
|
109
|
-
// The deadline fired rather than the call returning on its own; the guard
|
|
110
|
-
// is that this resolved at all, well inside the real 60-minute budget.
|
|
111
|
-
expect(Date.now() - startedAt).toBeLessThan(15_000);
|
|
112
|
-
}, 30_000);
|
|
113
|
-
|
|
114
|
-
test("the concurrency mutex is released after a stalled transfer", async () => {
|
|
115
|
-
const { createSnapshotNow } = await import("../backup/backup-worker.js");
|
|
116
|
-
|
|
117
|
-
const deps = {
|
|
118
|
-
assistantRuntimeBaseUrl: "http://127.0.0.1:7821",
|
|
119
|
-
exportTimeoutMs: 300,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
await expect(
|
|
123
|
-
withDeadline(createSnapshotNow(deps), "first stalled export"),
|
|
124
|
-
).rejects.toThrow();
|
|
125
|
-
|
|
126
|
-
// A wedged mutex surfaces here: the second run is refused outright rather
|
|
127
|
-
// than getting as far as its own stalled transfer.
|
|
128
|
-
const secondFailure = await withDeadline(
|
|
129
|
-
createSnapshotNow(deps),
|
|
130
|
-
"second stalled export",
|
|
131
|
-
).then(
|
|
132
|
-
() => null,
|
|
133
|
-
(err: unknown) => (err instanceof Error ? err.message : String(err)),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
expect(secondFailure).not.toBeNull();
|
|
137
|
-
expect(secondFailure).not.toContain("already in progress");
|
|
138
|
-
}, 30_000);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("backup export request options", () => {
|
|
142
|
-
test("opts out of the runtime's default request timeout", async () => {
|
|
143
|
-
// The daemon builds the entire archive before it can write a response
|
|
144
|
-
// header, so on a large workspace no header arrives for many minutes. The
|
|
145
|
-
// runtime's 300-second default would kill that request — and it is NOT
|
|
146
|
-
// extended by the abort signal below, so the explicit budget alone does
|
|
147
|
-
// not save it. This is the option that makes the budget real.
|
|
148
|
-
const { createSnapshotNow } = await import("../backup/backup-worker.js");
|
|
149
|
-
|
|
150
|
-
await expect(
|
|
151
|
-
withDeadline(
|
|
152
|
-
createSnapshotNow({
|
|
153
|
-
assistantRuntimeBaseUrl: "http://127.0.0.1:7821",
|
|
154
|
-
exportTimeoutMs: 300,
|
|
155
|
-
}),
|
|
156
|
-
"stalled export",
|
|
157
|
-
),
|
|
158
|
-
).rejects.toThrow();
|
|
159
|
-
|
|
160
|
-
expect(lastInit?.timeout).toBe(false);
|
|
161
|
-
// The caller's own deadline is still what governs.
|
|
162
|
-
expect(lastInit?.signal).toBeInstanceOf(AbortSignal);
|
|
163
|
-
}, 30_000);
|
|
164
|
-
});
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
-
import type { GatewayConfig } from "../config.js";
|
|
3
|
-
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
-
|
|
5
|
-
const TEST_SIGNING_KEY = Buffer.from("test-signing-key-at-least-32-bytes-long");
|
|
6
|
-
initSigningKey(TEST_SIGNING_KEY);
|
|
7
|
-
|
|
8
|
-
type FetchFn = (
|
|
9
|
-
input: string | URL | Request,
|
|
10
|
-
init?: RequestInit,
|
|
11
|
-
) => Promise<Response>;
|
|
12
|
-
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
|
|
13
|
-
async () => new Response(),
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
mock.module("../fetch.js", () => ({
|
|
17
|
-
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
const {
|
|
21
|
-
createMigrationExportToGcsProxyHandler,
|
|
22
|
-
createMigrationImportFromGcsProxyHandler,
|
|
23
|
-
createMigrationJobStatusProxyHandler,
|
|
24
|
-
} = await import("../http/routes/migration-proxy.js");
|
|
25
|
-
|
|
26
|
-
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
27
|
-
const merged: GatewayConfig = {
|
|
28
|
-
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
29
|
-
routingEntries: [],
|
|
30
|
-
port: 7830,
|
|
31
|
-
runtimeProxyRequireAuth: true,
|
|
32
|
-
runtimeTimeoutMs: 30000,
|
|
33
|
-
runtimeMaxRetries: 2,
|
|
34
|
-
runtimeInitialBackoffMs: 500,
|
|
35
|
-
maxWebhookPayloadBytes: 1048576,
|
|
36
|
-
logFile: { dir: undefined, retentionDays: 30 },
|
|
37
|
-
maxAttachmentBytes: {
|
|
38
|
-
telegram: 50 * 1024 * 1024,
|
|
39
|
-
slack: 100 * 1024 * 1024,
|
|
40
|
-
whatsapp: 16 * 1024 * 1024,
|
|
41
|
-
default: 50 * 1024 * 1024,
|
|
42
|
-
},
|
|
43
|
-
maxAttachmentConcurrency: 3,
|
|
44
|
-
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
45
|
-
trustProxy: false,
|
|
46
|
-
...overrides,
|
|
47
|
-
};
|
|
48
|
-
return merged;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
afterEach(() => {
|
|
52
|
-
fetchMock = mock(async () => new Response());
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("teleport-gcs migration proxies", () => {
|
|
56
|
-
test("export-to-gcs: forwards POST body to daemon and returns 202", async () => {
|
|
57
|
-
let capturedUrl: string | undefined;
|
|
58
|
-
let capturedMethod: string | undefined;
|
|
59
|
-
let capturedBody: ArrayBuffer | undefined;
|
|
60
|
-
let capturedHeaders: Headers | undefined;
|
|
61
|
-
fetchMock = mock(
|
|
62
|
-
async (input: string | URL | Request, init?: RequestInit) => {
|
|
63
|
-
capturedUrl = String(input);
|
|
64
|
-
capturedMethod = init?.method;
|
|
65
|
-
capturedBody = init?.body as ArrayBuffer;
|
|
66
|
-
capturedHeaders = init?.headers as unknown as Headers;
|
|
67
|
-
return new Response(
|
|
68
|
-
JSON.stringify({ job_id: "job-export-1", status: "pending" }),
|
|
69
|
-
{
|
|
70
|
-
status: 202,
|
|
71
|
-
headers: { "content-type": "application/json" },
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
const handler = createMigrationExportToGcsProxyHandler(makeConfig());
|
|
78
|
-
const bodyText = JSON.stringify({
|
|
79
|
-
upload_url: "https://storage.example/u",
|
|
80
|
-
description: "x",
|
|
81
|
-
});
|
|
82
|
-
const res = await handler(
|
|
83
|
-
new Request("http://localhost:7830/v1/migrations/export-to-gcs", {
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: {
|
|
86
|
-
authorization: "Bearer caller-token",
|
|
87
|
-
"content-type": "application/json",
|
|
88
|
-
},
|
|
89
|
-
body: bodyText,
|
|
90
|
-
}),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
expect(res.status).toBe(202);
|
|
94
|
-
expect(capturedUrl).toBe(
|
|
95
|
-
"http://localhost:7821/v1/migrations/export-to-gcs",
|
|
96
|
-
);
|
|
97
|
-
expect(capturedMethod).toBe("POST");
|
|
98
|
-
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
99
|
-
expect(capturedHeaders?.has("host")).toBe(false);
|
|
100
|
-
expect(Buffer.from(capturedBody!).toString("utf8")).toBe(bodyText);
|
|
101
|
-
expect(await res.json()).toEqual({
|
|
102
|
-
job_id: "job-export-1",
|
|
103
|
-
status: "pending",
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("import-from-gcs: forwards POST body to daemon and returns 202", async () => {
|
|
108
|
-
let capturedUrl: string | undefined;
|
|
109
|
-
let capturedMethod: string | undefined;
|
|
110
|
-
let capturedHeaders: Headers | undefined;
|
|
111
|
-
fetchMock = mock(
|
|
112
|
-
async (input: string | URL | Request, init?: RequestInit) => {
|
|
113
|
-
capturedUrl = String(input);
|
|
114
|
-
capturedMethod = init?.method;
|
|
115
|
-
capturedHeaders = init?.headers as unknown as Headers;
|
|
116
|
-
return new Response(
|
|
117
|
-
JSON.stringify({ job_id: "job-import-1", status: "pending" }),
|
|
118
|
-
{
|
|
119
|
-
status: 202,
|
|
120
|
-
headers: { "content-type": "application/json" },
|
|
121
|
-
},
|
|
122
|
-
);
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const handler = createMigrationImportFromGcsProxyHandler(makeConfig());
|
|
127
|
-
const res = await handler(
|
|
128
|
-
new Request("http://localhost:7830/v1/migrations/import-from-gcs", {
|
|
129
|
-
method: "POST",
|
|
130
|
-
headers: {
|
|
131
|
-
authorization: "Bearer caller-token",
|
|
132
|
-
"content-type": "application/json",
|
|
133
|
-
},
|
|
134
|
-
body: JSON.stringify({ bundle_url: "https://storage.example/b" }),
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
expect(res.status).toBe(202);
|
|
139
|
-
expect(capturedUrl).toBe(
|
|
140
|
-
"http://localhost:7821/v1/migrations/import-from-gcs",
|
|
141
|
-
);
|
|
142
|
-
expect(capturedMethod).toBe("POST");
|
|
143
|
-
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("jobs/:job_id: forwards GET to daemon with encoded job id", async () => {
|
|
147
|
-
let capturedUrl: string | undefined;
|
|
148
|
-
let capturedMethod: string | undefined;
|
|
149
|
-
fetchMock = mock(
|
|
150
|
-
async (input: string | URL | Request, init?: RequestInit) => {
|
|
151
|
-
capturedUrl = String(input);
|
|
152
|
-
capturedMethod = init?.method;
|
|
153
|
-
return new Response(
|
|
154
|
-
JSON.stringify({
|
|
155
|
-
job_id: "abc/def",
|
|
156
|
-
type: "export",
|
|
157
|
-
status: "processing",
|
|
158
|
-
}),
|
|
159
|
-
{
|
|
160
|
-
status: 200,
|
|
161
|
-
headers: { "content-type": "application/json" },
|
|
162
|
-
},
|
|
163
|
-
);
|
|
164
|
-
},
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const handler = createMigrationJobStatusProxyHandler(makeConfig());
|
|
168
|
-
const res = await handler(
|
|
169
|
-
new Request("http://localhost:7830/v1/migrations/jobs/abc%2Fdef"),
|
|
170
|
-
"abc/def",
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
expect(res.status).toBe(200);
|
|
174
|
-
expect(capturedMethod).toBe("GET");
|
|
175
|
-
expect(capturedUrl).toBe(
|
|
176
|
-
"http://localhost:7821/v1/migrations/jobs/abc%2Fdef",
|
|
177
|
-
);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("export-to-gcs: returns 502 on upstream connection failure", async () => {
|
|
181
|
-
fetchMock = mock(async () => {
|
|
182
|
-
throw new Error("connection refused");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
const handler = createMigrationExportToGcsProxyHandler(makeConfig());
|
|
186
|
-
const res = await handler(
|
|
187
|
-
new Request("http://localhost:7830/v1/migrations/export-to-gcs", {
|
|
188
|
-
method: "POST",
|
|
189
|
-
headers: { "content-type": "application/json" },
|
|
190
|
-
body: "{}",
|
|
191
|
-
}),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
expect(res.status).toBe(502);
|
|
195
|
-
expect(await res.json()).toEqual({ error: "Bad Gateway" });
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
test("jobs/:job_id: forwards upstream non-2xx (e.g. 404 job_not_found)", async () => {
|
|
199
|
-
fetchMock = mock(
|
|
200
|
-
async () =>
|
|
201
|
-
new Response(JSON.stringify({ error: { code: "job_not_found" } }), {
|
|
202
|
-
status: 404,
|
|
203
|
-
headers: { "content-type": "application/json" },
|
|
204
|
-
}),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const handler = createMigrationJobStatusProxyHandler(makeConfig());
|
|
208
|
-
const res = await handler(
|
|
209
|
-
new Request("http://localhost:7830/v1/migrations/jobs/unknown"),
|
|
210
|
-
"unknown",
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
expect(res.status).toBe(404);
|
|
214
|
-
expect(await res.json()).toEqual({ error: { code: "job_not_found" } });
|
|
215
|
-
});
|
|
216
|
-
});
|