fix(cli): retry admin device approval after ownership denial

This commit is contained in:
Peter Steinberger
2026-05-04 00:40:59 +01:00
parent baadd74b6b
commit c3f5c20f2c
6 changed files with 98 additions and 2 deletions

View File

@@ -171,6 +171,36 @@ describe("devices cli approve", () => {
);
});
it("retries explicit approval with admin scope when a paired-device session is ownership-denied", async () => {
callGateway
.mockResolvedValueOnce({
pending: [],
paired: [],
})
.mockRejectedValueOnce(new Error("GatewayClientRequestError: device pairing approval denied"))
.mockResolvedValueOnce({ device: { deviceId: "device-2" } });
await runDevicesApprove(["req-cross-device"]);
expect(callGateway).toHaveBeenCalledTimes(3);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "device.pair.approve",
params: { requestId: "req-cross-device" },
scopes: undefined,
}),
);
expect(callGateway).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
method: "device.pair.approve",
params: { requestId: "req-cross-device" },
scopes: ["operator.admin"],
}),
);
});
it("uses admin scope when a repair approval would inherit an admin token", async () => {
callGateway
.mockResolvedValueOnce({

View File

@@ -137,6 +137,12 @@ function normalizeErrorMessage(error: unknown): string {
return String(error);
}
function isDevicePairingApprovalDenied(error: unknown): boolean {
return normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)).includes(
"device pairing approval denied",
);
}
function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean {
const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error));
if (!readConnectPairingRequiredMessage(message)) {
@@ -197,6 +203,14 @@ async function approvePairingWithFallback(
scopes ? { scopes } : undefined,
);
} catch (error) {
if (isDevicePairingApprovalDenied(error) && !scopes?.includes(ADMIN_SCOPE)) {
return await callGatewayCli(
"device.pair.approve",
opts,
{ requestId },
{ scopes: [ADMIN_SCOPE] },
);
}
if (!shouldUseLocalPairingFallback(opts, error)) {
throw error;
}

View File

@@ -612,6 +612,49 @@ describe("deviceHandlers", () => {
);
});
it("allows admins to approve another device", async () => {
approveDevicePairingMock.mockResolvedValue({
status: "approved",
requestId: "req-2",
device: {
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 150,
},
});
const opts = createOptions(
"device.pair.approve",
{ requestId: "req-2" },
{
client: createClient(["operator.admin"], "device-1", {
isDeviceTokenAuth: true,
}),
},
);
await deviceHandlers["device.pair.approve"](opts);
expect(getPendingDevicePairingMock).not.toHaveBeenCalled();
expect(approveDevicePairingMock).toHaveBeenCalledWith("req-2", {
callerScopes: ["operator.admin"],
});
expect(opts.respond).toHaveBeenCalledWith(
true,
{
requestId: "req-2",
device: {
deviceId: "device-2",
publicKey: "pk-2",
approvedAtMs: 200,
createdAtMs: 150,
tokens: undefined,
},
},
undefined,
);
});
it("allows approving the caller device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-1",