From 489efc8f5eb15066f007f820ea71398357ad08e8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 2 Jun 2026 02:52:31 +0200 Subject: [PATCH] refactor: share device token authz test fixtures --- .../server.device-token-rotate-authz.test.ts | 491 +++++++++--------- 1 file changed, 238 insertions(+), 253 deletions(-) diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 417730fe091b..cbca91890278 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -134,10 +134,17 @@ async function waitForMacrotasks(): Promise { } type StartedGatewayClient = Awaited>; +type StartedGatewayServer = Awaited>; type IssuedOperatorToken = Awaited>; type PairingScopedDevice = Awaited>; type PairedDevice = NonNullable>>; type PairedDeviceToken = NonNullable["node"]>; +type RevokedNodeDeviceContext = { + started: StartedGatewayClient; + device: PairingScopedDevice; + revokedNodeToken: PairedDeviceToken; + connectPairingWs: () => Promise; +}; async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Promise<{ deviceId: string; @@ -252,6 +259,35 @@ async function connectPairingScopedIssuedOperator( }); } +async function closeStartedClient(started: StartedGatewayClient) { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); +} + +async function withStartedServer(callback: (started: StartedGatewayServer) => Promise) { + const started = await startServer("secret"); + try { + await callback(started); + } finally { + await started.server.close(); + started.envSnapshot.restore(); + } +} + +async function withPairingScopedIssuedWs( + started: Pick, + issued: Pick, + callback: (ws: WebSocket) => Promise, +) { + const pairingWs = await connectPairingScopedIssuedOperator(started.port, issued); + try { + await callback(pairingWs); + } finally { + pairingWs.close(); + } +} + async function revokeNodeToken(ws: WebSocket, deviceId: string): Promise { const revoke = await rpcReq<{ revokedAtMs?: number }>(ws, "device.token.revoke", { deviceId, @@ -280,29 +316,29 @@ function expectNodeTokenStillRevoked( async function expectLocalNodeReconnectDenied(params: { started: StartedGatewayClient; device: PairingScopedDevice; - clientName: GatewayClientName; + clientName?: GatewayClientName; clientDisplayName: string; - platform: string; - mode: GatewayClientMode; + platform?: string; + mode?: GatewayClientMode; timeoutMessage: string; - message: string; + message?: string; }) { await expect( connectGatewayClient({ url: `ws://127.0.0.1:${params.started.port}`, token: "secret", role: "node", - clientName: params.clientName, + clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.NODE_HOST, clientDisplayName: params.clientDisplayName, clientVersion: "1.0.0", - platform: params.platform, - mode: params.mode, + platform: params.platform ?? "linux", + mode: params.mode ?? GATEWAY_CLIENT_MODES.NODE, scopes: [], commands: ["system.run"], deviceIdentity: params.device.identity, timeoutMessage: params.timeoutMessage, }), - ).rejects.toThrow(params.message); + ).rejects.toThrow(params.message ?? "role upgrade pending approval"); } async function rotateOperatorToken(ws: WebSocket, params: { deviceId: string; scopes?: string[] }) { @@ -325,6 +361,34 @@ function expectPairingOnlyOperatorToken(paired: PairedDevice | null | undefined, } } +async function withRevokedNodeDevice( + name: string, + callback: (context: RevokedNodeDeviceContext) => Promise, + opts?: { platform?: string }, +) { + const started = await startServerWithClient("secret"); + const device = await issueMixedRolePairingScopedDevice(name, opts); + + let pairingWs: WebSocket | undefined; + try { + await connectOk(started.ws); + const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); + + await callback({ + started, + device, + revokedNodeToken, + connectPairingWs: async () => { + pairingWs = await connectPairingScopedDeviceOperator(started.port, device); + return pairingWs; + }, + }); + } finally { + pairingWs?.close(); + await closeStartedClient(started); + } +} + describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { let ownershipGuardServer: Awaited>; let pairingScopeDeniedCase: { @@ -426,247 +490,183 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { const paired = await getPairedDevice(device.deviceId); expect(paired?.tokens?.operator?.revokedAtMs).toBeTypeOf("number"); } finally { - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); + await closeStartedClient(started); } }); test("rejects a pairing-scoped operator session rotating a revoked node token", async () => { - const started = await startServerWithClient("secret"); - const device = await issueMixedRolePairingScopedDevice("same-device-node-token-rotate"); + await withRevokedNodeDevice( + "same-device-node-token-rotate", + async ({ device, revokedNodeToken, connectPairingWs }) => { + const pairingWs = await connectPairingWs(); - let pairingWs: WebSocket | undefined; - try { - await connectOk(started.ws); + const rotate = await rpcReq(pairingWs, "device.token.rotate", { + deviceId: device.deviceId, + role: "node", + }); + expect(rotate.ok).toBe(false); + expect(rotate.error?.message).toBe("device token rotation denied"); - const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); - - pairingWs = await connectPairingScopedDeviceOperator(started.port, device); - - const rotate = await rpcReq(pairingWs, "device.token.rotate", { - deviceId: device.deviceId, - role: "node", - }); - expect(rotate.ok).toBe(false); - expect(rotate.error?.message).toBe("device token rotation denied"); - - const pairedAfterRotate = await getPairedDevice(device.deviceId); - expectNodeTokenStillRevoked(pairedAfterRotate, revokedNodeToken); - } finally { - pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + const pairedAfterRotate = await getPairedDevice(device.deviceId); + expectNodeTokenStillRevoked(pairedAfterRotate, revokedNodeToken); + }, + ); }); test("rejects a pairing-scoped operator session approving a refreshed node token", async () => { - const started = await startServerWithClient("secret"); - const device = await issueMixedRolePairingScopedDevice("same-device-node-pair-approve"); + await withRevokedNodeDevice( + "same-device-node-pair-approve", + async ({ device, revokedNodeToken, connectPairingWs }) => { + const request = await requestDevicePairing({ + deviceId: device.deviceId, + publicKey: device.publicKey, + role: "node", + clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientMode: GATEWAY_CLIENT_MODES.NODE, + }); - let pairingWs: WebSocket | undefined; - try { - await connectOk(started.ws); + const pairingWs = await connectPairingWs(); - const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); + const approve = await rpcReq(pairingWs, "device.pair.approve", { + requestId: request.request.requestId, + }); + expect(approve.ok).toBe(false); + expect(approve.error?.message).toBe("device pairing approval denied"); - const request = await requestDevicePairing({ - deviceId: device.deviceId, - publicKey: device.publicKey, - role: "node", - clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientMode: GATEWAY_CLIENT_MODES.NODE, - }); - - pairingWs = await connectPairingScopedDeviceOperator(started.port, device); - - const approve = await rpcReq(pairingWs, "device.pair.approve", { - requestId: request.request.requestId, - }); - expect(approve.ok).toBe(false); - expect(approve.error?.message).toBe("device pairing approval denied"); - - const pairedAfterApprove = await getPairedDevice(device.deviceId); - expectNodeTokenStillRevoked(pairedAfterApprove, revokedNodeToken); - } finally { - pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + const pairedAfterApprove = await getPairedDevice(device.deviceId); + expectNodeTokenStillRevoked(pairedAfterApprove, revokedNodeToken); + }, + ); }); test("rejects local node reconnect after node token revocation", async () => { - const started = await startServerWithClient("secret"); - const device = await issueMixedRolePairingScopedDevice("same-device-node-reconnect"); + await withRevokedNodeDevice( + "same-device-node-reconnect", + async ({ started, device, revokedNodeToken }) => { + await expectLocalNodeReconnectDenied({ + started, + device, + clientDisplayName: "node-token-revoked", + timeoutMessage: "timeout waiting for revoked node reconnect", + }); - try { - await connectOk(started.ws); - - const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); - - await expectLocalNodeReconnectDenied({ - started, - device, - clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientDisplayName: "node-token-revoked", - platform: "linux", - mode: GATEWAY_CLIENT_MODES.NODE, - timeoutMessage: "timeout waiting for revoked node reconnect", - message: "role upgrade pending approval", - }); - - const pairedAfterReconnect = await getPairedDevice(device.deviceId); - expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); - } finally { - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + const pairedAfterReconnect = await getPairedDevice(device.deviceId); + expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); + }, + ); }); test("rejects local node reconnect with metadata mismatch after node token revocation", async () => { - const started = await startServerWithClient("secret"); - const device = await issueMixedRolePairingScopedDevice("same-device-node-metadata-reconnect", { - platform: "linux", - }); + await withRevokedNodeDevice( + "same-device-node-metadata-reconnect", + async ({ started, device, revokedNodeToken }) => { + const pairedAfterRevoke = await getPairedDevice(device.deviceId); + expect(pairedAfterRevoke?.platform).toBe("linux"); - try { - await connectOk(started.ws); + await expectLocalNodeReconnectDenied({ + started, + device, + clientName: GATEWAY_CLIENT_NAMES.MACOS_APP, + clientDisplayName: "node-token-metadata-mismatch", + platform: "macos", + mode: GATEWAY_CLIENT_MODES.UI, + timeoutMessage: "timeout waiting for metadata mismatch node reconnect", + message: "device metadata change pending approval", + }); - const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); - const pairedAfterRevoke = await getPairedDevice(device.deviceId); - expect(pairedAfterRevoke?.platform).toBe("linux"); - - await expectLocalNodeReconnectDenied({ - started, - device, - clientName: GATEWAY_CLIENT_NAMES.MACOS_APP, - clientDisplayName: "node-token-metadata-mismatch", - platform: "macos", - mode: GATEWAY_CLIENT_MODES.UI, - timeoutMessage: "timeout waiting for metadata mismatch node reconnect", - message: "device metadata change pending approval", - }); - - const pairedAfterReconnect = await getPairedDevice(device.deviceId); - expect(pairedAfterReconnect?.platform).toBe("linux"); - expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); - } finally { - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + const pairedAfterReconnect = await getPairedDevice(device.deviceId); + expect(pairedAfterReconnect?.platform).toBe("linux"); + expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); + }, + { platform: "linux" }, + ); }); test("rejects self-removal before local node reconnect after node token revocation", async () => { - const started = await startServerWithClient("secret"); - const device = await issueMixedRolePairingScopedDevice("same-device-node-remove-reconnect"); + await withRevokedNodeDevice( + "same-device-node-remove-reconnect", + async ({ started, device, revokedNodeToken, connectPairingWs }) => { + const pairingWs = await connectPairingWs(); - let pairingWs: WebSocket | undefined; - try { - await connectOk(started.ws); + const remove = await rpcReq(pairingWs, "device.pair.remove", { + deviceId: device.deviceId, + }); + expect(remove.ok).toBe(false); + expect(remove.error?.message).toBe("device pairing removal denied"); - const revokedNodeToken = await revokeNodeToken(started.ws, device.deviceId); + await expectLocalNodeReconnectDenied({ + started, + device, + clientDisplayName: "node-token-removal-denied", + timeoutMessage: "timeout waiting for denied removal node reconnect", + }); - pairingWs = await connectPairingScopedDeviceOperator(started.port, device); - - const remove = await rpcReq(pairingWs, "device.pair.remove", { - deviceId: device.deviceId, - }); - expect(remove.ok).toBe(false); - expect(remove.error?.message).toBe("device pairing removal denied"); - - await expectLocalNodeReconnectDenied({ - started, - device, - clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientDisplayName: "node-token-removal-denied", - platform: "linux", - mode: GATEWAY_CLIENT_MODES.NODE, - timeoutMessage: "timeout waiting for denied removal node reconnect", - message: "role upgrade pending approval", - }); - - const pairedAfterReconnect = await getPairedDevice(device.deviceId); - expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); - } finally { - pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + const pairedAfterReconnect = await getPairedDevice(device.deviceId); + expectNodeTokenStillRevoked(pairedAfterReconnect, revokedNodeToken); + }, + ); }); }); describe("gateway device.token.rotate/revoke caller scope guard", () => { test("rejects shared-token callers rotating or revoking above their session scopes", async () => { - const started = await startServer("secret"); - const target = await issueTestOperatorToken({ - name: "shared-pairing-target", - approvedScopes: ["operator.admin"], + await withStartedServer(async (started) => { + const target = await issueTestOperatorToken({ + name: "shared-pairing-target", + approvedScopes: ["operator.admin"], + }); + + const pairingWs = await openTrackedWs(started.port); + try { + await connectOk(pairingWs, { + token: "secret", + scopes: ["operator.pairing"], + deviceIdentityPath: resolveDeviceIdentityPath("shared-pairing-caller"), + }); + + const rotate = await rpcReq(pairingWs, "device.token.rotate", { + deviceId: target.deviceId, + role: "operator", + }); + expect(rotate.ok).toBe(false); + expect(rotate.error?.message).toBe("device token rotation denied"); + + const afterRotate = await getPairedDevice(target.deviceId); + expect(afterRotate?.tokens?.operator?.token).toBe(target.token); + expect(afterRotate?.tokens?.operator?.revokedAtMs).toBeUndefined(); + + const revoke = await rpcReq(pairingWs, "device.token.revoke", { + deviceId: target.deviceId, + role: "operator", + }); + expect(revoke.ok).toBe(false); + expect(revoke.error?.message).toBe("device token revocation denied"); + + const afterRevoke = await getPairedDevice(target.deviceId); + expect(afterRevoke?.tokens?.operator?.token).toBe(target.token); + expect(afterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined(); + } finally { + pairingWs.close(); + } }); - - let pairingWs: WebSocket | undefined; - try { - pairingWs = await openTrackedWs(started.port); - await connectOk(pairingWs, { - token: "secret", - scopes: ["operator.pairing"], - deviceIdentityPath: resolveDeviceIdentityPath("shared-pairing-caller"), - }); - - const rotate = await rpcReq(pairingWs, "device.token.rotate", { - deviceId: target.deviceId, - role: "operator", - }); - expect(rotate.ok).toBe(false); - expect(rotate.error?.message).toBe("device token rotation denied"); - - const afterRotate = await getPairedDevice(target.deviceId); - expect(afterRotate?.tokens?.operator?.token).toBe(target.token); - expect(afterRotate?.tokens?.operator?.revokedAtMs).toBeUndefined(); - - const revoke = await rpcReq(pairingWs, "device.token.revoke", { - deviceId: target.deviceId, - role: "operator", - }); - expect(revoke.ok).toBe(false); - expect(revoke.error?.message).toBe("device token revocation denied"); - - const afterRevoke = await getPairedDevice(target.deviceId); - expect(afterRevoke?.tokens?.operator?.token).toBe(target.token); - expect(afterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined(); - } finally { - pairingWs?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } }); test("rejects rotating an admin-approved device token above the caller session scopes", async () => { - const started = await startServer("secret"); - const attacker = await issuePairingScopedAdminToken("rotate-attacker"); + await withStartedServer(async (started) => { + const attacker = await issuePairingScopedAdminToken("rotate-attacker"); - let pairingWs: WebSocket | undefined; - try { - pairingWs = await connectPairingScopedIssuedOperator(started.port, attacker); + await withPairingScopedIssuedWs(started, attacker, async (pairingWs) => { + const rotate = await rotateOperatorToken(pairingWs, { + deviceId: attacker.deviceId, + scopes: ["operator.admin"], + }); + expectDeniedRotation(rotate); - const rotate = await rotateOperatorToken(pairingWs, { - deviceId: attacker.deviceId, - scopes: ["operator.admin"], + const paired = await getPairedDevice(attacker.deviceId); + expectPairingOnlyOperatorToken(paired); + expect(paired?.approvedScopes).toEqual(["operator.admin"]); }); - expectDeniedRotation(rotate); - - const paired = await getPairedDevice(attacker.deviceId); - expectPairingOnlyOperatorToken(paired); - expect(paired?.approvedScopes).toEqual(["operator.admin"]); - } finally { - pairingWs?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + }); }); test("blocks the pairing-token to admin-node-invoke escalation chain", async () => { @@ -704,61 +704,46 @@ describe("gateway device.token.rotate/revoke caller scope guard", () => { } finally { pairingWs?.close(); nodeClient?.stop(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); + await closeStartedClient(started); } }); test("returns the same public deny for unknown devices and caller scope failures", async () => { - const started = await startServer("secret"); - const attacker = await issuePairingScopedAdminToken("rotate-deny-shape"); + await withStartedServer(async (started) => { + const attacker = await issuePairingScopedAdminToken("rotate-deny-shape"); - let pairingWs: WebSocket | undefined; - try { - pairingWs = await connectPairingScopedIssuedOperator(started.port, attacker); + await withPairingScopedIssuedWs(started, attacker, async (pairingWs) => { + const missingScope = await rotateOperatorToken(pairingWs, { + deviceId: attacker.deviceId, + scopes: ["operator.admin"], + }); + const unknownDevice = await rotateOperatorToken(pairingWs, { + deviceId: "missing-device", + scopes: ["operator.pairing"], + }); - const missingScope = await rotateOperatorToken(pairingWs, { - deviceId: attacker.deviceId, - scopes: ["operator.admin"], + expectDeniedRotation(missingScope); + expectDeniedRotation(unknownDevice); }); - const unknownDevice = await rotateOperatorToken(pairingWs, { - deviceId: "missing-device", - scopes: ["operator.pairing"], - }); - - expectDeniedRotation(missingScope); - expectDeniedRotation(unknownDevice); - } finally { - pairingWs?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + }); }); test("rejects rotating a token for an unapproved role on an existing paired device", async () => { - const started = await startServer("secret"); - const attacker = await issuePairingOnlyOperatorToken("rotate-unapproved-role"); + await withStartedServer(async (started) => { + const attacker = await issuePairingOnlyOperatorToken("rotate-unapproved-role"); - let pairingWs: WebSocket | undefined; - try { - pairingWs = await connectPairingScopedIssuedOperator(started.port, attacker); + await withPairingScopedIssuedWs(started, attacker, async (pairingWs) => { + const rotate = await rpcReq(pairingWs, "device.token.rotate", { + deviceId: attacker.deviceId, + role: "node", + }); - const rotate = await rpcReq(pairingWs, "device.token.rotate", { - deviceId: attacker.deviceId, - role: "node", + expectDeniedRotation(rotate); + + const paired = await getPairedDevice(attacker.deviceId); + expect(paired?.tokens?.node).toBeUndefined(); + expectPairingOnlyOperatorToken(paired); }); - - expect(rotate.ok).toBe(false); - expect(rotate.error?.message).toBe("device token rotation denied"); - - const paired = await getPairedDevice(attacker.deviceId); - expect(paired?.tokens?.node).toBeUndefined(); - expectPairingOnlyOperatorToken(paired); - } finally { - pairingWs?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } + }); }); });