mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
// Nextcloud Talk tests cover setup plugin behavior.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
|
import { describe, expect, it } from "vitest";
|
|
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
|
import {
|
|
nextcloudTalkDmPolicy,
|
|
nextcloudTalkSetupContract,
|
|
normalizeNextcloudTalkBaseUrl,
|
|
setNextcloudTalkAccountConfig,
|
|
validateNextcloudTalkBaseUrl,
|
|
} from "./setup-core.js";
|
|
import { nextcloudTalkSetupWizard } from "./setup-surface.js";
|
|
import type { CoreConfig } from "./types.js";
|
|
|
|
describe("nextcloud talk setup", () => {
|
|
it("shows a bot install command with webhook, response, and reaction features", () => {
|
|
expect(nextcloudTalkSetupWizard.introNote?.lines.join("\n")).toContain(
|
|
"--feature webhook --feature response --feature reaction",
|
|
);
|
|
});
|
|
|
|
it("normalizes and validates base urls", () => {
|
|
expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe(
|
|
"https://cloud.example.com",
|
|
);
|
|
expect(normalizeNextcloudTalkBaseUrl(undefined)).toBe("");
|
|
|
|
expect(validateNextcloudTalkBaseUrl("")).toBe("Required");
|
|
expect(validateNextcloudTalkBaseUrl("cloud.example.com")).toBe(
|
|
"URL must start with http:// or https://",
|
|
);
|
|
expect(validateNextcloudTalkBaseUrl("https://cloud.example.com")).toBeUndefined();
|
|
});
|
|
|
|
it("patches scoped account config and clears selected fields", () => {
|
|
const cfg: CoreConfig = {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "top-secret",
|
|
accounts: {
|
|
work: {
|
|
botSecret: "work-secret",
|
|
botSecretFile: "/tmp/work-secret",
|
|
apiPassword: "api-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(
|
|
setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
|
|
apiUser: "bot",
|
|
}),
|
|
).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "top-secret",
|
|
apiUser: "bot",
|
|
accounts: {
|
|
work: {
|
|
botSecret: "work-secret",
|
|
botSecretFile: "/tmp/work-secret",
|
|
apiPassword: "api-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const clearedDefault = setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {}, [
|
|
"botSecret",
|
|
]);
|
|
expect(clearedDefault).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
accounts: {
|
|
work: {
|
|
botSecret: "work-secret",
|
|
botSecretFile: "/tmp/work-secret",
|
|
apiPassword: "api-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(clearedDefault).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
|
|
|
|
expect(setNextcloudTalkAccountConfig(cfg, "work", {}, ["botSecret", "botSecretFile"])).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "top-secret",
|
|
accounts: {
|
|
work: {
|
|
enabled: true,
|
|
apiPassword: "api-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("sets top-level DM policy state", () => {
|
|
const base: CoreConfig = {
|
|
channels: {
|
|
"nextcloud-talk": {},
|
|
},
|
|
};
|
|
|
|
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
|
|
expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
dmPolicy: "open",
|
|
allowFrom: ["*"],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("honors named-account DM policy state and config keys", () => {
|
|
const base: CoreConfig = {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
dmPolicy: "disabled",
|
|
accounts: {
|
|
work: {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
dmPolicy: "allowlist",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(nextcloudTalkDmPolicy.getCurrent(base, "work")).toBe("allowlist");
|
|
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base, "work")).toEqual({
|
|
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
|
|
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
|
|
});
|
|
});
|
|
|
|
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
|
const base: CoreConfig = {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
defaultAccount: "work",
|
|
dmPolicy: "disabled",
|
|
accounts: {
|
|
work: {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
dmPolicy: "allowlist",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("allowlist");
|
|
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base)).toEqual({
|
|
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
|
|
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
|
|
});
|
|
|
|
const next = nextcloudTalkDmPolicy.setPolicy(base, "open");
|
|
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBe("disabled");
|
|
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
|
|
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
|
| undefined;
|
|
expect(workAccount?.dmPolicy).toBe("open");
|
|
});
|
|
|
|
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
|
|
const next = nextcloudTalkDmPolicy.setPolicy(
|
|
{
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
allowFrom: ["alice"],
|
|
accounts: {
|
|
work: {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"open",
|
|
"work",
|
|
);
|
|
|
|
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBeUndefined();
|
|
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
|
|
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
|
| undefined;
|
|
expect(workAccount?.dmPolicy).toBe("open");
|
|
expect(workAccount?.allowFrom).toEqual(["alice", "*"]);
|
|
});
|
|
|
|
it("validates env/default-account constraints and applies config patches", () => {
|
|
const validateInput = nextcloudTalkSetupContract.validateInput;
|
|
const applyAccountConfig = nextcloudTalkSetupContract.applyAccountConfig;
|
|
expect(validateInput).toBeTypeOf("function");
|
|
expect(applyAccountConfig).toBeTypeOf("function");
|
|
if (!validateInput) {
|
|
throw new Error("Expected Nextcloud Talk setup validateInput");
|
|
}
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: "work",
|
|
input: { useEnv: true },
|
|
} as never),
|
|
).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.");
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { useEnv: false, baseUrl: "", secret: "" },
|
|
} as never),
|
|
).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env).");
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { useEnv: false, secret: "secret", baseUrl: "" },
|
|
} as never),
|
|
).toBe("Nextcloud Talk requires --base-url.");
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { useEnv: false, secret: "secret", baseUrl: "ftp://cloud.example.com" },
|
|
} as never),
|
|
).toBe("URL must start with http:// or https://");
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { useEnv: false, secret: "secret", baseUrl: "cloud.example.com" },
|
|
} as never),
|
|
).toBe("URL must start with http:// or https://");
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: {
|
|
useEnv: false,
|
|
secret: "secret",
|
|
baseUrl: " https://cloud.example.com/talk/// ",
|
|
},
|
|
} as never),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
validateInput({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { useEnv: false, secret: "secret", baseUrl: "http://cloud.example.com" },
|
|
} as never),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
applyAccountConfig({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {},
|
|
},
|
|
},
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: {
|
|
name: "Default",
|
|
baseUrl: " https://cloud.example.com/// ",
|
|
secret: "bot-secret",
|
|
},
|
|
} as never),
|
|
).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
name: "Default",
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "bot-secret",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
applyAccountConfig({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
accounts: {
|
|
work: {
|
|
botSecret: "old-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
accountId: "work",
|
|
input: {
|
|
name: "Work",
|
|
useEnv: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
},
|
|
} as never),
|
|
).toEqual({
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
accounts: {
|
|
work: {
|
|
enabled: true,
|
|
name: "Work",
|
|
baseUrl: "https://cloud.example.com",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("normalizes legacy CLI aliases before applying account config", async () => {
|
|
const prepareInput = nextcloudTalkSetupContract.prepareAccountConfigInput;
|
|
expect(prepareInput).toBeTypeOf("function");
|
|
if (!prepareInput) {
|
|
throw new Error("Expected Nextcloud Talk setup prepareAccountConfigInput");
|
|
}
|
|
|
|
const prepared = await prepareInput({
|
|
cfg: {},
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: {
|
|
url: "https://cloud.example.com",
|
|
token: "bot-secret",
|
|
tokenFile: "/tmp/bot-secret",
|
|
},
|
|
runtime: {} as never,
|
|
});
|
|
expect(prepared).toEqual({
|
|
url: "https://cloud.example.com",
|
|
token: "bot-secret",
|
|
tokenFile: "/tmp/bot-secret",
|
|
baseUrl: "https://cloud.example.com",
|
|
secret: "bot-secret",
|
|
secretFile: "/tmp/bot-secret",
|
|
});
|
|
|
|
const passwordPrepared = await prepareInput({
|
|
cfg: {},
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: { password: "legacy-secret" },
|
|
runtime: {} as never,
|
|
});
|
|
expect(passwordPrepared).toMatchObject({ secret: "legacy-secret" });
|
|
});
|
|
|
|
it("clears stored bot secret fields when switching the default account to env", () => {
|
|
type ApplyAccountConfigContext = Parameters<
|
|
typeof nextcloudTalkSetupContract.applyAccountConfig
|
|
>[0];
|
|
|
|
const next = nextcloudTalkSetupContract.applyAccountConfig({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
baseUrl: "https://cloud.old.example",
|
|
botSecret: "stored-secret",
|
|
botSecretFile: "/tmp/secret.txt",
|
|
},
|
|
},
|
|
},
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
input: {
|
|
baseUrl: "https://cloud.example.com",
|
|
useEnv: true,
|
|
},
|
|
} as unknown as ApplyAccountConfigContext);
|
|
|
|
expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com");
|
|
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
|
|
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
|
|
});
|
|
|
|
it("clears stored bot secret fields when the wizard switches to env", async () => {
|
|
const credential = expectDefined(
|
|
nextcloudTalkSetupWizard.credentials[0],
|
|
"Nextcloud Talk credential",
|
|
);
|
|
const next = await credential.applyUseEnv?.({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
enabled: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "stored-secret",
|
|
botSecretFile: "/tmp/secret.txt",
|
|
},
|
|
},
|
|
},
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
});
|
|
|
|
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
|
|
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
|
|
});
|
|
|
|
it("replaces only the selected account's API password when the wizard sets a credential", async () => {
|
|
const credential = expectDefined(
|
|
nextcloudTalkSetupWizard.credentials[1],
|
|
"Nextcloud Talk API credential",
|
|
);
|
|
const next = await credential.applySet?.({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
botSecret: "root-secret",
|
|
accounts: {
|
|
work: {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
apiUser: "bot",
|
|
apiPassword: "old-password",
|
|
apiPasswordFile: "/run/secrets/old-api-password",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
accountId: "work",
|
|
credentialValues: {},
|
|
value: "new-password",
|
|
resolvedValue: "new-password",
|
|
});
|
|
|
|
expect(next?.channels?.["nextcloud-talk"]?.botSecret).toBe("root-secret");
|
|
expect(next?.channels?.["nextcloud-talk"]?.accounts?.work).toEqual({
|
|
enabled: true,
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
apiUser: "bot",
|
|
apiPassword: "new-password",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveNextcloudTalkAccount", () => {
|
|
it("ignores a blank bot secret file before credential precedence", () => {
|
|
const account = resolveNextcloudTalkAccount({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "inline-secret",
|
|
botSecretFile: " ",
|
|
},
|
|
},
|
|
} as CoreConfig,
|
|
});
|
|
|
|
expect(account.secret).toBe("inline-secret");
|
|
expect(account.secretSource).toBe("config");
|
|
expect(account.tokenStatus).toBe("available");
|
|
expect(account.credentialDiagnostics).toBeUndefined();
|
|
});
|
|
|
|
it("matches normalized configured account ids", () => {
|
|
const account = resolveNextcloudTalkAccount({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
accounts: {
|
|
"Ops Team": {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "bot-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as CoreConfig,
|
|
accountId: "ops-team",
|
|
});
|
|
|
|
expect(account.accountId).toBe("ops-team");
|
|
expect(account.baseUrl).toBe("https://cloud.example.com");
|
|
expect(account.secret).toBe("bot-secret");
|
|
expect(account.secretSource).toBe("config");
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"marks symlinked botSecretFile paths configured-unavailable",
|
|
() => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
|
|
const secretFile = path.join(dir, "secret.txt");
|
|
const secretLink = path.join(dir, "secret-link.txt");
|
|
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
|
|
fs.symlinkSync(secretFile, secretLink);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecretFile: secretLink,
|
|
},
|
|
},
|
|
} as CoreConfig;
|
|
|
|
const account = resolveNextcloudTalkAccount({ cfg });
|
|
expect(account.secret).toBe("");
|
|
expect(account.secretSource).toBe("secretFile");
|
|
expect(account.tokenStatus).toBe("configured_unavailable");
|
|
expect(account.credentialDiagnostics).toEqual([
|
|
{
|
|
code: "CREDENTIAL_FILE_UNAVAILABLE",
|
|
path: "channels.nextcloud-talk.accounts.default.botSecretFile",
|
|
reason: "symlink",
|
|
},
|
|
]);
|
|
expect(JSON.stringify(account.credentialDiagnostics)).not.toContain(secretLink);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
},
|
|
);
|
|
|
|
it("does not fall through from a missing explicit bot secret file", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-missing-"));
|
|
const secretFile = path.join(dir, "missing-secret.txt");
|
|
const account = resolveNextcloudTalkAccount({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "inline-fallback",
|
|
botSecretFile: secretFile,
|
|
},
|
|
},
|
|
} as CoreConfig,
|
|
});
|
|
|
|
expect(account.secret).toBe("");
|
|
expect(account.secretSource).toBe("secretFile");
|
|
expect(account.tokenStatus).toBe("configured_unavailable");
|
|
expect(JSON.stringify(account.credentialDiagnostics)).not.toContain(secretFile);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("uses configured defaultAccount when accountId is omitted", () => {
|
|
const account = resolveNextcloudTalkAccount({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
defaultAccount: "work",
|
|
botSecret: "top-secret",
|
|
accounts: {
|
|
work: {
|
|
baseUrl: "https://cloud.example.com",
|
|
botSecret: "work-secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as CoreConfig,
|
|
});
|
|
|
|
expect(account.accountId).toBe("work");
|
|
expect(account.baseUrl).toBe("https://cloud.example.com");
|
|
expect(account.secret).toBe("work-secret");
|
|
expect(account.secretSource).toBe("config");
|
|
});
|
|
|
|
it("uses configured defaultAccount for omitted setup configured state", () => {
|
|
const configured = nextcloudTalkSetupWizard.status.resolveConfigured({
|
|
cfg: {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
defaultAccount: "work",
|
|
baseUrl: "https://root.example.com",
|
|
botSecret: "root-secret",
|
|
accounts: {
|
|
alerts: {
|
|
baseUrl: "https://alerts.example.com",
|
|
botSecret: "alerts-secret",
|
|
},
|
|
work: {
|
|
baseUrl: "",
|
|
botSecret: "",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as CoreConfig,
|
|
});
|
|
|
|
expect(configured).toBe(false);
|
|
});
|
|
});
|