mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
refactor(test): dedupe startup and nostr test fixtures
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import { discordPlugin } from "./channel.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -49,33 +46,6 @@ function createCfg(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedDiscordAccount> {
|
||||
const account = discordPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
params.accountId,
|
||||
) as ResolvedDiscordAccount;
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
abortSignal: new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
probeDiscordMock.mockReset();
|
||||
monitorDiscordProviderMock.mockReset();
|
||||
@@ -189,9 +159,9 @@ describe("discordPlugin outbound", () => {
|
||||
|
||||
const cfg = createCfg();
|
||||
await discordPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
createStartAccountContext({
|
||||
account: discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount,
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
abortStartedAccount,
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ChannelAccountSnapshot } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -21,6 +21,21 @@ vi.mock("./monitor.js", async () => {
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("googlechatPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -30,28 +45,12 @@ describe("googlechatPlugin gateway.startAccount", () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const account: ResolvedGoogleChatAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: googlechatPlugin.gateway!.startAccount!,
|
||||
account,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: () =>
|
||||
vi.waitFor(() => {
|
||||
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
|
||||
}),
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
@@ -62,7 +61,7 @@ describe("googlechatPlugin gateway.startAccount", () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expect(patches.some((entry) => entry.running === true)).toBe(true);
|
||||
expect(patches.some((entry) => entry.running === false)).toBe(true);
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
|
||||
@@ -19,6 +20,24 @@ vi.mock("./monitor.js", async () => {
|
||||
|
||||
import { ircPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedIrcAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("ircPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -28,32 +47,13 @@ describe("ircPlugin gateway.startAccount", () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
|
||||
const account: ResolvedIrcAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: ircPlugin.gateway!.startAccount!,
|
||||
account,
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: () =>
|
||||
vi.waitFor(() => {
|
||||
expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce();
|
||||
}),
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type {
|
||||
ChannelGatewayContext,
|
||||
ChannelAccountSnapshot,
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -33,34 +28,14 @@ function createRuntime() {
|
||||
return { runtime, probeLineBot, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
token: string;
|
||||
secret: string;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
abortSignal?: AbortSignal;
|
||||
}): ChannelGatewayContext<ResolvedLineAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: params.token,
|
||||
channelSecret: params.secret,
|
||||
tokenSource: "config" as const,
|
||||
config: {} as ResolvedLineAccount["config"],
|
||||
},
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: params.runtime,
|
||||
abortSignal: params.abortSignal ?? new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
enabled: true,
|
||||
channelAccessToken: params.token,
|
||||
channelSecret: params.secret,
|
||||
tokenSource: "config",
|
||||
config: {} as ResolvedLineAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,9 +46,8 @@ describe("linePlugin gateway.startAccount", () => {
|
||||
|
||||
await expect(
|
||||
linePlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
token: "token",
|
||||
secret: " ",
|
||||
createStartAccountContext({
|
||||
account: createAccount({ token: "token", secret: " " }),
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
),
|
||||
@@ -89,9 +63,8 @@ describe("linePlugin gateway.startAccount", () => {
|
||||
|
||||
await expect(
|
||||
linePlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
token: " ",
|
||||
secret: "secret",
|
||||
createStartAccountContext({
|
||||
account: createAccount({ token: " ", secret: "secret" }),
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
),
|
||||
@@ -107,9 +80,8 @@ describe("linePlugin gateway.startAccount", () => {
|
||||
|
||||
const abort = new AbortController();
|
||||
const task = linePlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
token: "token",
|
||||
secret: "secret",
|
||||
createStartAccountContext({
|
||||
account: createAccount({ token: "token", secret: "secret" }),
|
||||
runtime: createRuntimeEnv(),
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStartAccountContext } from "../../../test/helpers/extensions/star
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
|
||||
@@ -49,10 +50,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => {
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: () =>
|
||||
vi.waitFor(() => {
|
||||
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
|
||||
}),
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
|
||||
@@ -3,6 +3,11 @@ import { createStartAccountContext } from "../../../test/helpers/extensions/star
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { nostrPlugin } from "./channel.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
import {
|
||||
TEST_RELAY_URL,
|
||||
TEST_RESOLVED_PRIVATE_KEY,
|
||||
buildResolvedNostrAccount,
|
||||
} from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`),
|
||||
@@ -16,6 +21,16 @@ vi.mock("./nostr-bus.js", () => ({
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
function createCfg() {
|
||||
return {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_RESOLVED_PRIVATE_KEY, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("nostr outbound cfg threading", () => {
|
||||
afterEach(() => {
|
||||
mocks.normalizePubkey.mockClear();
|
||||
@@ -47,26 +62,11 @@ describe("nostr outbound cfg threading", () => {
|
||||
|
||||
const cleanup = (await nostrPlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret
|
||||
publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret
|
||||
relays: ["wss://relay.example.com"],
|
||||
config: {},
|
||||
},
|
||||
abortSignal: new AbortController().signal,
|
||||
account: buildResolvedNostrAccount(),
|
||||
}),
|
||||
)) as { stop: () => void };
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: "resolved-nostr-private-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createCfg();
|
||||
await nostrPlugin.outbound!.sendText!({
|
||||
cfg: cfg as any,
|
||||
to: "NPUB123",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nostrPlugin } from "./channel.js";
|
||||
import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js";
|
||||
|
||||
describe("nostrPlugin", () => {
|
||||
describe("meta", () => {
|
||||
@@ -42,13 +43,7 @@ describe("nostrPlugin", () => {
|
||||
});
|
||||
|
||||
it("listAccountIds returns default for configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
const ids = nostrPlugin.config.listAccountIds(cfg);
|
||||
expect(ids).toContain("default");
|
||||
});
|
||||
@@ -74,8 +69,7 @@ describe("nostrPlugin", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(looksLikeId(hexPubkey)).toBe(true);
|
||||
expect(looksLikeId(TEST_HEX_PRIVATE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid input", () => {
|
||||
@@ -94,8 +88,7 @@ describe("nostrPlugin", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
||||
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,8 +113,7 @@ describe("nostrPlugin", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
||||
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import { createMetrics, type MetricName } from "./metrics.js";
|
||||
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
|
||||
import { createSeenTracker } from "./seen-tracker.js";
|
||||
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
||||
|
||||
function createTracker(maxEntries = 100) {
|
||||
return createSeenTracker({ maxEntries });
|
||||
}
|
||||
|
||||
function createPlainMetrics() {
|
||||
return createMetrics();
|
||||
}
|
||||
|
||||
function createCollectingMetrics() {
|
||||
const events: unknown[] = [];
|
||||
return {
|
||||
events,
|
||||
metrics: createMetrics((event) => events.push(event)),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fuzz Tests for validatePrivateKey
|
||||
@@ -47,7 +64,7 @@ describe("validatePrivateKey fuzz", () => {
|
||||
});
|
||||
|
||||
it("rejects RTL override", () => {
|
||||
const withRtl = "\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const withRtl = `\u202E${TEST_HEX_PRIVATE_KEY}`;
|
||||
expect(() => validatePrivateKey(withRtl)).toThrow();
|
||||
});
|
||||
|
||||
@@ -191,14 +208,12 @@ describe("normalizePubkey fuzz", () => {
|
||||
describe("case sensitivity", () => {
|
||||
it("normalizes uppercase to lowercase", () => {
|
||||
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(normalizePubkey(upper)).toBe(lower);
|
||||
expect(normalizePubkey(upper)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
|
||||
it("normalizes mixed case to lowercase", () => {
|
||||
const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf";
|
||||
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(normalizePubkey(mixed)).toBe(lower);
|
||||
expect(normalizePubkey(mixed)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -210,14 +225,14 @@ describe("normalizePubkey fuzz", () => {
|
||||
describe("SeenTracker fuzz", () => {
|
||||
describe("malformed IDs", () => {
|
||||
it("handles empty string IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
expect(() => tracker.add("")).not.toThrow();
|
||||
expect(tracker.peek("")).toBe(true);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles very long IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
const longId = "a".repeat(100000);
|
||||
expect(() => tracker.add(longId)).not.toThrow();
|
||||
expect(tracker.peek(longId)).toBe(true);
|
||||
@@ -225,7 +240,7 @@ describe("SeenTracker fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles unicode IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
const unicodeId = "事件ID_🎉_тест";
|
||||
expect(() => tracker.add(unicodeId)).not.toThrow();
|
||||
expect(tracker.peek(unicodeId)).toBe(true);
|
||||
@@ -233,7 +248,7 @@ describe("SeenTracker fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles IDs with null bytes", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
const idWithNull = "event\x00id";
|
||||
expect(() => tracker.add(idWithNull)).not.toThrow();
|
||||
expect(tracker.peek(idWithNull)).toBe(true);
|
||||
@@ -241,7 +256,7 @@ describe("SeenTracker fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles prototype property names as IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
|
||||
// These should not affect the tracker's internal operation
|
||||
expect(() => tracker.add("__proto__")).not.toThrow();
|
||||
@@ -260,7 +275,7 @@ describe("SeenTracker fuzz", () => {
|
||||
|
||||
describe("rapid operations", () => {
|
||||
it("handles rapid add/check cycles", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 1000 });
|
||||
const tracker = createTracker(1000);
|
||||
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
const id = `event-${i}`;
|
||||
@@ -277,7 +292,7 @@ describe("SeenTracker fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles concurrent-style operations", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
|
||||
// Simulate interleaved operations
|
||||
for (let i = 0; i < 100; i++) {
|
||||
@@ -296,21 +311,21 @@ describe("SeenTracker fuzz", () => {
|
||||
|
||||
describe("seed edge cases", () => {
|
||||
it("handles empty seed array", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
expect(() => tracker.seed([])).not.toThrow();
|
||||
expect(tracker.size()).toBe(0);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles seed with duplicate IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||
const tracker = createTracker();
|
||||
tracker.seed(["id1", "id1", "id1", "id2", "id2"]);
|
||||
expect(tracker.size()).toBe(2);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles seed larger than maxEntries", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 5 });
|
||||
const tracker = createTracker(5);
|
||||
const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`);
|
||||
tracker.seed(ids);
|
||||
expect(tracker.size()).toBeLessThanOrEqual(5);
|
||||
@@ -326,7 +341,7 @@ describe("SeenTracker fuzz", () => {
|
||||
describe("Metrics fuzz", () => {
|
||||
describe("invalid metric names", () => {
|
||||
it("handles unknown metric names gracefully", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
// Cast to bypass type checking - testing runtime behavior
|
||||
expect(() => {
|
||||
@@ -337,21 +352,21 @@ describe("Metrics fuzz", () => {
|
||||
|
||||
describe("invalid label values", () => {
|
||||
it("handles null relay label", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
expect(() => {
|
||||
metrics.emit("relay.connect", 1, { relay: null as unknown as string });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles undefined relay label", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
expect(() => {
|
||||
metrics.emit("relay.connect", 1, { relay: undefined as unknown as string });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles very long relay URL", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
const longUrl = "wss://" + "a".repeat(10000) + ".com";
|
||||
expect(() => {
|
||||
metrics.emit("relay.connect", 1, { relay: longUrl });
|
||||
@@ -364,7 +379,7 @@ describe("Metrics fuzz", () => {
|
||||
|
||||
describe("extreme values", () => {
|
||||
it("handles NaN value", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
expect(() => metrics.emit("event.received", NaN)).not.toThrow();
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
@@ -372,7 +387,7 @@ describe("Metrics fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles Infinity value", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
expect(() => metrics.emit("event.received", Infinity)).not.toThrow();
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
@@ -380,7 +395,7 @@ describe("Metrics fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles negative value", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
metrics.emit("event.received", -1);
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
@@ -388,7 +403,7 @@ describe("Metrics fuzz", () => {
|
||||
});
|
||||
|
||||
it("handles very large value", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
metrics.emit("event.received", Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
@@ -398,8 +413,7 @@ describe("Metrics fuzz", () => {
|
||||
|
||||
describe("rapid emissions", () => {
|
||||
it("handles many rapid emissions", () => {
|
||||
const events: unknown[] = [];
|
||||
const metrics = createMetrics((e) => events.push(e));
|
||||
const { events, metrics } = createCollectingMetrics();
|
||||
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
metrics.emit("event.received");
|
||||
@@ -413,7 +427,7 @@ describe("Metrics fuzz", () => {
|
||||
|
||||
describe("reset during operation", () => {
|
||||
it("handles reset mid-operation safely", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.received");
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
|
||||
import { createSeenTracker } from "./seen-tracker.js";
|
||||
import { TEST_RELAY_URL } from "./test-fixtures.js";
|
||||
|
||||
const TEST_RELAY_URL_1 = "wss://relay1.com";
|
||||
const TEST_RELAY_URL_2 = "wss://relay2.com";
|
||||
const TEST_RELAY_URL_PRIMARY = "wss://relay.com";
|
||||
const TEST_RELAY_URL_GOOD = "wss://good-relay.com";
|
||||
const TEST_RELAY_URL_BAD = "wss://bad-relay.com";
|
||||
|
||||
function createTracker(overrides?: Partial<Parameters<typeof createSeenTracker>[0]>) {
|
||||
return createSeenTracker({
|
||||
maxEntries: 100,
|
||||
ttlMs: 60000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createCollectingMetrics() {
|
||||
const events: MetricEvent[] = [];
|
||||
return {
|
||||
events,
|
||||
metrics: createMetrics((event) => events.push(event)),
|
||||
};
|
||||
}
|
||||
|
||||
function createPlainMetrics() {
|
||||
return createMetrics();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seen Tracker Integration Tests
|
||||
@@ -9,7 +36,7 @@ import { createSeenTracker } from "./seen-tracker.js";
|
||||
describe("SeenTracker", () => {
|
||||
describe("basic operations", () => {
|
||||
it("tracks seen IDs", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||
const tracker = createTracker();
|
||||
|
||||
// First check returns false and adds
|
||||
expect(tracker.has("id1")).toBe(false);
|
||||
@@ -20,7 +47,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("peek does not add", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||
const tracker = createTracker();
|
||||
|
||||
expect(tracker.peek("id1")).toBe(false);
|
||||
expect(tracker.peek("id1")).toBe(false); // Still false
|
||||
@@ -32,7 +59,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("delete removes entries", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||
const tracker = createTracker();
|
||||
|
||||
tracker.add("id1");
|
||||
expect(tracker.peek("id1")).toBe(true);
|
||||
@@ -44,7 +71,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("clear removes all entries", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||
const tracker = createTracker();
|
||||
|
||||
tracker.add("id1");
|
||||
tracker.add("id2");
|
||||
@@ -59,7 +86,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("seed pre-populates entries", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||
const tracker = createTracker();
|
||||
|
||||
tracker.seed(["id1", "id2", "id3"]);
|
||||
expect(tracker.size()).toBe(3);
|
||||
@@ -73,7 +100,7 @@ describe("SeenTracker", () => {
|
||||
|
||||
describe("LRU eviction", () => {
|
||||
it("evicts least recently used when at capacity", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
|
||||
const tracker = createTracker({ maxEntries: 3 });
|
||||
|
||||
tracker.add("id1");
|
||||
tracker.add("id2");
|
||||
@@ -92,7 +119,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("accessing an entry moves it to front (prevents eviction)", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
|
||||
const tracker = createTracker({ maxEntries: 3 });
|
||||
|
||||
tracker.add("id1");
|
||||
tracker.add("id2");
|
||||
@@ -112,7 +139,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("handles capacity of 1", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 });
|
||||
const tracker = createTracker({ maxEntries: 1 });
|
||||
|
||||
tracker.add("id1");
|
||||
expect(tracker.peek("id1")).toBe(true);
|
||||
@@ -125,7 +152,7 @@ describe("SeenTracker", () => {
|
||||
});
|
||||
|
||||
it("seed respects maxEntries", () => {
|
||||
const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 });
|
||||
const tracker = createTracker({ maxEntries: 2 });
|
||||
|
||||
tracker.seed(["id1", "id2", "id3", "id4"]);
|
||||
expect(tracker.size()).toBe(2);
|
||||
@@ -142,7 +169,7 @@ describe("SeenTracker", () => {
|
||||
it("expires entries after TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const tracker = createSeenTracker({
|
||||
const tracker = createTracker({
|
||||
maxEntries: 100,
|
||||
ttlMs: 100,
|
||||
pruneIntervalMs: 50,
|
||||
@@ -164,7 +191,7 @@ describe("SeenTracker", () => {
|
||||
it("has() refreshes TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const tracker = createSeenTracker({
|
||||
const tracker = createTracker({
|
||||
maxEntries: 100,
|
||||
ttlMs: 100,
|
||||
pruneIntervalMs: 50,
|
||||
@@ -197,8 +224,7 @@ describe("SeenTracker", () => {
|
||||
describe("Metrics", () => {
|
||||
describe("createMetrics", () => {
|
||||
it("emits metric events to callback", () => {
|
||||
const events: MetricEvent[] = [];
|
||||
const metrics = createMetrics((event) => events.push(event));
|
||||
const { events, metrics } = createCollectingMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.processed");
|
||||
@@ -211,16 +237,15 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("includes labels in metric events", () => {
|
||||
const events: MetricEvent[] = [];
|
||||
const metrics = createMetrics((event) => events.push(event));
|
||||
const { events, metrics } = createCollectingMetrics();
|
||||
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL });
|
||||
|
||||
expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" });
|
||||
expect(events[0].labels).toEqual({ relay: TEST_RELAY_URL });
|
||||
});
|
||||
|
||||
it("accumulates counters in snapshot", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.received");
|
||||
@@ -236,39 +261,39 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("tracks per-relay stats", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" });
|
||||
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
|
||||
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_1 });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_2 });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays["wss://relay1.com"]).toBeDefined();
|
||||
expect(snapshot.relays["wss://relay1.com"].connects).toBe(1);
|
||||
expect(snapshot.relays["wss://relay1.com"].errors).toBe(2);
|
||||
expect(snapshot.relays["wss://relay2.com"].connects).toBe(1);
|
||||
expect(snapshot.relays["wss://relay2.com"].errors).toBe(0);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_1]).toBeDefined();
|
||||
expect(snapshot.relays[TEST_RELAY_URL_1].connects).toBe(1);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_1].errors).toBe(2);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_2].connects).toBe(1);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_2].errors).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks circuit breaker state changes", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
let snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open");
|
||||
expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("open");
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerOpens).toBe(1);
|
||||
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed");
|
||||
expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("closed");
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerCloses).toBe(1);
|
||||
});
|
||||
|
||||
it("tracks all rejection reasons", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.rejected.invalid_shape");
|
||||
metrics.emit("event.rejected.wrong_kind");
|
||||
@@ -295,17 +320,17 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("tracks relay message types", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.message.event", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.eose", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.closed", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.notice", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.ok", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.auth", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
const relay = snapshot.relays["wss://relay.com"];
|
||||
const relay = snapshot.relays[TEST_RELAY_URL_PRIMARY];
|
||||
expect(relay.messagesReceived.event).toBe(1);
|
||||
expect(relay.messagesReceived.eose).toBe(1);
|
||||
expect(relay.messagesReceived.closed).toBe(1);
|
||||
@@ -315,7 +340,7 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("tracks decrypt success/failure", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("decrypt.success");
|
||||
metrics.emit("decrypt.success");
|
||||
@@ -327,7 +352,7 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("tracks memory gauges (replaces rather than accumulates)", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("memory.seen_tracker_size", 100);
|
||||
metrics.emit("memory.seen_tracker_size", 150);
|
||||
@@ -338,11 +363,11 @@ describe("Metrics", () => {
|
||||
});
|
||||
|
||||
it("reset clears all counters", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.processed");
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
metrics.reset();
|
||||
|
||||
@@ -359,7 +384,7 @@ describe("Metrics", () => {
|
||||
|
||||
expect(() => {
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -380,18 +405,17 @@ describe("Metrics", () => {
|
||||
describe("Circuit Breaker Behavior", () => {
|
||||
// Test the circuit breaker logic through metrics emissions
|
||||
it("emits circuit breaker metrics in correct sequence", () => {
|
||||
const events: MetricEvent[] = [];
|
||||
const metrics = createMetrics((event) => events.push(event));
|
||||
const { events, metrics } = createCollectingMetrics();
|
||||
|
||||
// Simulate 5 failures -> open
|
||||
for (let i = 0; i < 5; i++) {
|
||||
metrics.emit("relay.error", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
}
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
// Simulate recovery
|
||||
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
|
||||
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
|
||||
expect(cbEvents).toHaveLength(3);
|
||||
@@ -407,19 +431,19 @@ describe("Circuit Breaker Behavior", () => {
|
||||
|
||||
describe("Health Scoring", () => {
|
||||
it("metrics track relay errors for health scoring", () => {
|
||||
const metrics = createMetrics();
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
// Simulate mixed success/failure pattern
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_GOOD });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
|
||||
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0);
|
||||
expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_GOOD].errors).toBe(0);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_BAD].errors).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,26 +6,23 @@ import {
|
||||
normalizePubkey,
|
||||
pubkeyToNpub,
|
||||
} from "./nostr-bus.js";
|
||||
|
||||
// Test private key (DO NOT use in production - this is a known test key)
|
||||
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||
import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js";
|
||||
|
||||
describe("validatePrivateKey", () => {
|
||||
describe("hex format", () => {
|
||||
it("accepts valid 64-char hex key", () => {
|
||||
const result = validatePrivateKey(TEST_HEX_KEY);
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBe(32);
|
||||
});
|
||||
|
||||
it("accepts lowercase hex", () => {
|
||||
const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase());
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toLowerCase());
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("accepts uppercase hex", () => {
|
||||
const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase());
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toUpperCase());
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
@@ -36,23 +33,23 @@ describe("validatePrivateKey", () => {
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
const result = validatePrivateKey(` ${TEST_HEX_KEY} `);
|
||||
const result = validatePrivateKey(` ${TEST_HEX_PRIVATE_KEY} `);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("trims newlines", () => {
|
||||
const result = validatePrivateKey(`${TEST_HEX_KEY}\n`);
|
||||
const result = validatePrivateKey(`${TEST_HEX_PRIVATE_KEY}\n`);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("rejects 63-char hex (too short)", () => {
|
||||
expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow(
|
||||
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY.slice(0, 63))).toThrow(
|
||||
"Private key must be 64 hex characters",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects 65-char hex (too long)", () => {
|
||||
expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow(
|
||||
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY + "0")).toThrow(
|
||||
"Private key must be 64 hex characters",
|
||||
);
|
||||
});
|
||||
@@ -71,7 +68,7 @@ describe("validatePrivateKey", () => {
|
||||
});
|
||||
|
||||
it("rejects key with 0x prefix", () => {
|
||||
expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow(
|
||||
expect(() => validatePrivateKey("0x" + TEST_HEX_PRIVATE_KEY)).toThrow(
|
||||
"Private key must be 64 hex characters",
|
||||
);
|
||||
});
|
||||
@@ -93,8 +90,7 @@ describe("validatePrivateKey", () => {
|
||||
describe("isValidPubkey", () => {
|
||||
describe("hex format", () => {
|
||||
it("accepts valid 64-char hex pubkey", () => {
|
||||
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(isValidPubkey(validHex)).toBe(true);
|
||||
expect(isValidPubkey(TEST_HEX_PRIVATE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts uppercase hex", () => {
|
||||
@@ -108,7 +104,7 @@ describe("isValidPubkey", () => {
|
||||
});
|
||||
|
||||
it("rejects 65-char hex", () => {
|
||||
const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0";
|
||||
const longHex = `${TEST_HEX_PRIVATE_KEY}0`;
|
||||
expect(isValidPubkey(longHex)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -134,8 +130,7 @@ describe("isValidPubkey", () => {
|
||||
});
|
||||
|
||||
it("handles whitespace-padded input", () => {
|
||||
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(isValidPubkey(` ${validHex} `)).toBe(true);
|
||||
expect(isValidPubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -149,8 +144,7 @@ describe("normalizePubkey", () => {
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(normalizePubkey(` ${hex} `)).toBe(hex);
|
||||
expect(normalizePubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
|
||||
it("rejects invalid hex", () => {
|
||||
@@ -161,14 +155,14 @@ describe("normalizePubkey", () => {
|
||||
|
||||
describe("getPublicKeyFromPrivate", () => {
|
||||
it("derives public key from hex private key", () => {
|
||||
const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||
const pubkey = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(pubkey.length).toBe(64);
|
||||
});
|
||||
|
||||
it("derives consistent public key", () => {
|
||||
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
expect(pubkey1).toBe(pubkey2);
|
||||
});
|
||||
|
||||
@@ -179,21 +173,18 @@ describe("getPublicKeyFromPrivate", () => {
|
||||
|
||||
describe("pubkeyToNpub", () => {
|
||||
it("converts hex pubkey to npub format", () => {
|
||||
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const npub = pubkeyToNpub(hex);
|
||||
const npub = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
expect(npub).toMatch(/^npub1[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it("produces consistent output", () => {
|
||||
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const npub1 = pubkeyToNpub(hex);
|
||||
const npub2 = pubkeyToNpub(hex);
|
||||
const npub1 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
const npub2 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
expect(npub1).toBe(npub2);
|
||||
});
|
||||
|
||||
it("normalizes uppercase hex first", () => {
|
||||
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const upper = lower.toUpperCase();
|
||||
expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper));
|
||||
const upper = TEST_HEX_PRIVATE_KEY.toUpperCase();
|
||||
expect(pubkeyToNpub(TEST_HEX_PRIVATE_KEY)).toBe(pubkeyToNpub(upper));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,11 +27,14 @@ vi.mock("./nostr-profile-import.js", () => ({
|
||||
|
||||
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||
import { importProfileFromRelays } from "./nostr-profile-import.js";
|
||||
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
|
||||
|
||||
function createMockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
@@ -98,13 +101,15 @@ function createMockResponse(): ServerResponse & {
|
||||
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
|
||||
}
|
||||
|
||||
type MockResponse = ReturnType<typeof createMockResponse>;
|
||||
|
||||
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
||||
return {
|
||||
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
||||
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
||||
getAccountInfo: vi.fn().mockReturnValue({
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
relays: ["wss://relay.damus.io"],
|
||||
pubkey: TEST_HEX_PUBLIC_KEY,
|
||||
relays: [TEST_PROFILE_RELAY_URL],
|
||||
}),
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
@@ -115,7 +120,29 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
|
||||
};
|
||||
}
|
||||
|
||||
function expectOkResponse(res: ReturnType<typeof createMockResponse>) {
|
||||
function createProfileHttpHarness(
|
||||
method: string,
|
||||
url: string,
|
||||
options?: {
|
||||
body?: unknown;
|
||||
ctx?: Partial<NostrProfileHttpContext>;
|
||||
req?: Parameters<typeof createMockRequest>[3];
|
||||
},
|
||||
) {
|
||||
const ctx = createMockContext(options?.ctx);
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(method, url, options?.body, options?.req);
|
||||
const res = createMockResponse();
|
||||
|
||||
return {
|
||||
ctx,
|
||||
req,
|
||||
res,
|
||||
run: () => handler(req, res),
|
||||
};
|
||||
}
|
||||
|
||||
function expectOkResponse(res: MockResponse) {
|
||||
expect(res._getStatusCode()).toBe(200);
|
||||
const data = JSON.parse(res._getData());
|
||||
expect(data.ok).toBe(true);
|
||||
@@ -131,11 +158,11 @@ function mockSuccessfulProfileImport() {
|
||||
},
|
||||
event: {
|
||||
id: "evt123",
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
pubkey: TEST_HEX_PUBLIC_KEY,
|
||||
created_at: 1234567890,
|
||||
},
|
||||
relaysQueried: ["wss://relay.damus.io"],
|
||||
sourceRelay: "wss://relay.damus.io",
|
||||
relaysQueried: [TEST_PROFILE_RELAY_URL],
|
||||
sourceRelay: TEST_PROFILE_RELAY_URL,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,36 +178,25 @@ describe("nostr-profile-http", () => {
|
||||
|
||||
describe("route matching", () => {
|
||||
it("returns false for non-nostr paths", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("GET", "/api/channels/telegram/profile");
|
||||
const res = createMockResponse();
|
||||
|
||||
const result = await handler(req, res);
|
||||
const { res, run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for paths without accountId", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("GET", "/api/channels/nostr/");
|
||||
const res = createMockResponse();
|
||||
|
||||
const result = await handler(req, res);
|
||||
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
||||
const res = createMockResponse();
|
||||
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
|
||||
|
||||
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
||||
|
||||
const result = await handler(req, res);
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -188,23 +204,22 @@ describe("nostr-profile-http", () => {
|
||||
|
||||
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
||||
it("returns profile and publish state", async () => {
|
||||
const ctx = createMockContext({
|
||||
getConfigProfile: vi.fn().mockReturnValue({
|
||||
name: "testuser",
|
||||
displayName: "Test User",
|
||||
}),
|
||||
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
|
||||
ctx: {
|
||||
getConfigProfile: vi.fn().mockReturnValue({
|
||||
name: "testuser",
|
||||
displayName: "Test User",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
||||
const res = createMockResponse();
|
||||
|
||||
vi.mocked(getNostrProfileState).mockResolvedValue({
|
||||
lastPublishedAt: 1234567890,
|
||||
lastPublishedEventId: "abc123",
|
||||
lastPublishResults: { "wss://relay.damus.io": "ok" },
|
||||
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
expect(res._getStatusCode()).toBe(200);
|
||||
const data = JSON.parse(res._getData());
|
||||
@@ -219,7 +234,7 @@ describe("nostr-profile-http", () => {
|
||||
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||
eventId: "event123",
|
||||
createdAt: 1234567890,
|
||||
successes: ["wss://relay.damus.io"],
|
||||
successes: [TEST_PROFILE_RELAY_URL],
|
||||
failures: [],
|
||||
});
|
||||
}
|
||||
@@ -232,98 +247,80 @@ describe("nostr-profile-http", () => {
|
||||
}
|
||||
|
||||
async function expectPrivatePictureRejected(pictureUrl: string) {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "hacker",
|
||||
picture: pictureUrl,
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: {
|
||||
name: "hacker",
|
||||
picture: pictureUrl,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
const data = expectBadRequestResponse(res);
|
||||
expect(data.error).toContain("private");
|
||||
}
|
||||
|
||||
it("validates profile and publishes", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "satoshi",
|
||||
displayName: "Satoshi Nakamoto",
|
||||
about: "Creator of Bitcoin",
|
||||
});
|
||||
const res = createMockResponse();
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{
|
||||
body: {
|
||||
name: "satoshi",
|
||||
displayName: "Satoshi Nakamoto",
|
||||
about: "Creator of Bitcoin",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockPublishSuccess();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
const data = expectOkResponse(res);
|
||||
expect(data.eventId).toBe("event123");
|
||||
expect(data.successes).toContain("wss://relay.damus.io");
|
||||
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
|
||||
expect(data.persisted).toBe(true);
|
||||
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects profile mutation from non-loopback remote address", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ remoteAddress: "198.51.100.10" },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { remoteAddress: "198.51.100.10" },
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects cross-origin profile mutation attempts", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ headers: { origin: "https://evil.example" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { origin: "https://evil.example" } },
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ headers: { "sec-fetch-site": "cross-site" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { "sec-fetch-site": "cross-site" } },
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
@@ -336,15 +333,14 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
it("rejects non-https URLs", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "test",
|
||||
picture: "http://example.com/pic.jpg",
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: {
|
||||
name: "test",
|
||||
picture: "http://example.com/pic.jpg",
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
const data = expectBadRequestResponse(res);
|
||||
// The schema validation catches non-https URLs before SSRF check
|
||||
@@ -354,21 +350,24 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
it("does not persist if all relays fail", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||
name: "test",
|
||||
});
|
||||
const res = createMockResponse();
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{
|
||||
body: {
|
||||
name: "test",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||
eventId: "event123",
|
||||
createdAt: 1234567890,
|
||||
successes: [],
|
||||
failures: [{ relay: "wss://relay.damus.io", error: "timeout" }],
|
||||
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
expect(res._getStatusCode()).toBe(200);
|
||||
const data = JSON.parse(res._getData());
|
||||
@@ -377,18 +376,20 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
it("enforces rate limiting", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
|
||||
mockPublishSuccess();
|
||||
|
||||
// Make 6 requests (limit is 5/min)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
|
||||
name: `user${i}`,
|
||||
});
|
||||
const res = createMockResponse();
|
||||
await handler(req, res);
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/rate-test/profile",
|
||||
{
|
||||
body: {
|
||||
name: `user${i}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
await run();
|
||||
|
||||
if (i < 5) {
|
||||
expectOkResponse(res);
|
||||
@@ -428,77 +429,77 @@ describe("nostr-profile-http", () => {
|
||||
}
|
||||
|
||||
it("imports profile from relays", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{ body: {} },
|
||||
);
|
||||
|
||||
mockSuccessfulProfileImport();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
const data = expectImportSuccessResponse(res);
|
||||
expect(data.saved).toBe(false); // autoMerge not requested
|
||||
});
|
||||
|
||||
it("rejects import mutation from non-loopback remote address", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{},
|
||||
{ remoteAddress: "203.0.113.10" },
|
||||
{
|
||||
body: {},
|
||||
req: { remoteAddress: "203.0.113.10" },
|
||||
},
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects cross-origin import mutation attempts", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{},
|
||||
{ headers: { origin: "https://evil.example" } },
|
||||
{
|
||||
body: {},
|
||||
req: { headers: { origin: "https://evil.example" } },
|
||||
},
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{},
|
||||
{ headers: { "x-real-ip": "198.51.100.55" } },
|
||||
{
|
||||
body: {},
|
||||
req: { headers: { "x-real-ip": "198.51.100.55" } },
|
||||
},
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("auto-merges when requested", async () => {
|
||||
const ctx = createMockContext({
|
||||
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||
});
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
|
||||
autoMerge: true,
|
||||
});
|
||||
const res = createMockResponse();
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{
|
||||
body: { autoMerge: true },
|
||||
ctx: {
|
||||
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockSuccessfulProfileImport();
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
const data = expectImportSuccessResponse(res);
|
||||
expect(data.saved).toBe(true);
|
||||
@@ -506,14 +507,18 @@ describe("nostr-profile-http", () => {
|
||||
});
|
||||
|
||||
it("returns error when account not found", async () => {
|
||||
const ctx = createMockContext({
|
||||
getAccountInfo: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
|
||||
const res = createMockResponse();
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/unknown/profile/import",
|
||||
{
|
||||
body: {},
|
||||
ctx: {
|
||||
getAccountInfo: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await handler(req, res);
|
||||
await run();
|
||||
|
||||
expect(res._getStatusCode()).toBe(404);
|
||||
const data = JSON.parse(res._getData());
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
validateProfile,
|
||||
sanitizeProfileForDisplay,
|
||||
} from "./nostr-profile.js";
|
||||
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
|
||||
|
||||
// Test private key
|
||||
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
||||
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
|
||||
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unicode Attack Vectors
|
||||
@@ -444,7 +445,7 @@ describe("event creation edge cases", () => {
|
||||
lud16: "e".repeat(200) + "@example.com",
|
||||
};
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
expect(event.kind).toBe(0);
|
||||
|
||||
// Content should be parseable JSON
|
||||
@@ -457,7 +458,7 @@ describe("event creation edge cases", () => {
|
||||
// Create events in quick succession
|
||||
let lastTimestamp = 0;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
|
||||
const event = createTestProfileEvent(profile, lastTimestamp);
|
||||
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
||||
lastTimestamp = event.created_at;
|
||||
}
|
||||
@@ -469,7 +470,7 @@ describe("event creation edge cases", () => {
|
||||
about: "line1\nline2\ttab\\backslash",
|
||||
};
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
const parsed = JSON.parse(event.content) as { name: string; about: string };
|
||||
|
||||
expect(parsed.name).toBe('test"user');
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
sanitizeProfileForDisplay,
|
||||
type ProfileContent,
|
||||
} from "./nostr-profile.js";
|
||||
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
|
||||
|
||||
// Test private key (DO NOT use in production - this is a known test key)
|
||||
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
||||
const TEST_PUBKEY = getPublicKey(TEST_SK);
|
||||
const TEST_PUBKEY = getPublicKey(TEST_HEX_PRIVATE_KEY_BYTES);
|
||||
|
||||
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
|
||||
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Content Conversion Tests
|
||||
@@ -123,7 +125,7 @@ describe("createProfileEvent", () => {
|
||||
about: "A test bot",
|
||||
};
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
|
||||
expect(event.kind).toBe(0);
|
||||
expect(event.pubkey).toBe(TEST_PUBKEY);
|
||||
@@ -139,7 +141,7 @@ describe("createProfileEvent", () => {
|
||||
about: "Testing JSON serialization",
|
||||
};
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
const parsedContent = JSON.parse(event.content) as ProfileContent;
|
||||
|
||||
expect(parsedContent.name).toBe("jsontest");
|
||||
@@ -149,14 +151,14 @@ describe("createProfileEvent", () => {
|
||||
|
||||
it("produces a verifiable signature", () => {
|
||||
const profile: NostrProfile = { name: "signaturetest" };
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses current timestamp when no lastPublishedAt provided", () => {
|
||||
const profile: NostrProfile = { name: "timestamptest" };
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
|
||||
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
||||
expect(event.created_at).toBe(expectedTimestamp);
|
||||
@@ -167,7 +169,7 @@ describe("createProfileEvent", () => {
|
||||
const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
|
||||
const profile: NostrProfile = { name: "monotonictest" };
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
|
||||
const event = createTestProfileEvent(profile, futureTimestamp);
|
||||
|
||||
expect(event.created_at).toBe(futureTimestamp + 1);
|
||||
});
|
||||
@@ -176,7 +178,7 @@ describe("createProfileEvent", () => {
|
||||
const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
|
||||
const profile: NostrProfile = { name: "pasttest" };
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
|
||||
const event = createTestProfileEvent(profile, pastTimestamp);
|
||||
|
||||
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
||||
expect(event.created_at).toBe(expectedTimestamp);
|
||||
@@ -364,7 +366,7 @@ describe("edge cases", () => {
|
||||
expect(content.name).toBe("🤖 Bot");
|
||||
expect(content.about).toBe("I am a 🤖 robot! 🎉");
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
const parsed = JSON.parse(event.content) as ProfileContent;
|
||||
expect(parsed.name).toBe("🤖 Bot");
|
||||
});
|
||||
@@ -378,7 +380,7 @@ describe("edge cases", () => {
|
||||
const content = profileToContent(profile);
|
||||
expect(content.name).toBe("日本語ユーザー");
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -390,7 +392,7 @@ describe("edge cases", () => {
|
||||
const content = profileToContent(profile);
|
||||
expect(content.about).toBe("Line 1\nLine 2\nLine 3");
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
const parsed = JSON.parse(event.content) as ProfileContent;
|
||||
expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
|
||||
});
|
||||
@@ -404,7 +406,7 @@ describe("edge cases", () => {
|
||||
const result = validateProfile(profile);
|
||||
expect(result.valid).toBe(true);
|
||||
|
||||
const event = createProfileEvent(TEST_SK, profile);
|
||||
const event = createTestProfileEvent(profile);
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { nostrPlugin } from "./channel.js";
|
||||
import { TEST_HEX_PRIVATE_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
||||
|
||||
const nostrConfigure = createPluginSetupWizardConfigure(nostrPlugin);
|
||||
|
||||
@@ -15,10 +16,10 @@ describe("nostr setup wizard", () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Nostr private key (nsec... or hex)") {
|
||||
return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
return TEST_HEX_PRIVATE_KEY;
|
||||
}
|
||||
if (message === "Relay URLs (comma-separated, optional)") {
|
||||
return "wss://relay.damus.io, wss://relay.primal.net";
|
||||
return TEST_SETUP_RELAY_URLS.join(", ");
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
@@ -33,12 +34,7 @@ describe("nostr setup wizard", () => {
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.nostr?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.nostr?.privateKey).toBe(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
expect(result.cfg.channels?.nostr?.relays).toEqual([
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
]);
|
||||
expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS);
|
||||
});
|
||||
});
|
||||
|
||||
46
extensions/nostr/src/test-fixtures.ts
Normal file
46
extensions/nostr/src/test-fixtures.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { ResolvedNostrAccount } from "./types.js";
|
||||
|
||||
export const TEST_HEX_PRIVATE_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
export const TEST_HEX_PUBLIC_KEY =
|
||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
|
||||
export const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||
|
||||
export const TEST_RELAY_URL = "wss://relay.example.com";
|
||||
export const TEST_SETUP_RELAY_URLS = ["wss://relay.damus.io", "wss://relay.primal.net"];
|
||||
export const TEST_RESOLVED_PRIVATE_KEY = "resolved-nostr-private-key";
|
||||
|
||||
export const TEST_HEX_PRIVATE_KEY_BYTES = new Uint8Array(
|
||||
TEST_HEX_PRIVATE_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)),
|
||||
);
|
||||
|
||||
export function createConfiguredNostrCfg(overrides: Record<string, unknown> = {}): {
|
||||
channels: { nostr: Record<string, unknown> };
|
||||
} {
|
||||
return {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildResolvedNostrAccount(
|
||||
overrides: Partial<ResolvedNostrAccount> = {},
|
||||
): ResolvedNostrAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
publicKey: TEST_HEX_PUBLIC_KEY,
|
||||
relays: [TEST_RELAY_URL],
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TEST_HEX_PRIVATE_KEY, createConfiguredNostrCfg } from "./test-fixtures.js";
|
||||
import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
|
||||
|
||||
const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
describe("listNostrAccountIds", () => {
|
||||
it("returns empty array when not configured", () => {
|
||||
const cfg = { channels: {} };
|
||||
@@ -15,31 +14,19 @@ describe("listNostrAccountIds", () => {
|
||||
});
|
||||
|
||||
it("returns default when privateKey is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("returns configured defaultAccount when privateKey is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
|
||||
expect(listNostrAccountIds(cfg)).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultNostrAccountId", () => {
|
||||
it("returns default when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
@@ -49,34 +36,25 @@ describe("resolveDefaultNostrAccountId", () => {
|
||||
});
|
||||
|
||||
it("prefers configured defaultAccount when present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
|
||||
expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNostrAccount", () => {
|
||||
it("resolves configured account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
name: "Test Bot",
|
||||
relays: ["wss://test.relay"],
|
||||
dmPolicy: "pairing" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
name: "Test Bot",
|
||||
relays: ["wss://test.relay"],
|
||||
dmPolicy: "pairing" as const,
|
||||
});
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.name).toBe("Test Bot");
|
||||
expect(account.enabled).toBe(true);
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.privateKey).toBe(TEST_PRIVATE_KEY);
|
||||
expect(account.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(account.relays).toEqual(["wss://test.relay"]);
|
||||
});
|
||||
@@ -95,14 +73,7 @@ describe("resolveNostrAccount", () => {
|
||||
});
|
||||
|
||||
it("handles disabled channel", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
enabled: false,
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({ enabled: false });
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.enabled).toBe(false);
|
||||
@@ -110,25 +81,16 @@ describe("resolveNostrAccount", () => {
|
||||
});
|
||||
|
||||
it("handles custom accountId parameter", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
const account = resolveNostrAccount({ cfg, accountId: "custom" });
|
||||
|
||||
expect(account.accountId).toBe("custom");
|
||||
});
|
||||
|
||||
it("handles allowFrom config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
allowFrom: ["npub1test", "0123456789abcdef"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
allowFrom: ["npub1test", "0123456789abcdef"],
|
||||
});
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]);
|
||||
@@ -149,22 +111,17 @@ describe("resolveNostrAccount", () => {
|
||||
});
|
||||
|
||||
it("preserves all config options", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
name: "Bot",
|
||||
enabled: true,
|
||||
relays: ["wss://relay1", "wss://relay2"],
|
||||
dmPolicy: "allowlist" as const,
|
||||
allowFrom: ["pubkey1", "pubkey2"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
name: "Bot",
|
||||
enabled: true,
|
||||
relays: ["wss://relay1", "wss://relay2"],
|
||||
dmPolicy: "allowlist" as const,
|
||||
allowFrom: ["pubkey1", "pubkey2"],
|
||||
});
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.config).toEqual({
|
||||
privateKey: TEST_PRIVATE_KEY,
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
name: "Bot",
|
||||
enabled: true,
|
||||
relays: ["wss://relay1", "wss://relay2"],
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedTelegramAccount } from "./accounts.js";
|
||||
import * as auditModule from "./audit.js";
|
||||
import { telegramPlugin } from "./channel.js";
|
||||
@@ -58,31 +55,8 @@ function createCfg(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedTelegramAccount> {
|
||||
const account = telegramPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
params.accountId,
|
||||
) as ResolvedTelegramAccount;
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
abortSignal: new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
function resolveAccount(cfg: OpenClawConfig, accountId: string): ResolvedTelegramAccount {
|
||||
return telegramPlugin.config.resolveAccount(cfg, accountId) as ResolvedTelegramAccount;
|
||||
}
|
||||
|
||||
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
|
||||
@@ -225,12 +199,13 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
});
|
||||
const cfg = createCfg();
|
||||
|
||||
await expect(
|
||||
telegramPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
cfg: createCfg(),
|
||||
accountId: "work",
|
||||
createStartAccountContext({
|
||||
account: resolveAccount(cfg, "work"),
|
||||
cfg,
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
),
|
||||
@@ -263,9 +238,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
};
|
||||
|
||||
await telegramPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
createStartAccountContext({
|
||||
account: resolveAccount(cfg, "ops"),
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
);
|
||||
@@ -521,9 +496,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
||||
|
||||
const cfg = createCfg();
|
||||
const ctx = createStartAccountCtx({
|
||||
const ctx = createStartAccountContext({
|
||||
account: resolveAccount(cfg, "ops"),
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
ctx.account = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ChannelAccountSnapshot } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -66,21 +67,13 @@ describe("zaloPlugin gateway.startAccount", () => {
|
||||
});
|
||||
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: () =>
|
||||
vi.waitFor(() => {
|
||||
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
|
||||
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
|
||||
}),
|
||||
waitForStarted: waitForStartedMocks(hoisted.probeZalo, hoisted.monitorZaloProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
});
|
||||
|
||||
expect(patches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
expectLifecyclePatch(patches, { accountId: "default" });
|
||||
expect(isSettled()).toBe(true);
|
||||
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -2,13 +2,16 @@ import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
OpenClawConfig,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { vi } from "vitest";
|
||||
import { createRuntimeEnv } from "./runtime-env.js";
|
||||
|
||||
export function createStartAccountContext<TAccount extends { accountId: string }>(params: {
|
||||
account: TAccount;
|
||||
abortSignal: AbortSignal;
|
||||
abortSignal?: AbortSignal;
|
||||
cfg?: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
statusPatchSink?: (next: ChannelAccountSnapshot) => void;
|
||||
}): ChannelGatewayContext<TAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
@@ -20,9 +23,9 @@ export function createStartAccountContext<TAccount extends { accountId: string }
|
||||
return {
|
||||
accountId: params.account.accountId,
|
||||
account: params.account,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
abortSignal: params.abortSignal,
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
runtime: params.runtime ?? createRuntimeEnv(),
|
||||
abortSignal: params.abortSignal ?? new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: (next) => {
|
||||
|
||||
@@ -35,6 +35,23 @@ export async function abortStartedAccount(params: {
|
||||
await params.task;
|
||||
}
|
||||
|
||||
export function waitForStartedMocks(...mocks: Array<ReturnType<typeof vi.fn>>) {
|
||||
return async () => {
|
||||
await vi.waitFor(() => {
|
||||
for (const mock of mocks) {
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expectLifecyclePatch(
|
||||
patches: ChannelAccountSnapshot[],
|
||||
expected: Partial<ChannelAccountSnapshot>,
|
||||
) {
|
||||
expect(patches).toContainEqual(expect.objectContaining(expected));
|
||||
}
|
||||
|
||||
export async function expectPendingUntilAbort(params: {
|
||||
waitForStarted: () => Promise<void>;
|
||||
isSettled: () => boolean;
|
||||
|
||||
Reference in New Issue
Block a user