mirror of https://github.com/openclaw/openclaw.git
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
import { Command } from "commander";
|
|
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const bootstrapMatrixVerificationMock = vi.fn();
|
|
const getMatrixRoomKeyBackupStatusMock = vi.fn();
|
|
const getMatrixVerificationStatusMock = vi.fn();
|
|
const matrixSetupApplyAccountConfigMock = vi.fn();
|
|
const matrixSetupValidateInputMock = vi.fn();
|
|
const matrixRuntimeLoadConfigMock = vi.fn();
|
|
const matrixRuntimeWriteConfigFileMock = vi.fn();
|
|
const restoreMatrixRoomKeyBackupMock = vi.fn();
|
|
const setMatrixSdkLogModeMock = vi.fn();
|
|
const updateMatrixOwnProfileMock = vi.fn();
|
|
const verifyMatrixRecoveryKeyMock = vi.fn();
|
|
|
|
vi.mock("./matrix/actions/verification.js", () => ({
|
|
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
|
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
|
|
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
|
|
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
|
|
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
|
|
}));
|
|
|
|
vi.mock("./matrix/client/logging.js", () => ({
|
|
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
|
|
}));
|
|
|
|
vi.mock("./matrix/actions/profile.js", () => ({
|
|
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
|
|
}));
|
|
|
|
vi.mock("./channel.js", () => ({
|
|
matrixPlugin: {
|
|
setup: {
|
|
applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args),
|
|
validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("./runtime.js", () => ({
|
|
getMatrixRuntime: () => ({
|
|
config: {
|
|
loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args),
|
|
writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args),
|
|
},
|
|
}),
|
|
}));
|
|
|
|
let registerMatrixCli: typeof import("./cli.js").registerMatrixCli;
|
|
|
|
function buildProgram(): Command {
|
|
const program = new Command();
|
|
registerMatrixCli({ program });
|
|
return program;
|
|
}
|
|
|
|
function formatExpectedLocalTimestamp(value: string): string {
|
|
return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value;
|
|
}
|
|
|
|
describe("matrix CLI verification commands", () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
process.exitCode = undefined;
|
|
({ registerMatrixCli } = await import("./cli.js"));
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
matrixSetupValidateInputMock.mockReturnValue(null);
|
|
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
|
|
matrixRuntimeLoadConfigMock.mockReturnValue({});
|
|
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
|
|
updateMatrixOwnProfileMock.mockResolvedValue({
|
|
skipped: false,
|
|
displayNameUpdated: true,
|
|
avatarUpdated: false,
|
|
resolvedAvatarUrl: null,
|
|
convertedAvatarFromHttp: false,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
|
|
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
|
success: false,
|
|
error: "invalid key",
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "device", "bad-key", "--json"], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
|
|
bootstrapMatrixVerificationMock.mockResolvedValue({
|
|
success: false,
|
|
error: "bootstrap failed",
|
|
verification: {},
|
|
crossSigning: {},
|
|
pendingVerifications: 0,
|
|
cryptoBootstrap: null,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
|
|
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
|
|
success: false,
|
|
error: "missing backup key",
|
|
backupVersion: null,
|
|
imported: 0,
|
|
total: 0,
|
|
loadedFromSecretStorage: false,
|
|
backup: {
|
|
serverVersion: "1",
|
|
activeVersion: null,
|
|
trusted: true,
|
|
matchesDecryptionKey: false,
|
|
decryptionKeyCached: false,
|
|
},
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "backup", "restore", "--json"], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("adds a matrix account and prints a binding hint", async () => {
|
|
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
|
matrixSetupApplyAccountConfigMock.mockImplementation(
|
|
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
|
|
...cfg,
|
|
channels: {
|
|
...(cfg.channels as Record<string, unknown> | undefined),
|
|
matrix: {
|
|
accounts: {
|
|
[accountId]: {
|
|
homeserver: "https://matrix.example.org",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(
|
|
[
|
|
"matrix",
|
|
"account",
|
|
"add",
|
|
"--account",
|
|
"Ops",
|
|
"--homeserver",
|
|
"https://matrix.example.org",
|
|
"--user-id",
|
|
"@ops:example.org",
|
|
"--password",
|
|
"secret",
|
|
],
|
|
{ from: "user" },
|
|
);
|
|
|
|
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: "ops",
|
|
input: expect.objectContaining({
|
|
homeserver: "https://matrix.example.org",
|
|
userId: "@ops:example.org",
|
|
password: "secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channels: {
|
|
matrix: {
|
|
accounts: {
|
|
ops: expect.objectContaining({
|
|
homeserver: "https://matrix.example.org",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops");
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix:ops",
|
|
);
|
|
});
|
|
|
|
it("uses --name as fallback account id and prints account-scoped config path", async () => {
|
|
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(
|
|
[
|
|
"matrix",
|
|
"account",
|
|
"add",
|
|
"--name",
|
|
"Main Bot",
|
|
"--homeserver",
|
|
"https://matrix.example.org",
|
|
"--user-id",
|
|
"@main:example.org",
|
|
"--password",
|
|
"secret",
|
|
],
|
|
{ from: "user" },
|
|
);
|
|
|
|
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: "main-bot",
|
|
}),
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith("Saved matrix account: main-bot");
|
|
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.main-bot");
|
|
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: "main-bot",
|
|
displayName: "Main Bot",
|
|
}),
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix:main-bot",
|
|
);
|
|
});
|
|
|
|
it("sets profile name and avatar via profile set command", async () => {
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(
|
|
[
|
|
"matrix",
|
|
"profile",
|
|
"set",
|
|
"--account",
|
|
"alerts",
|
|
"--name",
|
|
"Alerts Bot",
|
|
"--avatar-url",
|
|
"mxc://example/avatar",
|
|
],
|
|
{ from: "user" },
|
|
);
|
|
|
|
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: "alerts",
|
|
displayName: "Alerts Bot",
|
|
avatarUrl: "mxc://example/avatar",
|
|
}),
|
|
);
|
|
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
|
expect(console.log).toHaveBeenCalledWith("Account: alerts");
|
|
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.alerts");
|
|
});
|
|
|
|
it("returns JSON errors for invalid account setup input", async () => {
|
|
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "account", "add", "--json"], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
expect.stringContaining('"error": "Matrix requires --homeserver"'),
|
|
);
|
|
});
|
|
|
|
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
|
|
process.exitCode = 0;
|
|
bootstrapMatrixVerificationMock.mockResolvedValue({
|
|
success: true,
|
|
verification: {},
|
|
crossSigning: {},
|
|
pendingVerifications: 0,
|
|
cryptoBootstrap: {},
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" });
|
|
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
|
|
it("prints local timezone timestamps for verify status output in verbose mode", async () => {
|
|
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "1",
|
|
backup: {
|
|
serverVersion: "1",
|
|
activeVersion: "1",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
},
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: recoveryCreatedAt,
|
|
pendingVerifications: 0,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" });
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
|
expect(console.log).toHaveBeenCalledWith("Locally trusted: yes");
|
|
expect(console.log).toHaveBeenCalledWith("Signed by owner: yes");
|
|
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
|
});
|
|
|
|
it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => {
|
|
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
|
const verifiedAt = "2026-02-25T20:14:00.000Z";
|
|
bootstrapMatrixVerificationMock.mockResolvedValue({
|
|
success: true,
|
|
verification: {
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "1",
|
|
backup: {
|
|
serverVersion: "1",
|
|
activeVersion: "1",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
},
|
|
recoveryKeyStored: true,
|
|
recoveryKeyId: "SSSS",
|
|
recoveryKeyCreatedAt: recoveryCreatedAt,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
},
|
|
crossSigning: {
|
|
published: true,
|
|
masterKeyPublished: true,
|
|
selfSigningKeyPublished: true,
|
|
userSigningKeyPublished: true,
|
|
},
|
|
pendingVerifications: 0,
|
|
cryptoBootstrap: {},
|
|
});
|
|
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
|
success: true,
|
|
encryptionEnabled: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "1",
|
|
backup: {
|
|
serverVersion: "1",
|
|
activeVersion: "1",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
},
|
|
verified: true,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
recoveryKeyStored: true,
|
|
recoveryKeyId: "SSSS",
|
|
recoveryKeyCreatedAt: recoveryCreatedAt,
|
|
verifiedAt,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "bootstrap", "--verbose"], {
|
|
from: "user",
|
|
});
|
|
await program.parseAsync(["matrix", "verify", "device", "valid-key", "--verbose"], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
|
|
);
|
|
});
|
|
|
|
it("keeps default output concise when verbose is not provided", async () => {
|
|
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "1",
|
|
backup: {
|
|
serverVersion: "1",
|
|
activeVersion: "1",
|
|
trusted: true,
|
|
matchesDecryptionKey: true,
|
|
decryptionKeyCached: true,
|
|
},
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: recoveryCreatedAt,
|
|
pendingVerifications: 0,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
|
|
|
expect(console.log).not.toHaveBeenCalledWith(
|
|
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
|
);
|
|
expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0");
|
|
expect(console.log).not.toHaveBeenCalledWith("Diagnostics:");
|
|
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
|
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet");
|
|
});
|
|
|
|
it("shows explicit backup issue in default status output", async () => {
|
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "5256",
|
|
backup: {
|
|
serverVersion: "5256",
|
|
activeVersion: null,
|
|
trusted: true,
|
|
matchesDecryptionKey: false,
|
|
decryptionKeyCached: false,
|
|
keyLoadAttempted: true,
|
|
keyLoadError: null,
|
|
},
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
|
pendingVerifications: 0,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
|
|
);
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
"- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.",
|
|
);
|
|
expect(console.log).not.toHaveBeenCalledWith(
|
|
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
|
|
);
|
|
});
|
|
|
|
it("includes key load failure details in status output", async () => {
|
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
|
encryptionEnabled: true,
|
|
verified: true,
|
|
localVerified: true,
|
|
crossSigningVerified: true,
|
|
signedByOwner: true,
|
|
userId: "@bot:example.org",
|
|
deviceId: "DEVICE123",
|
|
backupVersion: "5256",
|
|
backup: {
|
|
serverVersion: "5256",
|
|
activeVersion: null,
|
|
trusted: true,
|
|
matchesDecryptionKey: false,
|
|
decryptionKeyCached: false,
|
|
keyLoadAttempted: true,
|
|
keyLoadError: "secret storage key is not available",
|
|
},
|
|
recoveryKeyStored: true,
|
|
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
|
pendingVerifications: 0,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
|
|
);
|
|
});
|
|
|
|
it("prints backup health lines for verify backup status in verbose mode", async () => {
|
|
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
|
|
serverVersion: "2",
|
|
activeVersion: null,
|
|
trusted: true,
|
|
matchesDecryptionKey: false,
|
|
decryptionKeyCached: false,
|
|
keyLoadAttempted: true,
|
|
keyLoadError: null,
|
|
});
|
|
const program = buildProgram();
|
|
|
|
await program.parseAsync(["matrix", "verify", "backup", "status", "--verbose"], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
|
|
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
|
|
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
|
|
});
|
|
});
|