mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
test: dedupe helper-heavy test suites
This commit is contained in:
@@ -12,50 +12,54 @@ const baseAttempt = {
|
||||
reason: "rate_limit" as const,
|
||||
};
|
||||
|
||||
describe("fallback-state", () => {
|
||||
it("treats fallback as active only when state matches selected and active refs", () => {
|
||||
const state: FallbackNoticeState = {
|
||||
fallbackNoticeSelectedModel: "demo-primary/model-a",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
};
|
||||
const activeFallbackState: FallbackNoticeState = {
|
||||
fallbackNoticeSelectedModel: "demo-primary/model-a",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveFallbackState({
|
||||
selectedModelRef: "demo-primary/model-a",
|
||||
activeModelRef: "demo-fallback/model-b",
|
||||
state,
|
||||
});
|
||||
|
||||
expect(resolved.active).toBe(true);
|
||||
expect(resolved.reason).toBe("rate limit");
|
||||
function resolveDemoFallbackTransition(
|
||||
overrides: Partial<Parameters<typeof resolveFallbackTransition>[0]> = {},
|
||||
) {
|
||||
return resolveFallbackTransition({
|
||||
selectedProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
activeProvider: "demo-fallback",
|
||||
activeModel: "model-b",
|
||||
attempts: [baseAttempt],
|
||||
state: {},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("does not treat runtime drift as fallback when persisted state does not match", () => {
|
||||
const state: FallbackNoticeState = {
|
||||
fallbackNoticeSelectedModel: "other-provider/other-model",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
};
|
||||
|
||||
describe("fallback-state", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "treats fallback as active only when state matches selected and active refs",
|
||||
state: activeFallbackState,
|
||||
expected: { active: true, reason: "rate limit" },
|
||||
},
|
||||
{
|
||||
name: "does not treat runtime drift as fallback when persisted state does not match",
|
||||
state: {
|
||||
fallbackNoticeSelectedModel: "other-provider/other-model",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
} satisfies FallbackNoticeState,
|
||||
expected: { active: false, reason: undefined },
|
||||
},
|
||||
])("$name", ({ state, expected }) => {
|
||||
const resolved = resolveActiveFallbackState({
|
||||
selectedModelRef: "demo-primary/model-a",
|
||||
activeModelRef: "demo-fallback/model-b",
|
||||
state,
|
||||
});
|
||||
|
||||
expect(resolved.active).toBe(false);
|
||||
expect(resolved.reason).toBeUndefined();
|
||||
expect(resolved).toEqual(expected);
|
||||
});
|
||||
|
||||
it("marks fallback transition when selected->active pair changes", () => {
|
||||
const resolved = resolveFallbackTransition({
|
||||
selectedProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
activeProvider: "demo-fallback",
|
||||
activeModel: "model-b",
|
||||
attempts: [baseAttempt],
|
||||
state: {},
|
||||
});
|
||||
const resolved = resolveDemoFallbackTransition();
|
||||
|
||||
expect(resolved.fallbackActive).toBe(true);
|
||||
expect(resolved.fallbackTransitioned).toBe(true);
|
||||
@@ -67,30 +71,17 @@ describe("fallback-state", () => {
|
||||
});
|
||||
|
||||
it("normalizes fallback reason whitespace for summaries", () => {
|
||||
const resolved = resolveFallbackTransition({
|
||||
selectedProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
activeProvider: "demo-fallback",
|
||||
activeModel: "model-b",
|
||||
const resolved = resolveDemoFallbackTransition({
|
||||
attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }],
|
||||
state: {},
|
||||
});
|
||||
|
||||
expect(resolved.reasonSummary).toBe("rate limit burst");
|
||||
});
|
||||
|
||||
it("refreshes reason when fallback remains active with same model pair", () => {
|
||||
const resolved = resolveFallbackTransition({
|
||||
selectedProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
activeProvider: "demo-fallback",
|
||||
activeModel: "model-b",
|
||||
const resolved = resolveDemoFallbackTransition({
|
||||
attempts: [{ ...baseAttempt, reason: "timeout" }],
|
||||
state: {
|
||||
fallbackNoticeSelectedModel: "demo-primary/model-a",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
},
|
||||
state: activeFallbackState,
|
||||
});
|
||||
|
||||
expect(resolved.fallbackTransitioned).toBe(false);
|
||||
@@ -99,17 +90,12 @@ describe("fallback-state", () => {
|
||||
});
|
||||
|
||||
it("marks fallback as cleared when runtime returns to selected model", () => {
|
||||
const resolved = resolveFallbackTransition({
|
||||
selectedProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
const resolved = resolveDemoFallbackTransition({
|
||||
activeProvider: "demo-primary",
|
||||
selectedModel: "model-a",
|
||||
activeModel: "model-a",
|
||||
attempts: [],
|
||||
state: {
|
||||
fallbackNoticeSelectedModel: "demo-primary/model-a",
|
||||
fallbackNoticeActiveModel: "demo-fallback/model-b",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
},
|
||||
state: activeFallbackState,
|
||||
});
|
||||
|
||||
expect(resolved.fallbackActive).toBe(false);
|
||||
|
||||
@@ -36,16 +36,21 @@ function cfg(accounts?: Record<string, unknown> | null, defaultAccount?: string)
|
||||
|
||||
describe("createAccountListHelpers", () => {
|
||||
describe("listConfiguredAccountIds", () => {
|
||||
it("returns empty for missing config", () => {
|
||||
expect(listConfiguredAccountIds({} as OpenClawConfig)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when no accounts key", () => {
|
||||
expect(listConfiguredAccountIds(cfg(null))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for empty accounts object", () => {
|
||||
expect(listConfiguredAccountIds(cfg({}))).toEqual([]);
|
||||
it.each([
|
||||
{
|
||||
name: "returns empty for missing config",
|
||||
input: {} as OpenClawConfig,
|
||||
},
|
||||
{
|
||||
name: "returns empty when no accounts key",
|
||||
input: cfg(null),
|
||||
},
|
||||
{
|
||||
name: "returns empty for empty accounts object",
|
||||
input: cfg({}),
|
||||
},
|
||||
])("$name", ({ input }) => {
|
||||
expect(listConfiguredAccountIds(input)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out empty keys", () => {
|
||||
@@ -77,42 +82,61 @@ describe("createAccountListHelpers", () => {
|
||||
});
|
||||
|
||||
describe("listAccountIds", () => {
|
||||
it('returns ["default"] for empty config', () => {
|
||||
expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it('returns ["default"] for empty accounts', () => {
|
||||
expect(listAccountIds(cfg({}))).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("returns sorted ids", () => {
|
||||
expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]);
|
||||
it.each([
|
||||
{
|
||||
name: 'returns ["default"] for empty config',
|
||||
input: {} as OpenClawConfig,
|
||||
expected: ["default"],
|
||||
},
|
||||
{
|
||||
name: 'returns ["default"] for empty accounts',
|
||||
input: cfg({}),
|
||||
expected: ["default"],
|
||||
},
|
||||
{
|
||||
name: "returns sorted ids",
|
||||
input: cfg({ z: {}, a: {}, m: {} }),
|
||||
expected: ["a", "m", "z"],
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(listAccountIds(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultAccountId", () => {
|
||||
it("prefers configured defaultAccount when it matches a configured account id", () => {
|
||||
expect(resolveDefaultAccountId(cfg({ alpha: {}, beta: {} }, "beta"))).toBe("beta");
|
||||
});
|
||||
|
||||
it("normalizes configured defaultAccount before matching", () => {
|
||||
expect(resolveDefaultAccountId(cfg({ "router-d": {} }, "Router D"))).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back when configured defaultAccount is missing", () => {
|
||||
expect(resolveDefaultAccountId(cfg({ beta: {}, alpha: {} }, "missing"))).toBe("alpha");
|
||||
});
|
||||
|
||||
it('returns "default" when present', () => {
|
||||
expect(resolveDefaultAccountId(cfg({ default: {}, other: {} }))).toBe("default");
|
||||
});
|
||||
|
||||
it("returns first sorted id when no default", () => {
|
||||
expect(resolveDefaultAccountId(cfg({ beta: {}, alpha: {} }))).toBe("alpha");
|
||||
});
|
||||
|
||||
it('returns "default" for empty config', () => {
|
||||
expect(resolveDefaultAccountId({} as OpenClawConfig)).toBe("default");
|
||||
it.each([
|
||||
{
|
||||
name: "prefers configured defaultAccount when it matches a configured account id",
|
||||
input: cfg({ alpha: {}, beta: {} }, "beta"),
|
||||
expected: "beta",
|
||||
},
|
||||
{
|
||||
name: "normalizes configured defaultAccount before matching",
|
||||
input: cfg({ "router-d": {} }, "Router D"),
|
||||
expected: "router-d",
|
||||
},
|
||||
{
|
||||
name: "falls back when configured defaultAccount is missing",
|
||||
input: cfg({ beta: {}, alpha: {} }, "missing"),
|
||||
expected: "alpha",
|
||||
},
|
||||
{
|
||||
name: 'returns "default" when present',
|
||||
input: cfg({ default: {}, other: {} }),
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "returns first sorted id when no default",
|
||||
input: cfg({ beta: {}, alpha: {} }),
|
||||
expected: "alpha",
|
||||
},
|
||||
{
|
||||
name: 'returns "default" for empty config',
|
||||
input: {} as OpenClawConfig,
|
||||
expected: "default",
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(resolveDefaultAccountId(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("can preserve configured defaults that are not present in accounts", () => {
|
||||
@@ -149,49 +173,49 @@ describe("listCombinedAccountIds", () => {
|
||||
});
|
||||
|
||||
describe("resolveListedDefaultAccountId", () => {
|
||||
it("prefers the configured default when present in the listed ids", () => {
|
||||
expect(
|
||||
resolveListedDefaultAccountId({
|
||||
it.each([
|
||||
{
|
||||
name: "prefers the configured default when present in the listed ids",
|
||||
input: {
|
||||
accountIds: ["alerts", "work"],
|
||||
configuredDefaultAccountId: "work",
|
||||
}),
|
||||
).toBe("work");
|
||||
});
|
||||
|
||||
it("matches configured defaults against normalized listed ids", () => {
|
||||
expect(
|
||||
resolveListedDefaultAccountId({
|
||||
},
|
||||
expected: "work",
|
||||
},
|
||||
{
|
||||
name: "matches configured defaults against normalized listed ids",
|
||||
input: {
|
||||
accountIds: ["Router D"],
|
||||
configuredDefaultAccountId: "router-d",
|
||||
}),
|
||||
).toBe("router-d");
|
||||
});
|
||||
|
||||
it("prefers the default account id when listed", () => {
|
||||
expect(
|
||||
resolveListedDefaultAccountId({
|
||||
},
|
||||
expected: "router-d",
|
||||
},
|
||||
{
|
||||
name: "prefers the default account id when listed",
|
||||
input: {
|
||||
accountIds: ["default", "work"],
|
||||
}),
|
||||
).toBe("default");
|
||||
});
|
||||
|
||||
it("can preserve an unlisted configured default", () => {
|
||||
expect(
|
||||
resolveListedDefaultAccountId({
|
||||
},
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "can preserve an unlisted configured default",
|
||||
input: {
|
||||
accountIds: ["default", "work"],
|
||||
configuredDefaultAccountId: "ops",
|
||||
allowUnlistedDefaultAccount: true,
|
||||
}),
|
||||
).toBe("ops");
|
||||
});
|
||||
|
||||
it("supports an explicit fallback id for ambiguous multi-account setups", () => {
|
||||
expect(
|
||||
resolveListedDefaultAccountId({
|
||||
},
|
||||
expected: "ops",
|
||||
},
|
||||
{
|
||||
name: "supports an explicit fallback id for ambiguous multi-account setups",
|
||||
input: {
|
||||
accountIds: ["alerts", "work"],
|
||||
ambiguousFallbackAccountId: "default",
|
||||
}),
|
||||
).toBe("default");
|
||||
},
|
||||
expected: "default",
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(resolveListedDefaultAccountId(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,49 +329,59 @@ describe("mergeAccountConfig", () => {
|
||||
});
|
||||
|
||||
describe("resolveMergedAccountConfig", () => {
|
||||
it("merges the matching account config into channel config", () => {
|
||||
const merged = resolveMergedAccountConfig<{
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
}>({
|
||||
channelConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work",
|
||||
type MergedChannelConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ResolveMergedInput = Parameters<typeof resolveMergedAccountConfig<MergedChannelConfig>>[0];
|
||||
|
||||
const resolveMergedCases: Array<{
|
||||
name: string;
|
||||
input: ResolveMergedInput;
|
||||
expected: MergedChannelConfig;
|
||||
}> = [
|
||||
{
|
||||
name: "merges the matching account config into channel config",
|
||||
input: {
|
||||
channelConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(merged).toEqual({
|
||||
enabled: true,
|
||||
name: "Work",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports normalized account lookups", () => {
|
||||
const merged = resolveMergedAccountConfig<{
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
}>({
|
||||
channelConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
accounts: {
|
||||
"Router D": {
|
||||
name: "Router",
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work",
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
},
|
||||
accountId: "router-d",
|
||||
normalizeAccountId,
|
||||
});
|
||||
expected: {
|
||||
enabled: true,
|
||||
name: "Work",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "supports normalized account lookups",
|
||||
input: {
|
||||
channelConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
accounts: {
|
||||
"Router D": {
|
||||
name: "Router",
|
||||
},
|
||||
},
|
||||
accountId: "router-d",
|
||||
normalizeAccountId,
|
||||
},
|
||||
expected: {
|
||||
enabled: true,
|
||||
name: "Router",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(merged).toEqual({
|
||||
enabled: true,
|
||||
name: "Router",
|
||||
});
|
||||
it.each(resolveMergedCases)("$name", ({ input, expected }) => {
|
||||
expect(resolveMergedAccountConfig<MergedChannelConfig>(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("deep-merges selected nested object keys after resolving the account", () => {
|
||||
|
||||
@@ -15,29 +15,29 @@ function cfgWithChannel(channelKey: string, accounts?: Record<string, unknown>):
|
||||
}
|
||||
|
||||
describe("buildAccountScopedDmSecurityPolicy", () => {
|
||||
it("builds top-level dm policy paths when no account config exists", () => {
|
||||
expect(
|
||||
buildAccountScopedDmSecurityPolicy({
|
||||
it.each([
|
||||
{
|
||||
name: "builds top-level dm policy paths when no account config exists",
|
||||
input: {
|
||||
cfg: cfgWithChannel("demo-root"),
|
||||
channelKey: "demo-root",
|
||||
fallbackAccountId: "default",
|
||||
policy: "pairing",
|
||||
allowFrom: ["123"],
|
||||
policyPathSuffix: "dmPolicy",
|
||||
}),
|
||||
).toEqual({
|
||||
policy: "pairing",
|
||||
allowFrom: ["123"],
|
||||
policyPath: "channels.demo-root.dmPolicy",
|
||||
allowFromPath: "channels.demo-root.",
|
||||
approveHint: formatPairingApproveHint("demo-root"),
|
||||
normalizeEntry: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses account-scoped paths when account config exists", () => {
|
||||
expect(
|
||||
buildAccountScopedDmSecurityPolicy({
|
||||
},
|
||||
expected: {
|
||||
policy: "pairing",
|
||||
allowFrom: ["123"],
|
||||
policyPath: "channels.demo-root.dmPolicy",
|
||||
allowFromPath: "channels.demo-root.",
|
||||
approveHint: formatPairingApproveHint("demo-root"),
|
||||
normalizeEntry: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses account-scoped paths when account config exists",
|
||||
input: {
|
||||
cfg: cfgWithChannel("demo-account", { work: {} }),
|
||||
channelKey: "demo-account",
|
||||
accountId: "work",
|
||||
@@ -45,40 +45,38 @@ describe("buildAccountScopedDmSecurityPolicy", () => {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["+12125551212"],
|
||||
policyPathSuffix: "dmPolicy",
|
||||
}),
|
||||
).toEqual({
|
||||
policy: "allowlist",
|
||||
allowFrom: ["+12125551212"],
|
||||
policyPath: "channels.demo-account.accounts.work.dmPolicy",
|
||||
allowFromPath: "channels.demo-account.accounts.work.",
|
||||
approveHint: formatPairingApproveHint("demo-account"),
|
||||
normalizeEntry: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports nested dm paths without explicit policyPath", () => {
|
||||
expect(
|
||||
buildAccountScopedDmSecurityPolicy({
|
||||
},
|
||||
expected: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["+12125551212"],
|
||||
policyPath: "channels.demo-account.accounts.work.dmPolicy",
|
||||
allowFromPath: "channels.demo-account.accounts.work.",
|
||||
approveHint: formatPairingApproveHint("demo-account"),
|
||||
normalizeEntry: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "supports nested dm paths without explicit policyPath",
|
||||
input: {
|
||||
cfg: cfgWithChannel("demo-nested", { work: {} }),
|
||||
channelKey: "demo-nested",
|
||||
accountId: "work",
|
||||
policy: "pairing",
|
||||
allowFrom: [],
|
||||
allowFromPathSuffix: "dm.",
|
||||
}),
|
||||
).toEqual({
|
||||
policy: "pairing",
|
||||
allowFrom: [],
|
||||
policyPath: undefined,
|
||||
allowFromPath: "channels.demo-nested.accounts.work.dm.",
|
||||
approveHint: formatPairingApproveHint("demo-nested"),
|
||||
normalizeEntry: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports custom defaults and approve hints", () => {
|
||||
expect(
|
||||
buildAccountScopedDmSecurityPolicy({
|
||||
},
|
||||
expected: {
|
||||
policy: "pairing",
|
||||
allowFrom: [],
|
||||
policyPath: undefined,
|
||||
allowFromPath: "channels.demo-nested.accounts.work.dm.",
|
||||
approveHint: formatPairingApproveHint("demo-nested"),
|
||||
normalizeEntry: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "supports custom defaults and approve hints",
|
||||
input: {
|
||||
cfg: cfgWithChannel("demo-default"),
|
||||
channelKey: "demo-default",
|
||||
fallbackAccountId: "default",
|
||||
@@ -86,15 +84,18 @@ describe("buildAccountScopedDmSecurityPolicy", () => {
|
||||
defaultPolicy: "allowlist",
|
||||
policyPathSuffix: "dmPolicy",
|
||||
approveHint: "openclaw pairing approve demo-default <code>",
|
||||
}),
|
||||
).toEqual({
|
||||
policy: "allowlist",
|
||||
allowFrom: ["user-1"],
|
||||
policyPath: "channels.demo-default.dmPolicy",
|
||||
allowFromPath: "channels.demo-default.",
|
||||
approveHint: "openclaw pairing approve demo-default <code>",
|
||||
normalizeEntry: undefined,
|
||||
});
|
||||
},
|
||||
expected: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["user-1"],
|
||||
policyPath: "channels.demo-default.dmPolicy",
|
||||
allowFromPath: "channels.demo-default.",
|
||||
approveHint: "openclaw pairing approve demo-default <code>",
|
||||
normalizeEntry: undefined,
|
||||
},
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(buildAccountScopedDmSecurityPolicy(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -128,35 +128,79 @@ async function runPromptSingleToken(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function createSecretInputPrompter(params: {
|
||||
selects: string[];
|
||||
confirms?: boolean[];
|
||||
texts?: string[];
|
||||
}) {
|
||||
const selects = [...params.selects];
|
||||
const confirms = [...(params.confirms ?? [])];
|
||||
const texts = [...(params.texts ?? [])];
|
||||
return {
|
||||
select: vi.fn(async () => selects.shift() ?? "plaintext"),
|
||||
confirm: vi.fn(async () => confirms.shift() ?? false),
|
||||
text: vi.fn(async () => texts.shift() ?? ""),
|
||||
note: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function runPromptSingleChannelSecretInput(params: {
|
||||
prompter: ReturnType<typeof createSecretInputPrompter>;
|
||||
providerHint: string;
|
||||
credentialLabel: string;
|
||||
accountConfigured: boolean;
|
||||
canUseEnv: boolean;
|
||||
hasConfigToken: boolean;
|
||||
preferredEnvVar: string;
|
||||
}) {
|
||||
return await promptSingleChannelSecretInput({
|
||||
cfg: {},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
prompter: params.prompter as any,
|
||||
providerHint: params.providerHint,
|
||||
credentialLabel: params.credentialLabel,
|
||||
accountConfigured: params.accountConfigured,
|
||||
canUseEnv: params.canUseEnv,
|
||||
hasConfigToken: params.hasConfigToken,
|
||||
envPrompt: "use env",
|
||||
keepPrompt: "keep",
|
||||
inputPrompt: "token",
|
||||
preferredEnvVar: params.preferredEnvVar,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildSingleChannelSecretPromptState", () => {
|
||||
it("enables env path only when env is present and no config token exists", () => {
|
||||
expect(
|
||||
buildSingleChannelSecretPromptState({
|
||||
it.each([
|
||||
{
|
||||
name: "enables env path only when env is present and no config token exists",
|
||||
input: {
|
||||
accountConfigured: false,
|
||||
hasConfigToken: false,
|
||||
allowEnv: true,
|
||||
envValue: "token-from-env",
|
||||
}),
|
||||
).toEqual({
|
||||
accountConfigured: false,
|
||||
hasConfigToken: false,
|
||||
canUseEnv: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables env path when config token already exists", () => {
|
||||
expect(
|
||||
buildSingleChannelSecretPromptState({
|
||||
},
|
||||
expected: {
|
||||
accountConfigured: false,
|
||||
hasConfigToken: false,
|
||||
canUseEnv: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disables env path when config token already exists",
|
||||
input: {
|
||||
accountConfigured: true,
|
||||
hasConfigToken: true,
|
||||
allowEnv: true,
|
||||
envValue: "token-from-env",
|
||||
}),
|
||||
).toEqual({
|
||||
accountConfigured: true,
|
||||
hasConfigToken: true,
|
||||
canUseEnv: false,
|
||||
});
|
||||
},
|
||||
expected: {
|
||||
accountConfigured: true,
|
||||
hasConfigToken: true,
|
||||
canUseEnv: false,
|
||||
},
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(buildSingleChannelSecretPromptState(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,74 +378,80 @@ describe("promptLegacyChannelAllowFromForAccount", () => {
|
||||
});
|
||||
|
||||
describe("promptSingleChannelToken", () => {
|
||||
it("uses env tokens when confirmed", async () => {
|
||||
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
|
||||
it.each([
|
||||
{
|
||||
name: "uses env tokens when confirmed",
|
||||
confirms: [true],
|
||||
texts: [],
|
||||
state: {
|
||||
accountConfigured: false,
|
||||
canUseEnv: true,
|
||||
hasConfigToken: false,
|
||||
},
|
||||
expected: { useEnv: true, token: null },
|
||||
expectTextCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "prompts for token when env exists but user declines env",
|
||||
confirms: [false],
|
||||
texts: ["abc"],
|
||||
state: {
|
||||
accountConfigured: false,
|
||||
canUseEnv: true,
|
||||
hasConfigToken: false,
|
||||
},
|
||||
expected: { useEnv: false, token: "abc" },
|
||||
expectTextCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "keeps existing configured token when confirmed",
|
||||
confirms: [true],
|
||||
texts: [],
|
||||
state: {
|
||||
accountConfigured: true,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: true,
|
||||
},
|
||||
expected: { useEnv: false, token: null },
|
||||
expectTextCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "prompts for token when no env/config token is used",
|
||||
confirms: [false],
|
||||
texts: ["xyz"],
|
||||
state: {
|
||||
accountConfigured: true,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: false,
|
||||
},
|
||||
expected: { useEnv: false, token: "xyz" },
|
||||
expectTextCalls: 1,
|
||||
},
|
||||
])("$name", async ({ confirms, texts, state, expected, expectTextCalls }) => {
|
||||
const prompter = createTokenPrompter({ confirms, texts });
|
||||
const result = await runPromptSingleToken({
|
||||
prompter,
|
||||
accountConfigured: false,
|
||||
canUseEnv: true,
|
||||
hasConfigToken: false,
|
||||
...state,
|
||||
});
|
||||
expect(result).toEqual({ useEnv: true, token: null });
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for token when env exists but user declines env", async () => {
|
||||
const prompter = createTokenPrompter({ confirms: [false], texts: ["abc"] });
|
||||
const result = await runPromptSingleToken({
|
||||
prompter,
|
||||
accountConfigured: false,
|
||||
canUseEnv: true,
|
||||
hasConfigToken: false,
|
||||
});
|
||||
expect(result).toEqual({ useEnv: false, token: "abc" });
|
||||
});
|
||||
|
||||
it("keeps existing configured token when confirmed", async () => {
|
||||
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
|
||||
const result = await runPromptSingleToken({
|
||||
prompter,
|
||||
accountConfigured: true,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: true,
|
||||
});
|
||||
expect(result).toEqual({ useEnv: false, token: null });
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for token when no env/config token is used", async () => {
|
||||
const prompter = createTokenPrompter({ confirms: [false], texts: ["xyz"] });
|
||||
const result = await runPromptSingleToken({
|
||||
prompter,
|
||||
accountConfigured: true,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: false,
|
||||
});
|
||||
expect(result).toEqual({ useEnv: false, token: "xyz" });
|
||||
expect(result).toEqual(expected);
|
||||
expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptSingleChannelSecretInput", () => {
|
||||
it("returns use-env action when plaintext mode selects env fallback", async () => {
|
||||
const prompter = {
|
||||
select: vi.fn(async () => "plaintext"),
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => ""),
|
||||
note: vi.fn(async () => undefined),
|
||||
};
|
||||
const prompter = createSecretInputPrompter({
|
||||
selects: ["plaintext"],
|
||||
confirms: [true],
|
||||
});
|
||||
|
||||
const result = await promptSingleChannelSecretInput({
|
||||
cfg: {},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
prompter: prompter as any,
|
||||
const result = await runPromptSingleChannelSecretInput({
|
||||
prompter,
|
||||
providerHint: "telegram",
|
||||
credentialLabel: "Telegram bot token",
|
||||
accountConfigured: false,
|
||||
canUseEnv: true,
|
||||
hasConfigToken: false,
|
||||
envPrompt: "use env",
|
||||
keepPrompt: "keep",
|
||||
inputPrompt: "token",
|
||||
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
|
||||
});
|
||||
|
||||
@@ -410,25 +460,18 @@ describe("promptSingleChannelSecretInput", () => {
|
||||
|
||||
it("returns ref + resolved value when external env ref is selected", async () => {
|
||||
process.env.OPENCLAW_TEST_TOKEN = "secret-token";
|
||||
const prompter = {
|
||||
select: vi.fn().mockResolvedValueOnce("ref").mockResolvedValueOnce("env"),
|
||||
confirm: vi.fn(async () => false),
|
||||
text: vi.fn(async () => "OPENCLAW_TEST_TOKEN"),
|
||||
note: vi.fn(async () => undefined),
|
||||
};
|
||||
const prompter = createSecretInputPrompter({
|
||||
selects: ["ref", "env"],
|
||||
texts: ["OPENCLAW_TEST_TOKEN"],
|
||||
});
|
||||
|
||||
const result = await promptSingleChannelSecretInput({
|
||||
cfg: {},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
prompter: prompter as any,
|
||||
const result = await runPromptSingleChannelSecretInput({
|
||||
prompter,
|
||||
providerHint: "discord",
|
||||
credentialLabel: "Discord bot token",
|
||||
accountConfigured: false,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: false,
|
||||
envPrompt: "use env",
|
||||
keepPrompt: "keep",
|
||||
inputPrompt: "token",
|
||||
preferredEnvVar: "OPENCLAW_TEST_TOKEN",
|
||||
});
|
||||
|
||||
@@ -444,25 +487,18 @@ describe("promptSingleChannelSecretInput", () => {
|
||||
});
|
||||
|
||||
it("returns keep action when ref mode keeps an existing configured ref", async () => {
|
||||
const prompter = {
|
||||
select: vi.fn(async () => "ref"),
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => ""),
|
||||
note: vi.fn(async () => undefined),
|
||||
};
|
||||
const prompter = createSecretInputPrompter({
|
||||
selects: ["ref"],
|
||||
confirms: [true],
|
||||
});
|
||||
|
||||
const result = await promptSingleChannelSecretInput({
|
||||
cfg: {},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
prompter: prompter as any,
|
||||
const result = await runPromptSingleChannelSecretInput({
|
||||
prompter,
|
||||
providerHint: "telegram",
|
||||
credentialLabel: "Telegram bot token",
|
||||
accountConfigured: true,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: true,
|
||||
envPrompt: "use env",
|
||||
keepPrompt: "keep",
|
||||
inputPrompt: "token",
|
||||
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
|
||||
});
|
||||
|
||||
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
} from "./threading-helpers.js";
|
||||
|
||||
describe("createStaticReplyToModeResolver", () => {
|
||||
it("always returns the configured mode", () => {
|
||||
expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off");
|
||||
expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all");
|
||||
it.each(["off", "all"] as const)("always returns the configured mode %s", (mode) => {
|
||||
expect(createStaticReplyToModeResolver(mode)({ cfg: {} as OpenClawConfig })).toBe(mode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTopLevelChannelReplyToModeResolver", () => {
|
||||
it("reads the top-level channel config", () => {
|
||||
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
|
||||
expect(
|
||||
resolver({
|
||||
cfg: { channels: { "demo-top-level": { replyToMode: "first" } } } as OpenClawConfig,
|
||||
}),
|
||||
).toBe("first");
|
||||
});
|
||||
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
|
||||
|
||||
it("falls back to off", () => {
|
||||
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
|
||||
expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off");
|
||||
it.each([
|
||||
{
|
||||
name: "reads the top-level channel config",
|
||||
cfg: { channels: { "demo-top-level": { replyToMode: "first" } } } as OpenClawConfig,
|
||||
expected: "first",
|
||||
},
|
||||
{
|
||||
name: "falls back to off",
|
||||
cfg: {} as OpenClawConfig,
|
||||
expected: "off",
|
||||
},
|
||||
])("$name", ({ cfg, expected }) => {
|
||||
expect(resolver({ cfg })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,19 @@ function createPromptAndCredentialSpies(params?: { confirmResult?: boolean; text
|
||||
};
|
||||
}
|
||||
|
||||
function setMinimaxEnv(params: { apiKey?: string; oauthToken?: string } = {}) {
|
||||
if (params.apiKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = params.apiKey; // pragma: allowlist secret
|
||||
}
|
||||
if (params.oauthToken === undefined) {
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
} else {
|
||||
process.env.MINIMAX_OAUTH_TOKEN = params.oauthToken; // pragma: allowlist secret
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMinimaxApiKey(params: {
|
||||
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
|
||||
env?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["env"];
|
||||
@@ -114,8 +127,7 @@ async function ensureMinimaxApiKeyWithEnvRefPrompter(params: {
|
||||
}
|
||||
|
||||
async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) {
|
||||
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv({ apiKey: "env-key" });
|
||||
|
||||
const { confirm, text } = createPromptSpies({
|
||||
confirmResult: params.confirmResult,
|
||||
@@ -193,19 +205,15 @@ describe("normalizeTokenProviderInput", () => {
|
||||
});
|
||||
|
||||
describe("maybeApplyApiKeyFromOption", () => {
|
||||
it("stores normalized token when provider matches", async () => {
|
||||
const { result, setCredential } = await runMaybeApplyDemoToken("demo-provider");
|
||||
it.each(["demo-provider", " DeMo-PrOvIdEr "])(
|
||||
"stores normalized token when provider %p matches",
|
||||
async (tokenProvider) => {
|
||||
const { result, setCredential } = await runMaybeApplyDemoToken(tokenProvider);
|
||||
|
||||
expect(result).toBe("opt-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
|
||||
});
|
||||
|
||||
it("matches provider with whitespace/case normalization", async () => {
|
||||
const { result, setCredential } = await runMaybeApplyDemoToken(" DeMo-PrOvIdEr ");
|
||||
|
||||
expect(result).toBe("opt-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
|
||||
});
|
||||
expect(result).toBe("opt-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
|
||||
},
|
||||
);
|
||||
|
||||
it("skips when provider does not match", async () => {
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
@@ -251,8 +259,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("uses explicit inline env ref when secret-input-mode=ref selects existing env key", async () => {
|
||||
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv({ apiKey: "env-key" });
|
||||
|
||||
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
|
||||
confirmResult: true,
|
||||
@@ -272,8 +279,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("fails ref mode without select when fallback env var is missing", async () => {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv();
|
||||
|
||||
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
|
||||
confirmResult: true,
|
||||
@@ -294,8 +300,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("uses explicit env for ref fallback instead of host process env", async () => {
|
||||
process.env.MINIMAX_API_KEY = "host-key"; // pragma: allowlist secret
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv({ apiKey: "host-key" });
|
||||
const env = { MINIMAX_API_KEY: "explicit-key" } as NodeJS.ProcessEnv;
|
||||
|
||||
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
|
||||
@@ -316,8 +321,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("re-prompts after provider ref validation failure and succeeds with env ref", async () => {
|
||||
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv({ apiKey: "env-key" });
|
||||
|
||||
const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"];
|
||||
const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"];
|
||||
@@ -355,8 +359,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("never includes resolved env secret values in reference validation notes", async () => {
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; // pragma: allowlist secret
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
setMinimaxEnv({ apiKey: "sk-minimax-redacted-value" });
|
||||
|
||||
const select = vi.fn(async () => "env") as WizardPrompter["select"];
|
||||
const text = vi.fn<WizardPrompter["text"]>().mockResolvedValue("MINIMAX_API_KEY");
|
||||
@@ -407,8 +410,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
it("falls back to env flow and shows note when opts provider does not match", async () => {
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
|
||||
setMinimaxEnv({ apiKey: "env-key" });
|
||||
|
||||
const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({
|
||||
confirmResult: true,
|
||||
|
||||
@@ -160,6 +160,14 @@ async function withAllowFromCacheReadSpy(params: {
|
||||
readSpy.mockRestore();
|
||||
}
|
||||
|
||||
async function seedDefaultAccountAllowFromFixture(stateDir: string) {
|
||||
await seedTelegramAllowFromFixtures({
|
||||
stateDir,
|
||||
scopedAccountId: DEFAULT_ACCOUNT_ID,
|
||||
scopedAllowFrom: ["1002"],
|
||||
});
|
||||
}
|
||||
|
||||
describe("pairing store", () => {
|
||||
it("reuses pending code and reports created=false", async () => {
|
||||
await withTempStateDir(async () => {
|
||||
@@ -448,11 +456,7 @@ describe("pairing store", () => {
|
||||
|
||||
it("reads legacy channel-scoped allowFrom for default account", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await seedTelegramAllowFromFixtures({
|
||||
stateDir,
|
||||
scopedAccountId: "default",
|
||||
scopedAllowFrom: ["1002"],
|
||||
});
|
||||
await seedDefaultAccountAllowFromFixture(stateDir);
|
||||
|
||||
const scoped = await readChannelAllowFromStore("telegram", process.env, DEFAULT_ACCOUNT_ID);
|
||||
expect(scoped).toEqual(["1002", "1001"]);
|
||||
@@ -461,11 +465,7 @@ describe("pairing store", () => {
|
||||
|
||||
it("uses default-account allowFrom when account id is omitted", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await seedTelegramAllowFromFixtures({
|
||||
stateDir,
|
||||
scopedAccountId: DEFAULT_ACCOUNT_ID,
|
||||
scopedAllowFrom: ["1002"],
|
||||
});
|
||||
await seedDefaultAccountAllowFromFixture(stateDir);
|
||||
|
||||
const asyncScoped = await readChannelAllowFromStore("telegram", process.env);
|
||||
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env);
|
||||
@@ -474,22 +474,23 @@ describe("pairing store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses cached async allowFrom reads and invalidates on file updates", async () => {
|
||||
it.each([
|
||||
{
|
||||
label: "async",
|
||||
createReadSpy: () => vi.spyOn(fs, "readFile"),
|
||||
readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"),
|
||||
},
|
||||
{
|
||||
label: "sync",
|
||||
createReadSpy: () => vi.spyOn(fsSync, "readFileSync"),
|
||||
readAllowFrom: async () => readChannelAllowFromStoreSync("telegram", process.env, "yy"),
|
||||
},
|
||||
])("reuses cached $label allowFrom reads and invalidates on file updates", async (variant) => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await withAllowFromCacheReadSpy({
|
||||
stateDir,
|
||||
createReadSpy: () => vi.spyOn(fs, "readFile"),
|
||||
readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses cached sync allowFrom reads and invalidates on file updates", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await withAllowFromCacheReadSpy({
|
||||
stateDir,
|
||||
createReadSpy: () => vi.spyOn(fsSync, "readFileSync"),
|
||||
readAllowFrom: async () => readChannelAllowFromStoreSync("telegram", process.env, "yy"),
|
||||
createReadSpy: variant.createReadSpy,
|
||||
readAllowFrom: variant.readAllowFrom,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,36 @@ import {
|
||||
|
||||
const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID;
|
||||
|
||||
function createMergedReaderCfg(
|
||||
channelId: "whatsapp" | "imessage",
|
||||
accountConfig: { allowFrom: string[]; defaultTo: string },
|
||||
) {
|
||||
return {
|
||||
channels: {
|
||||
[channelId]: {
|
||||
allowFrom: ["root"],
|
||||
defaultTo: " root:chat ",
|
||||
accounts: {
|
||||
alt: accountConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfigWritesCfg() {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
Work: { configWrites: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectAdapterAllowFromAndDefaultTo(adapter: unknown) {
|
||||
const channelAdapter = adapter as {
|
||||
resolveAllowFrom?: (params: { cfg: object; accountId: string }) => unknown;
|
||||
@@ -46,108 +76,92 @@ function expectAdapterAllowFromAndDefaultTo(adapter: unknown) {
|
||||
}
|
||||
|
||||
describe("mapAllowFromEntries", () => {
|
||||
it("coerces allowFrom entries to strings", () => {
|
||||
expect(mapAllowFromEntries(["user", 42])).toEqual(["user", "42"]);
|
||||
});
|
||||
|
||||
it("returns empty list for missing input", () => {
|
||||
expect(mapAllowFromEntries(undefined)).toEqual([]);
|
||||
it.each([
|
||||
{
|
||||
name: "coerces allowFrom entries to strings",
|
||||
input: ["user", 42],
|
||||
expected: ["user", "42"],
|
||||
},
|
||||
{
|
||||
name: "returns empty list for missing input",
|
||||
input: undefined,
|
||||
expected: [],
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(mapAllowFromEntries(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOptionalConfigString", () => {
|
||||
it("trims and returns string values", () => {
|
||||
expect(resolveOptionalConfigString(" room:123 ")).toBe("room:123");
|
||||
});
|
||||
|
||||
it("coerces numeric values", () => {
|
||||
expect(resolveOptionalConfigString(123)).toBe("123");
|
||||
});
|
||||
|
||||
it("returns undefined for empty values", () => {
|
||||
expect(resolveOptionalConfigString(" ")).toBeUndefined();
|
||||
expect(resolveOptionalConfigString(undefined)).toBeUndefined();
|
||||
it.each([
|
||||
{
|
||||
name: "trims and returns string values",
|
||||
input: " room:123 ",
|
||||
expected: "room:123",
|
||||
},
|
||||
{
|
||||
name: "coerces numeric values",
|
||||
input: 123,
|
||||
expected: "123",
|
||||
},
|
||||
{
|
||||
name: "returns undefined for empty string values",
|
||||
input: " ",
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined for missing values",
|
||||
input: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(resolveOptionalConfigString(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider config readers", () => {
|
||||
it("reads merged WhatsApp allowFrom/defaultTo without the channel registry", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["root"],
|
||||
defaultTo: " root:chat ",
|
||||
accounts: {
|
||||
alt: {
|
||||
allowFrom: ["49123", "42"],
|
||||
defaultTo: " alt:chat ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveWhatsAppConfigAllowFrom({ cfg, accountId: "alt" })).toEqual(["49123", "42"]);
|
||||
expect(resolveWhatsAppConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
|
||||
});
|
||||
|
||||
it("reads merged iMessage allowFrom/defaultTo without the channel registry", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
allowFrom: ["root"],
|
||||
defaultTo: " root:chat ",
|
||||
accounts: {
|
||||
alt: {
|
||||
allowFrom: ["chat_id:9", "user@example.com"],
|
||||
defaultTo: " alt:chat ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveIMessageConfigAllowFrom({ cfg, accountId: "alt" })).toEqual([
|
||||
"chat_id:9",
|
||||
"user@example.com",
|
||||
]);
|
||||
expect(resolveIMessageConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
|
||||
it.each([
|
||||
{
|
||||
name: "reads merged WhatsApp allowFrom/defaultTo without the channel registry",
|
||||
cfg: createMergedReaderCfg("whatsapp", {
|
||||
allowFrom: ["49123", "42"],
|
||||
defaultTo: " alt:chat ",
|
||||
}),
|
||||
resolveAllowFrom: resolveWhatsAppConfigAllowFrom,
|
||||
resolveDefaultTo: resolveWhatsAppConfigDefaultTo,
|
||||
expectedAllowFrom: ["49123", "42"],
|
||||
},
|
||||
{
|
||||
name: "reads merged iMessage allowFrom/defaultTo without the channel registry",
|
||||
cfg: createMergedReaderCfg("imessage", {
|
||||
allowFrom: ["chat_id:9", "user@example.com"],
|
||||
defaultTo: " alt:chat ",
|
||||
}),
|
||||
resolveAllowFrom: resolveIMessageConfigAllowFrom,
|
||||
resolveDefaultTo: resolveIMessageConfigDefaultTo,
|
||||
expectedAllowFrom: ["chat_id:9", "user@example.com"],
|
||||
},
|
||||
])("$name", ({ cfg, resolveAllowFrom, resolveDefaultTo, expectedAllowFrom }) => {
|
||||
expect(resolveAllowFrom({ cfg, accountId: "alt" })).toEqual(expectedAllowFrom);
|
||||
expect(resolveDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config write helpers", () => {
|
||||
it("matches account ids case-insensitively", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
Work: { configWrites: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId: "work" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
resolveChannelConfigWrites({
|
||||
cfg: createConfigWritesCfg(),
|
||||
channelId: "telegram",
|
||||
accountId: "work",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks account-scoped writes when the configured account key differs only by case", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
Work: { configWrites: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg,
|
||||
cfg: createConfigWritesCfg(),
|
||||
target: {
|
||||
kind: "account",
|
||||
scope: { channelId: "telegram", accountId: "work" },
|
||||
|
||||
@@ -71,39 +71,45 @@ describe("sendPayloadWithChunkedTextAndMedia", () => {
|
||||
});
|
||||
|
||||
describe("resolveOutboundMediaUrls", () => {
|
||||
it("prefers mediaUrls over the legacy single-media field", () => {
|
||||
expect(
|
||||
resolveOutboundMediaUrls({
|
||||
it.each([
|
||||
{
|
||||
name: "prefers mediaUrls over the legacy single-media field",
|
||||
payload: {
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
mediaUrl: "https://example.com/legacy.png",
|
||||
}),
|
||||
).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
|
||||
});
|
||||
|
||||
it("falls back to the legacy single-media field", () => {
|
||||
expect(
|
||||
resolveOutboundMediaUrls({
|
||||
},
|
||||
expected: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
},
|
||||
{
|
||||
name: "falls back to the legacy single-media field",
|
||||
payload: {
|
||||
mediaUrl: "https://example.com/legacy.png",
|
||||
}),
|
||||
).toEqual(["https://example.com/legacy.png"]);
|
||||
},
|
||||
expected: ["https://example.com/legacy.png"],
|
||||
},
|
||||
])("$name", ({ payload, expected }) => {
|
||||
expect(resolveOutboundMediaUrls(payload)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countOutboundMedia", () => {
|
||||
it("counts normalized media entries", () => {
|
||||
expect(
|
||||
countOutboundMedia({
|
||||
it.each([
|
||||
{
|
||||
name: "counts normalized media entries",
|
||||
payload: {
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("counts legacy single-media payloads", () => {
|
||||
expect(
|
||||
countOutboundMedia({
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "counts legacy single-media payloads",
|
||||
payload: {
|
||||
mediaUrl: "https://example.com/legacy.png",
|
||||
}),
|
||||
).toBe(1);
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
])("$name", ({ payload, expected }) => {
|
||||
expect(countOutboundMedia(payload)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,33 +122,76 @@ describe("hasOutboundMedia", () => {
|
||||
});
|
||||
|
||||
describe("hasOutboundText", () => {
|
||||
it("checks raw text presence by default", () => {
|
||||
expect(hasOutboundText({ text: "hello" })).toBe(true);
|
||||
expect(hasOutboundText({ text: " " })).toBe(true);
|
||||
expect(hasOutboundText({})).toBe(false);
|
||||
});
|
||||
|
||||
it("can trim whitespace-only text", () => {
|
||||
expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false);
|
||||
expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true);
|
||||
it.each([
|
||||
{
|
||||
name: "checks raw text presence by default",
|
||||
payload: { text: "hello" },
|
||||
options: undefined,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "treats whitespace-only text as present by default",
|
||||
payload: { text: " " },
|
||||
options: undefined,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns false when text is missing",
|
||||
payload: {},
|
||||
options: undefined,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "can trim whitespace-only text",
|
||||
payload: { text: " " },
|
||||
options: { trim: true },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "keeps non-empty trimmed text",
|
||||
payload: { text: " hi " },
|
||||
options: { trim: true },
|
||||
expected: true,
|
||||
},
|
||||
])("$name", ({ payload, options, expected }) => {
|
||||
expect(hasOutboundText(payload, options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOutboundReplyContent", () => {
|
||||
it("detects text or media content", () => {
|
||||
expect(hasOutboundReplyContent({ text: "hello" })).toBe(true);
|
||||
expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true);
|
||||
expect(hasOutboundReplyContent({})).toBe(false);
|
||||
});
|
||||
|
||||
it("can ignore whitespace-only text unless media exists", () => {
|
||||
expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false);
|
||||
expect(
|
||||
hasOutboundReplyContent(
|
||||
{ text: " ", mediaUrls: ["https://example.com/a.png"] },
|
||||
{ trimText: true },
|
||||
),
|
||||
).toBe(true);
|
||||
it.each([
|
||||
{
|
||||
name: "detects text content",
|
||||
payload: { text: "hello" },
|
||||
options: undefined,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "detects media content",
|
||||
payload: { mediaUrl: "https://example.com/a.png" },
|
||||
options: undefined,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns false when text and media are both missing",
|
||||
payload: {},
|
||||
options: undefined,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "can ignore whitespace-only text",
|
||||
payload: { text: " " },
|
||||
options: { trimText: true },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "still reports content when trimmed text is blank but media exists",
|
||||
payload: { text: " ", mediaUrls: ["https://example.com/a.png"] },
|
||||
options: { trimText: true },
|
||||
expected: true,
|
||||
},
|
||||
])("$name", ({ payload, options, expected }) => {
|
||||
expect(hasOutboundReplyContent(payload, options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,16 +235,27 @@ describe("resolveSendableOutboundReplyParts", () => {
|
||||
});
|
||||
|
||||
describe("resolveTextChunksWithFallback", () => {
|
||||
it("returns existing chunks unchanged", () => {
|
||||
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("falls back to the full text when chunkers return nothing", () => {
|
||||
expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("returns empty for empty text with no chunks", () => {
|
||||
expect(resolveTextChunksWithFallback("", [])).toEqual([]);
|
||||
it.each([
|
||||
{
|
||||
name: "returns existing chunks unchanged",
|
||||
text: "hello",
|
||||
chunks: ["a", "b"],
|
||||
expected: ["a", "b"],
|
||||
},
|
||||
{
|
||||
name: "falls back to the full text when chunkers return nothing",
|
||||
text: "hello",
|
||||
chunks: [],
|
||||
expected: ["hello"],
|
||||
},
|
||||
{
|
||||
name: "returns empty for empty text with no chunks",
|
||||
text: "",
|
||||
chunks: [],
|
||||
expected: [],
|
||||
},
|
||||
])("$name", ({ text, chunks, expected }) => {
|
||||
expect(resolveTextChunksWithFallback(text, chunks)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,44 +11,157 @@ import {
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
describe("createDefaultChannelRuntimeState", () => {
|
||||
it("builds default runtime state without extra fields", () => {
|
||||
expect(createDefaultChannelRuntimeState("default")).toEqual({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
const defaultRuntimeState = {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
it("merges extra fields into the default runtime state", () => {
|
||||
expect(
|
||||
createDefaultChannelRuntimeState("alerts", {
|
||||
type ExpectedAccountSnapshot = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
probe?: unknown;
|
||||
lastInboundAt: number | null;
|
||||
lastOutboundAt: number | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const defaultChannelSummary = {
|
||||
configured: false,
|
||||
...defaultRuntimeState,
|
||||
};
|
||||
|
||||
const defaultTokenChannelSummary = {
|
||||
...defaultChannelSummary,
|
||||
tokenSource: "none",
|
||||
mode: null,
|
||||
probe: undefined,
|
||||
lastProbeAt: null,
|
||||
};
|
||||
|
||||
const defaultAccountSnapshot: ExpectedAccountSnapshot = {
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: undefined,
|
||||
configured: false,
|
||||
...defaultRuntimeState,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
};
|
||||
|
||||
function expectedAccountSnapshot(
|
||||
overrides: Partial<ExpectedAccountSnapshot> = {},
|
||||
): ExpectedAccountSnapshot {
|
||||
return {
|
||||
...defaultAccountSnapshot,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const adapterAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profileUrl: "https://example.test",
|
||||
};
|
||||
|
||||
const adapterRuntime = {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
};
|
||||
|
||||
const adapterProbe = { ok: true };
|
||||
|
||||
function expectedAdapterAccountSnapshot() {
|
||||
return {
|
||||
...expectedAccountSnapshot({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
running: true,
|
||||
probe: adapterProbe,
|
||||
}),
|
||||
profileUrl: adapterAccount.profileUrl,
|
||||
connected: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createComputedStatusAdapter() {
|
||||
return createComputedAccountStatusAdapter<
|
||||
{ accountId: string; enabled: boolean; profileUrl: string },
|
||||
{ ok: boolean }
|
||||
>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState("default"),
|
||||
resolveAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: true,
|
||||
extra: {
|
||||
profileUrl: account.profileUrl,
|
||||
connected: runtime?.running ?? false,
|
||||
probe,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function createAsyncStatusAdapter() {
|
||||
return createAsyncComputedAccountStatusAdapter<
|
||||
{ accountId: string; enabled: boolean; profileUrl: string },
|
||||
{ ok: boolean }
|
||||
>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState("default"),
|
||||
resolveAccountSnapshot: async ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: true,
|
||||
extra: {
|
||||
profileUrl: account.profileUrl,
|
||||
connected: runtime?.running ?? false,
|
||||
probe,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createDefaultChannelRuntimeState", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "builds default runtime state without extra fields",
|
||||
accountId: "default",
|
||||
extra: undefined,
|
||||
expected: {
|
||||
accountId: "default",
|
||||
...defaultRuntimeState,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merges extra fields into the default runtime state",
|
||||
accountId: "alerts",
|
||||
extra: {
|
||||
probeAt: 123,
|
||||
healthy: true,
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "alerts",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probeAt: 123,
|
||||
healthy: true,
|
||||
});
|
||||
},
|
||||
expected: {
|
||||
accountId: "alerts",
|
||||
...defaultRuntimeState,
|
||||
probeAt: 123,
|
||||
healthy: true,
|
||||
},
|
||||
},
|
||||
])("$name", ({ accountId, extra, expected }) => {
|
||||
expect(createDefaultChannelRuntimeState(accountId, extra)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBaseChannelStatusSummary", () => {
|
||||
it("defaults missing values", () => {
|
||||
expect(buildBaseChannelStatusSummary({})).toEqual({
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
expect(buildBaseChannelStatusSummary({})).toEqual(defaultChannelSummary);
|
||||
});
|
||||
|
||||
it("keeps explicit values", () => {
|
||||
@@ -61,6 +174,7 @@ describe("buildBaseChannelStatusSummary", () => {
|
||||
lastError: "boom",
|
||||
}),
|
||||
).toEqual({
|
||||
...defaultChannelSummary,
|
||||
configured: true,
|
||||
running: true,
|
||||
lastStartAt: 1,
|
||||
@@ -81,13 +195,10 @@ describe("buildBaseChannelStatusSummary", () => {
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...defaultChannelSummary,
|
||||
configured: true,
|
||||
mode: "webhook",
|
||||
secretSource: "env",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -98,19 +209,7 @@ describe("buildBaseAccountStatusSnapshot", () => {
|
||||
buildBaseAccountStatusSnapshot({
|
||||
account: { accountId: "default", enabled: true, configured: true },
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
});
|
||||
).toEqual(expectedAccountSnapshot({ enabled: true, configured: true }));
|
||||
});
|
||||
|
||||
it("merges extra snapshot fields after the shared account shape", () => {
|
||||
@@ -125,17 +224,7 @@ describe("buildBaseAccountStatusSnapshot", () => {
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: undefined,
|
||||
configured: true,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
...expectedAccountSnapshot({ configured: true }),
|
||||
connected: true,
|
||||
mode: "polling",
|
||||
});
|
||||
@@ -150,19 +239,7 @@ describe("buildComputedAccountStatusSnapshot", () => {
|
||||
enabled: true,
|
||||
configured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
});
|
||||
).toEqual(expectedAccountSnapshot({ enabled: true }));
|
||||
});
|
||||
|
||||
it("merges computed extras after the shared fields", () => {
|
||||
@@ -177,173 +254,100 @@ describe("buildComputedAccountStatusSnapshot", () => {
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: undefined,
|
||||
configured: true,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
...expectedAccountSnapshot({ configured: true }),
|
||||
connected: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createComputedAccountStatusAdapter", () => {
|
||||
it("builds account snapshots from computed account metadata and extras", () => {
|
||||
const status = createComputedAccountStatusAdapter<
|
||||
{ accountId: string; enabled: boolean; profileUrl: string },
|
||||
{ ok: boolean }
|
||||
>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState("default"),
|
||||
resolveAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: true,
|
||||
extra: {
|
||||
profileUrl: account.profileUrl,
|
||||
connected: runtime?.running ?? false,
|
||||
probe,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
status.buildAccountSnapshot?.({
|
||||
account: { accountId: "default", enabled: true, profileUrl: "https://example.test" },
|
||||
cfg: {} as never,
|
||||
runtime: { accountId: "default", running: true },
|
||||
probe: { ok: true },
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
running: true,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: { ok: true },
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
profileUrl: "https://example.test",
|
||||
connected: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAsyncComputedAccountStatusAdapter", () => {
|
||||
it("builds account snapshots from async computed account metadata and extras", async () => {
|
||||
const status = createAsyncComputedAccountStatusAdapter<
|
||||
{ accountId: string; enabled: boolean; profileUrl: string },
|
||||
{ ok: boolean }
|
||||
>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState("default"),
|
||||
resolveAccountSnapshot: async ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: true,
|
||||
extra: {
|
||||
profileUrl: account.profileUrl,
|
||||
connected: runtime?.running ?? false,
|
||||
probe,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
status.buildAccountSnapshot?.({
|
||||
account: { accountId: "default", enabled: true, profileUrl: "https://example.test" },
|
||||
cfg: {} as never,
|
||||
runtime: { accountId: "default", running: true },
|
||||
probe: { ok: true },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
running: true,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: { ok: true },
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
profileUrl: "https://example.test",
|
||||
connected: true,
|
||||
});
|
||||
});
|
||||
describe("computed account status adapters", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "sync",
|
||||
createStatus: createComputedStatusAdapter,
|
||||
},
|
||||
{
|
||||
name: "async",
|
||||
createStatus: createAsyncStatusAdapter,
|
||||
},
|
||||
])(
|
||||
"builds account snapshots from $name computed account metadata and extras",
|
||||
async ({ createStatus }) => {
|
||||
const status = createStatus();
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
status.buildAccountSnapshot?.({
|
||||
account: adapterAccount,
|
||||
cfg: {} as never,
|
||||
runtime: adapterRuntime,
|
||||
probe: adapterProbe,
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual(expectedAdapterAccountSnapshot());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("buildRuntimeAccountStatusSnapshot", () => {
|
||||
it("builds runtime lifecycle fields with defaults", () => {
|
||||
expect(buildRuntimeAccountStatusSnapshot({})).toEqual({
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges extra fields into runtime snapshots", () => {
|
||||
expect(buildRuntimeAccountStatusSnapshot({}, { port: 3978 })).toEqual({
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
port: 3978,
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "builds runtime lifecycle fields with defaults",
|
||||
input: {},
|
||||
extra: undefined,
|
||||
expected: {
|
||||
...defaultRuntimeState,
|
||||
probe: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merges extra fields into runtime snapshots",
|
||||
input: {},
|
||||
extra: { port: 3978 },
|
||||
expected: {
|
||||
...defaultRuntimeState,
|
||||
probe: undefined,
|
||||
port: 3978,
|
||||
},
|
||||
},
|
||||
])("$name", ({ input, extra, expected }) => {
|
||||
expect(buildRuntimeAccountStatusSnapshot(input, extra)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTokenChannelStatusSummary", () => {
|
||||
it("includes token/probe fields with mode by default", () => {
|
||||
expect(buildTokenChannelStatusSummary({})).toEqual({
|
||||
configured: false,
|
||||
tokenSource: "none",
|
||||
running: false,
|
||||
mode: null,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastProbeAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("can omit mode for channels without a mode state", () => {
|
||||
expect(
|
||||
buildTokenChannelStatusSummary(
|
||||
{
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
running: true,
|
||||
lastStartAt: 1,
|
||||
lastStopAt: 2,
|
||||
lastError: "boom",
|
||||
probe: { ok: true },
|
||||
lastProbeAt: 3,
|
||||
},
|
||||
{ includeMode: false },
|
||||
),
|
||||
).toEqual({
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
running: true,
|
||||
lastStartAt: 1,
|
||||
lastStopAt: 2,
|
||||
lastError: "boom",
|
||||
probe: { ok: true },
|
||||
lastProbeAt: 3,
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "includes token/probe fields with mode by default",
|
||||
input: {},
|
||||
options: undefined,
|
||||
expected: defaultTokenChannelSummary,
|
||||
},
|
||||
{
|
||||
name: "can omit mode for channels without a mode state",
|
||||
input: {
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
running: true,
|
||||
lastStartAt: 1,
|
||||
lastStopAt: 2,
|
||||
lastError: "boom",
|
||||
probe: { ok: true },
|
||||
lastProbeAt: 3,
|
||||
},
|
||||
options: { includeMode: false },
|
||||
expected: {
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
running: true,
|
||||
lastStartAt: 1,
|
||||
lastStopAt: 2,
|
||||
lastError: "boom",
|
||||
probe: { ok: true },
|
||||
lastProbeAt: 3,
|
||||
},
|
||||
},
|
||||
])("$name", ({ input, options, expected }) => {
|
||||
expect(buildTokenChannelStatusSummary(input, options)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,44 +23,49 @@ function createPluginSourceRoots() {
|
||||
}
|
||||
|
||||
describe("formatPluginSourceForTable", () => {
|
||||
it("shortens bundled plugin sources under the stock root", () => {
|
||||
const roots = createPluginSourceRoots();
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "bundled",
|
||||
source: path.join(roots.stock, "demo-stock", "index.ts"),
|
||||
},
|
||||
roots,
|
||||
);
|
||||
expect(out.value).toBe("stock:demo-stock/index.ts");
|
||||
expect(out.rootKey).toBe("stock");
|
||||
});
|
||||
|
||||
it("shortens workspace plugin sources under the workspace root", () => {
|
||||
const roots = createPluginSourceRoots();
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "workspace",
|
||||
source: path.join(roots.workspace, "demo-workspace", "index.ts"),
|
||||
},
|
||||
roots,
|
||||
);
|
||||
expect(out.value).toBe("workspace:demo-workspace/index.ts");
|
||||
expect(out.rootKey).toBe("workspace");
|
||||
});
|
||||
|
||||
it("shortens global plugin sources under the global root", () => {
|
||||
const roots = createPluginSourceRoots();
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "global",
|
||||
source: path.join(roots.global, "demo-global", "index.js"),
|
||||
},
|
||||
roots,
|
||||
);
|
||||
expect(out.value).toBe("global:demo-global/index.js");
|
||||
expect(out.rootKey).toBe("global");
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "bundled plugin sources under the stock root",
|
||||
origin: "bundled" as const,
|
||||
sourceKey: "stock" as const,
|
||||
dirName: "demo-stock",
|
||||
fileName: "index.ts",
|
||||
expectedValue: "stock:demo-stock/index.ts",
|
||||
expectedRootKey: "stock" as const,
|
||||
},
|
||||
{
|
||||
name: "workspace plugin sources under the workspace root",
|
||||
origin: "workspace" as const,
|
||||
sourceKey: "workspace" as const,
|
||||
dirName: "demo-workspace",
|
||||
fileName: "index.ts",
|
||||
expectedValue: "workspace:demo-workspace/index.ts",
|
||||
expectedRootKey: "workspace" as const,
|
||||
},
|
||||
{
|
||||
name: "global plugin sources under the global root",
|
||||
origin: "global" as const,
|
||||
sourceKey: "global" as const,
|
||||
dirName: "demo-global",
|
||||
fileName: "index.js",
|
||||
expectedValue: "global:demo-global/index.js",
|
||||
expectedRootKey: "global" as const,
|
||||
},
|
||||
])(
|
||||
"shortens $name",
|
||||
({ origin, sourceKey, dirName, fileName, expectedValue, expectedRootKey }) => {
|
||||
const roots = createPluginSourceRoots();
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin,
|
||||
source: path.join(roots[sourceKey], dirName, fileName),
|
||||
},
|
||||
roots,
|
||||
);
|
||||
expect(out.value).toBe(expectedValue);
|
||||
expect(out.rootKey).toBe(expectedRootKey);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves source roots from an explicit env override", () => {
|
||||
const homeDir = path.resolve(path.sep, "tmp", "openclaw-home");
|
||||
|
||||
@@ -298,6 +298,14 @@ describe("security/dm-policy-shared", () => {
|
||||
expectedReactionAllowed: boolean;
|
||||
};
|
||||
|
||||
type DecisionCase = {
|
||||
name: string;
|
||||
input: Parameters<typeof resolveDmGroupAccessDecision>[0];
|
||||
expected:
|
||||
| ReturnType<typeof resolveDmGroupAccessDecision>
|
||||
| Pick<ReturnType<typeof resolveDmGroupAccessDecision>, "decision">;
|
||||
};
|
||||
|
||||
function createParityCase({
|
||||
name,
|
||||
...overrides
|
||||
@@ -387,97 +395,113 @@ describe("security/dm-policy-shared", () => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const channel of channels) {
|
||||
it(`[${channel}] blocks groups when group allowlist is empty`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
const decisionCases: DecisionCase[] = [
|
||||
{
|
||||
name: "blocks groups when group allowlist is empty",
|
||||
input: {
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
effectiveAllowFrom: ["owner"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
},
|
||||
expected: {
|
||||
decision: "block",
|
||||
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
|
||||
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||
});
|
||||
});
|
||||
|
||||
it(`[${channel}] allows groups when group policy is open`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allows groups when group policy is open",
|
||||
input: {
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "open",
|
||||
effectiveAllowFrom: ["owner"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
},
|
||||
expected: {
|
||||
decision: "allow",
|
||||
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
|
||||
reason: "groupPolicy=open",
|
||||
});
|
||||
});
|
||||
|
||||
it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks DM allowlist mode when allowlist is empty",
|
||||
input: {
|
||||
isGroup: false,
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
},
|
||||
expected: {
|
||||
decision: "block",
|
||||
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
|
||||
reason: "dmPolicy=allowlist (not allowlisted)",
|
||||
});
|
||||
});
|
||||
|
||||
it(`[${channel}] uses pairing flow when DM sender is not allowlisted`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses pairing flow when DM sender is not allowlisted",
|
||||
input: {
|
||||
isGroup: false,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
},
|
||||
expected: {
|
||||
decision: "pairing",
|
||||
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
|
||||
reason: "dmPolicy=pairing (not allowlisted)",
|
||||
});
|
||||
});
|
||||
|
||||
it(`[${channel}] allows DM sender when allowlisted`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allows DM sender when allowlisted",
|
||||
input: {
|
||||
isGroup: false,
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
effectiveAllowFrom: ["owner"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
expect(decision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it(`[${channel}] blocks group allowlist mode when sender/group is not allowlisted`, () => {
|
||||
const decision = resolveDmGroupAccessDecision({
|
||||
},
|
||||
expected: {
|
||||
decision: "allow",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks group allowlist mode when sender/group is not allowlisted",
|
||||
input: {
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
effectiveAllowFrom: ["owner"],
|
||||
effectiveGroupAllowFrom: ["group:abc"],
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
},
|
||||
expected: {
|
||||
decision: "block",
|
||||
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
|
||||
reason: "groupPolicy=allowlist (not allowlisted)",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const channel of channels) {
|
||||
for (const testCase of decisionCases) {
|
||||
it(`[${channel}] ${testCase.name}`, () => {
|
||||
const decision = resolveDmGroupAccessDecision(testCase.input);
|
||||
if ("reasonCode" in testCase.expected && "reason" in testCase.expected) {
|
||||
expect(decision).toEqual(testCase.expected);
|
||||
return;
|
||||
}
|
||||
expect(decision).toMatchObject(testCase.expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,18 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
import { resolveSendPolicy } from "./send-policy.js";
|
||||
|
||||
describe("resolveSendPolicy", () => {
|
||||
const cfgWithRules = (
|
||||
rules: NonNullable<NonNullable<OpenClawConfig["session"]>["sendPolicy"]>["rules"],
|
||||
) =>
|
||||
({
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules,
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
it("defaults to allow", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
expect(resolveSendPolicy({ cfg })).toBe("allow");
|
||||
@@ -21,55 +33,40 @@ describe("resolveSendPolicy", () => {
|
||||
expect(resolveSendPolicy({ cfg, entry })).toBe("deny");
|
||||
});
|
||||
|
||||
it("rule match by channel + chatType", () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [
|
||||
{
|
||||
action: "deny",
|
||||
match: { channel: "demo-channel", chatType: "group" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const entry: SessionEntry = {
|
||||
sessionId: "s",
|
||||
updatedAt: 0,
|
||||
channel: "demo-channel",
|
||||
chatType: "group",
|
||||
};
|
||||
expect(resolveSendPolicy({ cfg, entry, sessionKey: "demo-channel:group:dev" })).toBe("deny");
|
||||
});
|
||||
|
||||
it("rule match by keyPrefix", () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny");
|
||||
});
|
||||
|
||||
it("rule match by rawKeyPrefix", () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:demo-channel:group:dev" })).toBe(
|
||||
"deny",
|
||||
);
|
||||
expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:other-channel:group:dev" })).toBe(
|
||||
"allow",
|
||||
);
|
||||
it.each([
|
||||
{
|
||||
name: "rule match by channel + chatType",
|
||||
cfg: cfgWithRules([
|
||||
{ action: "deny", match: { channel: "demo-channel", chatType: "group" } },
|
||||
]),
|
||||
entry: {
|
||||
sessionId: "s",
|
||||
updatedAt: 0,
|
||||
channel: "demo-channel",
|
||||
chatType: "group",
|
||||
} as SessionEntry,
|
||||
sessionKey: "demo-channel:group:dev",
|
||||
expected: "deny",
|
||||
},
|
||||
{
|
||||
name: "rule match by keyPrefix",
|
||||
cfg: cfgWithRules([{ action: "deny", match: { keyPrefix: "cron:" } }]),
|
||||
sessionKey: "cron:job-1",
|
||||
expected: "deny",
|
||||
},
|
||||
{
|
||||
name: "rule match by rawKeyPrefix",
|
||||
cfg: cfgWithRules([{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }]),
|
||||
sessionKey: "agent:main:demo-channel:group:dev",
|
||||
expected: "deny",
|
||||
},
|
||||
{
|
||||
name: "rawKeyPrefix does not match other channels",
|
||||
cfg: cfgWithRules([{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }]),
|
||||
sessionKey: "agent:main:other-channel:group:dev",
|
||||
expected: "allow",
|
||||
},
|
||||
])("$name", ({ cfg, entry, sessionKey, expected }) => {
|
||||
expect(resolveSendPolicy({ cfg, entry, sessionKey })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user