mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
1097 lines
36 KiB
TypeScript
1097 lines
36 KiB
TypeScript
// Matrix tests cover verification plugin behavior.
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const withResolvedActionClientMock = vi.fn();
|
|
const withStartedActionClientMock = vi.fn();
|
|
const loadConfigMock = vi.fn(() => ({
|
|
channels: {
|
|
matrix: {},
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../runtime.js", () => ({
|
|
getMatrixRuntime: () => ({
|
|
config: {
|
|
current: loadConfigMock,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
|
|
"openclaw/plugin-sdk/plugin-config-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()),
|
|
};
|
|
});
|
|
|
|
vi.mock("./client.js", () => ({
|
|
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
|
|
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
|
|
}));
|
|
|
|
let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifications;
|
|
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
|
|
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
|
|
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
|
|
let restoreMatrixRoomKeyBackup: typeof import("./verification.js").restoreMatrixRoomKeyBackup;
|
|
let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification;
|
|
let startMatrixVerification: typeof import("./verification.js").startMatrixVerification;
|
|
let confirmMatrixVerificationSas: typeof import("./verification.js").confirmMatrixVerificationSas;
|
|
|
|
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
|
|
|
function mockCallArg(source: MockCallSource, label: string, callIndex = 0, argIndex = 0): unknown {
|
|
const call = source.mock.calls[callIndex];
|
|
if (!call) {
|
|
throw new Error(`Expected ${label} call ${callIndex} to exist`);
|
|
}
|
|
if (!(argIndex in call)) {
|
|
throw new Error(`Expected ${label} call ${callIndex} argument ${argIndex} to exist`);
|
|
}
|
|
return call[argIndex];
|
|
}
|
|
|
|
function mockObjectArg(
|
|
source: MockCallSource,
|
|
label: string,
|
|
callIndex = 0,
|
|
argIndex = 0,
|
|
): Record<string, unknown> {
|
|
const value = mockCallArg(source, label, callIndex, argIndex);
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error(`Expected ${label} call ${callIndex} argument ${argIndex} to be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function expectResolvedActionClientReadinessNone(): void {
|
|
expect(mockObjectArg(withResolvedActionClientMock, "withResolvedActionClient").readiness).toBe(
|
|
"none",
|
|
);
|
|
expect(mockCallArg(withResolvedActionClientMock, "withResolvedActionClient", 0, 1)).toBeTypeOf(
|
|
"function",
|
|
);
|
|
expect(mockCallArg(withResolvedActionClientMock, "withResolvedActionClient", 0, 2)).toBe(
|
|
"discard",
|
|
);
|
|
}
|
|
|
|
describe("matrix verification actions", () => {
|
|
beforeAll(async () => {
|
|
({
|
|
confirmMatrixVerificationSas,
|
|
getMatrixEncryptionStatus,
|
|
getMatrixRoomKeyBackupStatus,
|
|
getMatrixVerificationStatus,
|
|
listMatrixVerifications,
|
|
restoreMatrixRoomKeyBackup,
|
|
runMatrixSelfVerification,
|
|
startMatrixVerification,
|
|
} = await import("./verification.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loadConfigMock.mockReturnValue({
|
|
channels: {
|
|
matrix: {},
|
|
},
|
|
});
|
|
});
|
|
|
|
function mockVerifiedOwnerStatus() {
|
|
return {
|
|
backup: {
|
|
activeVersion: "1",
|
|
decryptionKeyCached: true,
|
|
keyLoadAttempted: false,
|
|
keyLoadError: null,
|
|
matchesDecryptionKey: true,
|
|
serverVersion: "1",
|
|
trusted: true,
|
|
},
|
|
backupVersion: "1",
|
|
crossSigningVerified: true,
|
|
deviceId: "DEVICE123",
|
|
localVerified: true,
|
|
recoveryKeyCreatedAt: null,
|
|
recoveryKeyId: null,
|
|
recoveryKeyStored: false,
|
|
signedByOwner: true,
|
|
userId: "@bot:example.org",
|
|
verified: true,
|
|
};
|
|
}
|
|
|
|
function mockUnverifiedOwnerStatus() {
|
|
return {
|
|
...mockVerifiedOwnerStatus(),
|
|
crossSigningVerified: false,
|
|
localVerified: false,
|
|
signedByOwner: false,
|
|
verified: false,
|
|
};
|
|
}
|
|
|
|
function mockCrossSigningPublicationStatus(published = true) {
|
|
return {
|
|
masterKeyPublished: published,
|
|
published,
|
|
selfSigningKeyPublished: published,
|
|
userId: "@bot:example.org",
|
|
userSigningKeyPublished: published,
|
|
};
|
|
}
|
|
|
|
it("points encryption guidance at the selected Matrix account", async () => {
|
|
loadConfigMock.mockReturnValue({
|
|
channels: {
|
|
matrix: {
|
|
accounts: {
|
|
ops: {
|
|
encryption: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto: null });
|
|
});
|
|
|
|
await expect(
|
|
listMatrixVerifications({ cfg: loadConfigMock(), accountId: "ops" }),
|
|
).rejects.toThrow(
|
|
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
|
);
|
|
});
|
|
|
|
it("uses the resolved default Matrix account when accountId is omitted", async () => {
|
|
loadConfigMock.mockReturnValue({
|
|
channels: {
|
|
matrix: {
|
|
defaultAccount: "ops",
|
|
accounts: {
|
|
ops: {
|
|
encryption: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto: null });
|
|
});
|
|
|
|
await expect(listMatrixVerifications({ cfg: loadConfigMock() })).rejects.toThrow(
|
|
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
|
);
|
|
});
|
|
|
|
it("uses explicit cfg instead of runtime config when crypto is unavailable", async () => {
|
|
const explicitCfg = {
|
|
channels: {
|
|
matrix: {
|
|
accounts: {
|
|
ops: {
|
|
encryption: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
loadConfigMock.mockImplementation(() => {
|
|
throw new Error("verification actions should not reload runtime config when cfg is provided");
|
|
});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto: null });
|
|
});
|
|
|
|
await expect(listMatrixVerifications({ cfg: explicitCfg, accountId: "ops" })).rejects.toThrow(
|
|
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
|
);
|
|
expect(loadConfigMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prepares local crypto before resolving authoritative verification status", async () => {
|
|
const prepareForOneOff = vi.fn(async () => undefined);
|
|
const start = vi.fn(async () => undefined);
|
|
const getOwnDeviceVerificationStatus = vi.fn().mockResolvedValue({
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: null,
|
|
recoveryKeyId: "SSSS",
|
|
backupVersion: "11",
|
|
backup: {
|
|
serverVersion: "11",
|
|
activeVersion: "11",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
keyLoadAttempted: false,
|
|
keyLoadError: null,
|
|
},
|
|
serverDeviceKnown: true,
|
|
});
|
|
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
prepareForOneOff,
|
|
crypto: {
|
|
listVerifications: vi.fn(async () => []),
|
|
getRecoveryKey: vi.fn(async () => ({
|
|
encodedPrivateKey: "rec-key",
|
|
})),
|
|
},
|
|
getOwnDeviceVerificationStatus,
|
|
start,
|
|
});
|
|
});
|
|
|
|
const status = await getMatrixVerificationStatus({ includeRecoveryKey: true });
|
|
|
|
expect(status.verified).toBe(true);
|
|
expect(status.pendingVerifications).toBe(0);
|
|
expect("recoveryKey" in status ? status.recoveryKey : undefined).toBe("rec-key");
|
|
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
|
expectResolvedActionClientReadinessNone();
|
|
expect(prepareForOneOff).toHaveBeenCalledTimes(1);
|
|
expect(start).not.toHaveBeenCalled();
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
|
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed before local Matrix prep when the current device is gone", async () => {
|
|
const prepareForOneOff = vi.fn(async () => undefined);
|
|
const getOwnDeviceVerificationStatus = vi.fn(async () => ({
|
|
encryptionEnabled: true,
|
|
verified: false,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
localVerified: false,
|
|
crossSigningVerified: false,
|
|
signedByOwner: false,
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: null,
|
|
recoveryKeyId: "SSSS",
|
|
backupVersion: "11",
|
|
backup: {
|
|
serverVersion: "11",
|
|
activeVersion: "11",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
keyLoadAttempted: false,
|
|
keyLoadError: null,
|
|
},
|
|
serverDeviceKnown: false,
|
|
}));
|
|
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
crypto: {
|
|
listVerifications: vi.fn(async () => []),
|
|
},
|
|
getOwnDeviceVerificationStatus,
|
|
prepareForOneOff,
|
|
});
|
|
});
|
|
|
|
const status = await getMatrixVerificationStatus();
|
|
|
|
expect(status.deviceId).toBe("DEVICE123");
|
|
expect(status.serverDeviceKnown).toBe(false);
|
|
expect(status.pendingVerifications).toBe(0);
|
|
expectResolvedActionClientReadinessNone();
|
|
expect(prepareForOneOff).not.toHaveBeenCalled();
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resolves encryption and backup status without starting the Matrix client", async () => {
|
|
withResolvedActionClientMock
|
|
.mockImplementationOnce(async (_opts, run) => {
|
|
return await run({
|
|
crypto: {
|
|
getRecoveryKey: vi.fn(async () => ({
|
|
encodedPrivateKey: "rec-key",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
})),
|
|
listVerifications: vi.fn(async () => [{ id: "req-1" }]),
|
|
},
|
|
});
|
|
})
|
|
.mockImplementationOnce(async (_opts, run) => {
|
|
return await run({
|
|
getRoomKeyBackupStatus: vi.fn(async () => ({
|
|
serverVersion: "11",
|
|
activeVersion: "11",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
keyLoadAttempted: false,
|
|
keyLoadError: null,
|
|
})),
|
|
});
|
|
});
|
|
|
|
const encryption = await getMatrixEncryptionStatus({ includeRecoveryKey: true });
|
|
const backup = await getMatrixRoomKeyBackupStatus();
|
|
|
|
expect(encryption.encryptionEnabled).toBe(true);
|
|
expect(encryption.recoveryKeyStored).toBe(true);
|
|
expect(encryption.recoveryKey).toBe("rec-key");
|
|
expect(encryption.pendingVerifications).toBe(1);
|
|
expect(backup.serverVersion).toBe("11");
|
|
expect(backup.trusted).toBe(true);
|
|
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
|
|
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores room-key backup without startup crypto auto-repair", async () => {
|
|
const restoreRoomKeyBackup = vi.fn(async () => ({
|
|
success: true,
|
|
imported: 1,
|
|
total: 1,
|
|
}));
|
|
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ restoreRoomKeyBackup });
|
|
});
|
|
|
|
const restored = await restoreMatrixRoomKeyBackup({ recoveryKey: " key " });
|
|
|
|
expect(restored.success).toBe(true);
|
|
expect(restoreRoomKeyBackup).toHaveBeenCalledWith({ recoveryKey: "key" });
|
|
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
|
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rehydrates DM verification requests before follow-up actions", async () => {
|
|
const tracked = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "txn-dm",
|
|
};
|
|
const started = {
|
|
...tracked,
|
|
chosenMethod: "m.sas.v1",
|
|
phaseName: "started",
|
|
};
|
|
const crypto = {
|
|
ensureVerificationDmTracked: vi.fn(async () => tracked),
|
|
startVerification: vi.fn(async () => started),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
const result = await startMatrixVerification("txn-dm", {
|
|
verificationDmRoomId: "!dm:example.org",
|
|
verificationDmUserId: "@alice:example.org",
|
|
});
|
|
|
|
expect(result.id).toBe("verification-1");
|
|
expect(result.phaseName).toBe("started");
|
|
|
|
expect(crypto.ensureVerificationDmTracked).toHaveBeenCalledWith({
|
|
roomId: "!dm:example.org",
|
|
userId: "@alice:example.org",
|
|
});
|
|
expect(crypto.startVerification).toHaveBeenCalledWith("txn-dm", "sas");
|
|
});
|
|
|
|
it("requires complete DM lookup details for verification follow-up actions", async () => {
|
|
const crypto = {
|
|
ensureVerificationDmTracked: vi.fn(),
|
|
startVerification: vi.fn(),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
await expect(
|
|
startMatrixVerification("txn-dm", {
|
|
verificationDmRoomId: "!dm:example.org",
|
|
}),
|
|
).rejects.toThrow("--user-id and --room-id must be provided together");
|
|
|
|
expect(crypto.ensureVerificationDmTracked).not.toHaveBeenCalled();
|
|
expect(crypto.startVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps self-verification in one started Matrix client session", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const ready = {
|
|
...requested,
|
|
phaseName: "ready",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
emoji: [["🐶", "Dog"]],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const listVerifications = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([ready])
|
|
.mockResolvedValueOnce([completed]);
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => sas),
|
|
listVerifications,
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const confirmSas = vi.fn(async () => true);
|
|
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
|
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
|
mockCrossSigningPublicationStatus(),
|
|
);
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: true,
|
|
verification: mockVerifiedOwnerStatus(),
|
|
}));
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus,
|
|
getOwnDeviceVerificationStatus,
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({ confirmSas, timeoutMs: 500 });
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
expect(result.id).toBe("verification-1");
|
|
expect((result.ownerVerification as { verified?: unknown } | undefined)?.verified).toBe(true);
|
|
|
|
expect(withStartedActionClientMock).toHaveBeenCalledTimes(1);
|
|
expect(crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
|
|
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
|
|
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
|
|
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
|
|
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
|
|
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(1);
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const getOwnDeviceVerificationStatus = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
|
|
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
|
|
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
|
mockCrossSigningPublicationStatus(),
|
|
);
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: true,
|
|
verification: mockUnverifiedOwnerStatus(),
|
|
}));
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus,
|
|
getOwnDeviceVerificationStatus,
|
|
trustOwnIdentityAfterSelfVerification,
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({
|
|
confirmSas: vi.fn(async () => true),
|
|
timeoutMs: 500,
|
|
});
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
expect((result.ownerVerification as { verified?: unknown } | undefined)?.verified).toBe(true);
|
|
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
|
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
|
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not let the SDK identity-only status read hang completed self-verification", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const getOwnDeviceIdentityVerificationStatus = vi.fn(
|
|
async () => await new Promise<never>(() => {}),
|
|
);
|
|
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
|
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
|
mockCrossSigningPublicationStatus(),
|
|
);
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: true,
|
|
verification: mockUnverifiedOwnerStatus(),
|
|
}));
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus,
|
|
getOwnDeviceIdentityVerificationStatus,
|
|
getOwnDeviceVerificationStatus,
|
|
trustOwnIdentityAfterSelfVerification,
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({
|
|
confirmSas: vi.fn(async () => true),
|
|
timeoutMs: 500,
|
|
});
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
|
|
expect(getOwnDeviceIdentityVerificationStatus).not.toHaveBeenCalled();
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not complete self-verification until cross-signing keys are published", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
|
const getOwnCrossSigningPublicationStatus = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(false))
|
|
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(true));
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(false),
|
|
success: false,
|
|
verification: mockVerifiedOwnerStatus(),
|
|
}));
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus,
|
|
getOwnDeviceVerificationStatus,
|
|
trustOwnIdentityAfterSelfVerification,
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({
|
|
confirmSas: vi.fn(async () => true),
|
|
timeoutMs: 500,
|
|
});
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
expect((result.ownerVerification as { verified?: unknown } | undefined)?.verified).toBe(true);
|
|
|
|
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
|
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
|
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("waits for SAS data without restarting an already-started self-verification", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const started = {
|
|
...requested,
|
|
phaseName: "started",
|
|
};
|
|
const sas = {
|
|
...started,
|
|
hasSas: true,
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn().mockResolvedValueOnce([started]).mockResolvedValueOnce([sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(),
|
|
};
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: true,
|
|
verification: mockVerifiedOwnerStatus(),
|
|
}));
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
|
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({
|
|
confirmSas: vi.fn(async () => true),
|
|
timeoutMs: 500,
|
|
});
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
|
|
expect(crypto.startVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails immediately when an already-started self-verification uses a non-SAS method", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const started = {
|
|
...requested,
|
|
chosenMethod: "m.reciprocate.v1",
|
|
phaseName: "started",
|
|
};
|
|
const cancelled = {
|
|
...started,
|
|
phaseName: "cancelled",
|
|
};
|
|
const crypto = {
|
|
cancelVerification: vi.fn(async () => cancelled),
|
|
listVerifications: vi.fn(async () => [started]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
await expect(
|
|
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
|
).rejects.toThrow(
|
|
"Matrix self-verification started without SAS while waiting to show SAS emoji or decimals (method: m.reciprocate.v1)",
|
|
);
|
|
|
|
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
|
|
expect(crypto.startVerification).not.toHaveBeenCalled();
|
|
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
|
code: "m.user",
|
|
reason: "OpenClaw self-verification did not complete",
|
|
});
|
|
});
|
|
|
|
it("finalizes completed non-SAS self-verification without waiting for SAS", async () => {
|
|
const completed = {
|
|
completed: true,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "done",
|
|
transactionId: "tx-self",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(),
|
|
listVerifications: vi.fn(async () => []),
|
|
requestVerification: vi.fn(async () => completed),
|
|
startVerification: vi.fn(),
|
|
};
|
|
const confirmSas = vi.fn(async () => true);
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: true,
|
|
verification: mockVerifiedOwnerStatus(),
|
|
}));
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
|
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({ confirmSas, timeoutMs: 500 });
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
expect(result.id).toBe("verification-1");
|
|
|
|
expect(crypto.listVerifications).not.toHaveBeenCalled();
|
|
expect(crypto.startVerification).not.toHaveBeenCalled();
|
|
expect(crypto.confirmVerificationSas).not.toHaveBeenCalled();
|
|
expect(confirmSas).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows completed self-verification when only backup health remains degraded", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(),
|
|
success: false,
|
|
error: "Matrix room key backup is not trusted by this device",
|
|
verification: mockVerifiedOwnerStatus(),
|
|
}));
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
|
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
|
});
|
|
});
|
|
|
|
const result = await runMatrixSelfVerification({
|
|
confirmSas: vi.fn(async () => true),
|
|
timeoutMs: 500,
|
|
});
|
|
|
|
expect(result.completed).toBe(true);
|
|
expect(result.deviceOwnerVerified).toBe(true);
|
|
});
|
|
|
|
it("fails self-verification if SAS completes but full identity trust cannot be established", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const sas = {
|
|
...requested,
|
|
hasSas: true,
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
};
|
|
const completed = {
|
|
...sas,
|
|
completed: true,
|
|
phaseName: "done",
|
|
};
|
|
const crypto = {
|
|
cancelVerification: vi.fn(),
|
|
confirmVerificationSas: vi.fn(async () => completed),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
startVerification: vi.fn(async () => sas),
|
|
};
|
|
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
|
crossSigning: mockCrossSigningPublicationStatus(false),
|
|
success: false,
|
|
error: "cross-signing identity is still not trusted",
|
|
verification: mockUnverifiedOwnerStatus(),
|
|
}));
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({
|
|
bootstrapOwnDeviceVerification,
|
|
crypto,
|
|
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
|
|
mockCrossSigningPublicationStatus(false),
|
|
),
|
|
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
|
|
).rejects.toThrow(
|
|
"Timed out waiting for Matrix self-verification to establish full Matrix identity trust",
|
|
);
|
|
|
|
expect(crypto.cancelVerification).not.toHaveBeenCalled();
|
|
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancels the pending self-verification request when acceptance times out", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const crypto = {
|
|
cancelVerification: vi.fn(async () => requested),
|
|
listVerifications: vi.fn(async () => []),
|
|
requestVerification: vi.fn(async () => requested),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
await expect(
|
|
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
|
|
).rejects.toThrow("Timed out waiting for Matrix self-verification to be accepted");
|
|
|
|
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
|
code: "m.user",
|
|
reason: "OpenClaw self-verification did not complete",
|
|
});
|
|
});
|
|
|
|
it("fails immediately when the self-verification request is cancelled while waiting", async () => {
|
|
const requested = {
|
|
completed: false,
|
|
hasSas: false,
|
|
id: "verification-1",
|
|
phaseName: "requested",
|
|
transactionId: "tx-self",
|
|
};
|
|
const cancelled = {
|
|
...requested,
|
|
error: "Remote cancelled",
|
|
pending: false,
|
|
phaseName: "cancelled",
|
|
};
|
|
const crypto = {
|
|
cancelVerification: vi.fn(async () => cancelled),
|
|
listVerifications: vi.fn(async () => [cancelled]),
|
|
requestVerification: vi.fn(async () => requested),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
await expect(
|
|
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
|
).rejects.toThrow("Matrix self-verification was cancelled: Remote cancelled");
|
|
|
|
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
|
|
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
|
code: "m.user",
|
|
reason: "OpenClaw self-verification did not complete",
|
|
});
|
|
});
|
|
|
|
it("cancels the request when SAS mismatch submission fails", async () => {
|
|
const sas = {
|
|
completed: false,
|
|
hasSas: true,
|
|
id: "verification-1",
|
|
phaseName: "started",
|
|
sas: {
|
|
decimal: [1, 2, 3],
|
|
},
|
|
transactionId: "tx-self",
|
|
};
|
|
const crypto = {
|
|
cancelVerification: vi.fn(async () => sas),
|
|
listVerifications: vi.fn(async () => [sas]),
|
|
mismatchVerificationSas: vi.fn(async () => {
|
|
throw new Error("failed to send SAS mismatch");
|
|
}),
|
|
requestVerification: vi.fn(async () => sas),
|
|
};
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto });
|
|
});
|
|
|
|
await expect(
|
|
runMatrixSelfVerification({ confirmSas: vi.fn(async () => false), timeoutMs: 500 }),
|
|
).rejects.toThrow("failed to send SAS mismatch");
|
|
|
|
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
|
code: "m.user",
|
|
reason: "OpenClaw self-verification did not complete",
|
|
});
|
|
});
|
|
|
|
it("confirmMatrixVerificationSas calls trustOwnIdentityAfterSelfVerification on a self-verification", async () => {
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => ({
|
|
completed: true,
|
|
hasSas: true,
|
|
id: "verification-self",
|
|
isSelfVerification: true,
|
|
phaseName: "done",
|
|
transactionId: "tx-self",
|
|
})),
|
|
};
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto, trustOwnIdentityAfterSelfVerification });
|
|
});
|
|
|
|
const summary = await confirmMatrixVerificationSas("verification-self");
|
|
|
|
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-self");
|
|
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
|
|
expect(summary.isSelfVerification).toBe(true);
|
|
});
|
|
|
|
it("confirmMatrixVerificationSas does not call trustOwnIdentityAfterSelfVerification on a non-self verification", async () => {
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => ({
|
|
completed: true,
|
|
hasSas: true,
|
|
id: "verification-remote",
|
|
isSelfVerification: false,
|
|
phaseName: "done",
|
|
transactionId: "tx-remote",
|
|
})),
|
|
};
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto, trustOwnIdentityAfterSelfVerification });
|
|
});
|
|
|
|
const summary = await confirmMatrixVerificationSas("verification-remote");
|
|
|
|
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-remote");
|
|
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
|
|
expect(summary.isSelfVerification).toBe(false);
|
|
});
|
|
|
|
it("confirmMatrixVerificationSas does not trust own identity when self-verification failed", async () => {
|
|
const crypto = {
|
|
confirmVerificationSas: vi.fn(async () => ({
|
|
completed: false,
|
|
error: "verifier rejected mid-protocol",
|
|
hasSas: true,
|
|
id: "verification-self",
|
|
isSelfVerification: true,
|
|
phaseName: "started",
|
|
transactionId: "tx-self",
|
|
})),
|
|
};
|
|
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
|
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
|
return await run({ crypto, trustOwnIdentityAfterSelfVerification });
|
|
});
|
|
|
|
const summary = await confirmMatrixVerificationSas("verification-self");
|
|
|
|
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-self");
|
|
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
|
|
expect(summary.error).toMatch(/verifier rejected mid-protocol/);
|
|
});
|
|
});
|