Files
openclaw/src/shared/device-pairing-access.test.ts
ly-wang19 b9a7bf83a4 fix(device-pairing): guard role normalization against non-string entries (#93504)
normalizeRoleList in src/shared/device-pairing-access.ts called .trim() on every roles[] entry and the singular role without a typeof === "string" guard, so a malformed/legacy on-disk pairing record (roles/role loaded via blind-cast JSON in coercePairingStateRecord) threw "TypeError: role.trim is not a function" and crashed resolvePendingDeviceApprovalState -- and thus `openclaw devices list`, which calls it per pending request with no try/catch.

Route each item through the shared non-string-safe normalizer normalizeUniqueSingleOrTrimmedStringList, mirroring the #90654/#92178 fix that already guarded the sibling mergeRoles/mergeScopes (src/infra/device-pairing.ts) and the in-file scopes path (normalizeDeviceAuthScopes). Non-string entries are dropped; valid roles are still trimmed, deduped, and sorted. Net -10 LOC.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:56:01 +08:00

114 lines
3.1 KiB
TypeScript

// Device pairing access tests cover role and scope checks for pairing requests.
import { describe, expect, it } from "vitest";
import { resolvePendingDeviceApprovalState } from "./device-pairing-access.js";
describe("resolvePendingDeviceApprovalState", () => {
it("treats legacy singular approved role fields as approved access", () => {
expect(
resolvePendingDeviceApprovalState(
{
role: "operator",
scopes: ["operator.read"],
},
{
role: "operator",
scopes: ["operator.read"],
},
),
).toEqual({
kind: "re-approval",
requested: {
roles: ["operator"],
scopes: ["operator.read"],
},
approved: {
roles: ["operator"],
scopes: ["operator.read"],
},
});
});
it("treats revoked approved-role tokens as a role upgrade", () => {
expect(
resolvePendingDeviceApprovalState(
{
role: "operator",
scopes: ["operator.read"],
},
{
role: "operator",
scopes: ["operator.read"],
tokens: {
operator: {
role: "operator",
revokedAtMs: Date.now(),
},
},
},
),
).toEqual({
kind: "role-upgrade",
requested: {
roles: ["operator"],
scopes: ["operator.read"],
},
approved: {
roles: [],
scopes: ["operator.read"],
},
});
});
it("drops non-string role entries from malformed pairing records instead of crashing", () => {
// Legacy/malformed on-disk pairing records can carry non-string roles/role (blind-cast JSON);
// before the shared-normalizer guard these crashed normalizeRoleList on .trim().
type PendingArg = Parameters<typeof resolvePendingDeviceApprovalState>[0];
type PairedArg = NonNullable<Parameters<typeof resolvePendingDeviceApprovalState>[1]>;
expect(
resolvePendingDeviceApprovalState(
{
roles: [123, "operator", null, " admin "],
role: 5,
scopes: ["operator.read"],
} as unknown as PendingArg,
{
roles: [null, "operator"],
role: 9,
scopes: ["operator.read"],
} as unknown as PairedArg,
),
).toEqual({
kind: "role-upgrade",
requested: {
roles: ["admin", "operator"],
scopes: ["operator.read"],
},
approved: {
roles: ["operator"],
scopes: ["operator.read"],
},
});
});
it("drops a non-string token role without crashing", () => {
type PairedArg = NonNullable<Parameters<typeof resolvePendingDeviceApprovalState>[1]>;
expect(
resolvePendingDeviceApprovalState({ role: "operator", scopes: ["operator.read"] }, {
roles: ["operator"],
scopes: ["operator.read"],
tokens: { t1: { role: 7, revokedAtMs: null } },
} as unknown as PairedArg),
).toEqual({
kind: "role-upgrade",
requested: {
roles: ["operator"],
scopes: ["operator.read"],
},
approved: {
roles: [],
scopes: ["operator.read"],
},
});
});
});