test: cover scope upgrade flow
This commit is contained in:
@@ -291,7 +291,7 @@ Same `deviceId` across roles → single “Instance” row:
|
|||||||
# Execution checklist (ship order)
|
# Execution checklist (ship order)
|
||||||
- [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local.
|
- [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local.
|
||||||
- [x] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
|
- [x] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
|
||||||
- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests.
|
- [x] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests.
|
||||||
- [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting.
|
- [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting.
|
||||||
- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation.
|
- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation.
|
||||||
- [ ] **Discovery + allowlist:** WS discovery TXT includes TLS fingerprint + role hints; node commands filtered by server allowlist.
|
- [ ] **Discovery + allowlist:** WS discovery TXT includes TLS fingerprint + role hints; node commands filtered by server allowlist.
|
||||||
|
|||||||
@@ -152,6 +152,85 @@ describe("gateway server auth/connect", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requires pairing for scope upgrades", async () => {
|
||||||
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
|
const { tmpdir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
||||||
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
|
await import("../infra/device-identity.js");
|
||||||
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||||
|
await import("../infra/device-pairing.js");
|
||||||
|
const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } =
|
||||||
|
await import("../utils/message-channel.js");
|
||||||
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
|
const identityDir = await mkdtemp(join(tmpdir(), "clawdbot-device-scope-"));
|
||||||
|
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||||
|
const client = {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "test",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
};
|
||||||
|
const buildDevice = (scopes: string[]) => {
|
||||||
|
const signedAtMs = Date.now();
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: client.id,
|
||||||
|
clientMode: client.mode,
|
||||||
|
role: "operator",
|
||||||
|
scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: "secret",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const initial = await connectReq(ws, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
client,
|
||||||
|
device: buildDevice(["operator.read"]),
|
||||||
|
});
|
||||||
|
if (!initial.ok) {
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
const pending = list.pending.at(0);
|
||||||
|
expect(pending?.requestId).toBeDefined();
|
||||||
|
if (pending?.requestId) {
|
||||||
|
await approveDevicePairing(pending.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paired = await getPairedDevice(identity.deviceId);
|
||||||
|
expect(paired?.scopes).toContain("operator.read");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws2.once("open", resolve));
|
||||||
|
const res = await connectReq(ws2, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
client,
|
||||||
|
device: buildDevice(["operator.admin"]),
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
paired = await getPairedDevice(identity.deviceId);
|
||||||
|
expect(paired?.scopes).toContain("operator.admin");
|
||||||
|
|
||||||
|
ws2.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects revoked device token", async () => {
|
test("rejects revoked device token", async () => {
|
||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =
|
||||||
|
|||||||
Reference in New Issue
Block a user