@openclaw/nostr 2026.5.2 → 2026.5.3-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +532 -0
- package/dist/channel-DfEqBtUh.js +1466 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/config-schema-DIk4jlBg.js +64 -0
- package/dist/default-relays-DLwdWOTu.js +4 -0
- package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
- package/dist/index.js +84 -0
- package/dist/runtime-api.js +2 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +165 -0
- package/dist/setup-surface-DxAaUTyC.js +336 -0
- package/dist/test-api.js +2 -0
- package/package.json +15 -6
- package/api.ts +0 -10
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -97
- package/runtime-api.ts +0 -6
- package/setup-api.ts +0 -1
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/channel-api.ts +0 -15
- package/src/channel.inbound.test.ts +0 -176
- package/src/channel.outbound.test.ts +0 -128
- package/src/channel.setup.ts +0 -231
- package/src/channel.test.ts +0 -519
- package/src/channel.ts +0 -207
- package/src/config-schema.ts +0 -98
- package/src/default-relays.ts +0 -1
- package/src/gateway.ts +0 -302
- package/src/inbound-direct-dm-runtime.ts +0 -1
- package/src/metrics.ts +0 -458
- package/src/nostr-bus.fuzz.test.ts +0 -360
- package/src/nostr-bus.inbound.test.ts +0 -526
- package/src/nostr-bus.integration.test.ts +0 -472
- package/src/nostr-bus.test.ts +0 -190
- package/src/nostr-bus.ts +0 -789
- package/src/nostr-key-utils.ts +0 -94
- package/src/nostr-profile-core.ts +0 -134
- package/src/nostr-profile-http-runtime.ts +0 -6
- package/src/nostr-profile-http.test.ts +0 -632
- package/src/nostr-profile-http.ts +0 -594
- package/src/nostr-profile-import.test.ts +0 -119
- package/src/nostr-profile-import.ts +0 -262
- package/src/nostr-profile-url-safety.ts +0 -21
- package/src/nostr-profile.fuzz.test.ts +0 -430
- package/src/nostr-profile.test.ts +0 -412
- package/src/nostr-profile.ts +0 -144
- package/src/nostr-state-store.test.ts +0 -237
- package/src/nostr-state-store.ts +0 -223
- package/src/runtime.ts +0 -9
- package/src/seen-tracker.ts +0 -289
- package/src/session-route.ts +0 -25
- package/src/setup-surface.ts +0 -265
- package/src/test-fixtures.ts +0 -45
- package/src/types.ts +0 -117
- package/test/setup.ts +0 -5
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Nostr Profile HTTP Handler
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
-
import { Socket } from "node:net";
|
|
7
|
-
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
import {
|
|
9
|
-
clearNostrProfileRateLimitStateForTest,
|
|
10
|
-
createNostrProfileHttpHandler,
|
|
11
|
-
getNostrProfileRateLimitStateSizeForTest,
|
|
12
|
-
isNostrProfileRateLimitedForTest,
|
|
13
|
-
type NostrProfileHttpContext,
|
|
14
|
-
} from "./nostr-profile-http.js";
|
|
15
|
-
|
|
16
|
-
const runtimeScopeMock = vi.hoisted(() => vi.fn());
|
|
17
|
-
|
|
18
|
-
vi.mock("./nostr-profile-http-runtime.js", async () => {
|
|
19
|
-
const webhookIngress = await import("openclaw/plugin-sdk/webhook-ingress");
|
|
20
|
-
const requestGuards = await import("openclaw/plugin-sdk/webhook-request-guards");
|
|
21
|
-
return {
|
|
22
|
-
createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter,
|
|
23
|
-
readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit,
|
|
24
|
-
requestBodyErrorToText: requestGuards.requestBodyErrorToText,
|
|
25
|
-
getPluginRuntimeGatewayRequestScope: runtimeScopeMock,
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Mock the channel exports
|
|
30
|
-
vi.mock("./channel.js", () => ({
|
|
31
|
-
publishNostrProfile: vi.fn(),
|
|
32
|
-
getNostrProfileState: vi.fn(),
|
|
33
|
-
}));
|
|
34
|
-
|
|
35
|
-
// Mock the import module
|
|
36
|
-
vi.mock("./nostr-profile-import.js", () => ({
|
|
37
|
-
importProfileFromRelays: vi.fn(),
|
|
38
|
-
mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
42
|
-
import { importProfileFromRelays } from "./nostr-profile-import.js";
|
|
43
|
-
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
|
44
|
-
|
|
45
|
-
// ============================================================================
|
|
46
|
-
// Test Helpers
|
|
47
|
-
// ============================================================================
|
|
48
|
-
|
|
49
|
-
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
|
|
50
|
-
|
|
51
|
-
afterAll(() => {
|
|
52
|
-
runtimeScopeMock.mockReset();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
|
|
56
|
-
if (!scopes) {
|
|
57
|
-
runtimeScopeMock.mockReturnValue(undefined);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
runtimeScopeMock.mockReturnValue({
|
|
61
|
-
client: {
|
|
62
|
-
connect: {
|
|
63
|
-
scopes: [...scopes],
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function responseChunkText(chunk: unknown): string {
|
|
70
|
-
if (typeof chunk === "string") {
|
|
71
|
-
return chunk;
|
|
72
|
-
}
|
|
73
|
-
if (Buffer.isBuffer(chunk)) {
|
|
74
|
-
return chunk.toString();
|
|
75
|
-
}
|
|
76
|
-
return "";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function createMockRequest(
|
|
80
|
-
method: string,
|
|
81
|
-
url: string,
|
|
82
|
-
body?: unknown,
|
|
83
|
-
opts?: { headers?: Record<string, string>; remoteAddress?: string },
|
|
84
|
-
): IncomingMessage {
|
|
85
|
-
const socket = new Socket();
|
|
86
|
-
Object.defineProperty(socket, "remoteAddress", {
|
|
87
|
-
value: opts?.remoteAddress ?? "127.0.0.1",
|
|
88
|
-
configurable: true,
|
|
89
|
-
});
|
|
90
|
-
const req = new IncomingMessage(socket);
|
|
91
|
-
req.method = method;
|
|
92
|
-
req.url = url;
|
|
93
|
-
req.headers = { host: "localhost:3000", ...opts?.headers };
|
|
94
|
-
|
|
95
|
-
if (body) {
|
|
96
|
-
const bodyStr = JSON.stringify(body);
|
|
97
|
-
process.nextTick(() => {
|
|
98
|
-
req.emit("data", Buffer.from(bodyStr));
|
|
99
|
-
req.emit("end");
|
|
100
|
-
});
|
|
101
|
-
} else {
|
|
102
|
-
process.nextTick(() => {
|
|
103
|
-
req.emit("end");
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return req;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
type MockResponse = {
|
|
111
|
-
_getData: () => string;
|
|
112
|
-
_getStatusCode: () => number;
|
|
113
|
-
write: (chunk: unknown) => boolean;
|
|
114
|
-
end: (chunk?: unknown) => MockResponse;
|
|
115
|
-
statusCode: number;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
function createMockResponse(): MockResponse {
|
|
119
|
-
let data = "";
|
|
120
|
-
let statusCode = 200;
|
|
121
|
-
const res = Object.assign(new ServerResponse({} as IncomingMessage), {
|
|
122
|
-
_getData: () => data,
|
|
123
|
-
_getStatusCode: () => statusCode,
|
|
124
|
-
}) as MockResponse;
|
|
125
|
-
|
|
126
|
-
res.write = function (chunk: unknown) {
|
|
127
|
-
data += responseChunkText(chunk);
|
|
128
|
-
return true;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
res.end = function (chunk?: unknown) {
|
|
132
|
-
if (chunk) {
|
|
133
|
-
data += responseChunkText(chunk);
|
|
134
|
-
}
|
|
135
|
-
return this;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
Object.defineProperty(res, "statusCode", {
|
|
139
|
-
get: () => statusCode,
|
|
140
|
-
set: (code: number) => {
|
|
141
|
-
statusCode = code;
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
return res;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
|
149
|
-
return {
|
|
150
|
-
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
|
151
|
-
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
|
152
|
-
getAccountInfo: vi.fn().mockReturnValue({
|
|
153
|
-
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
154
|
-
relays: [TEST_PROFILE_RELAY_URL],
|
|
155
|
-
}),
|
|
156
|
-
log: {
|
|
157
|
-
info: vi.fn(),
|
|
158
|
-
warn: vi.fn(),
|
|
159
|
-
error: vi.fn(),
|
|
160
|
-
},
|
|
161
|
-
...overrides,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function createProfileHttpHarness(
|
|
166
|
-
method: string,
|
|
167
|
-
url: string,
|
|
168
|
-
options?: {
|
|
169
|
-
body?: unknown;
|
|
170
|
-
ctx?: Partial<NostrProfileHttpContext>;
|
|
171
|
-
req?: Parameters<typeof createMockRequest>[3];
|
|
172
|
-
},
|
|
173
|
-
) {
|
|
174
|
-
const ctx = createMockContext(options?.ctx);
|
|
175
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
176
|
-
const req = createMockRequest(method, url, options?.body, options?.req);
|
|
177
|
-
const res = createMockResponse();
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
ctx,
|
|
181
|
-
req,
|
|
182
|
-
res,
|
|
183
|
-
run: () => handler(req, res as unknown as ServerResponse),
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function expectOkResponse(res: MockResponse) {
|
|
188
|
-
expect(res._getStatusCode()).toBe(200);
|
|
189
|
-
const data = JSON.parse(res._getData());
|
|
190
|
-
expect(data.ok).toBe(true);
|
|
191
|
-
return data;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function mockSuccessfulProfileImport() {
|
|
195
|
-
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
|
196
|
-
ok: true,
|
|
197
|
-
profile: {
|
|
198
|
-
name: "imported",
|
|
199
|
-
displayName: "Imported User",
|
|
200
|
-
},
|
|
201
|
-
event: {
|
|
202
|
-
id: "evt123",
|
|
203
|
-
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
204
|
-
created_at: 1234567890,
|
|
205
|
-
},
|
|
206
|
-
relaysQueried: [TEST_PROFILE_RELAY_URL],
|
|
207
|
-
sourceRelay: TEST_PROFILE_RELAY_URL,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function expectAdminScopeRejected(params: {
|
|
212
|
-
scopes: readonly string[] | undefined;
|
|
213
|
-
method: string;
|
|
214
|
-
url: string;
|
|
215
|
-
body: unknown;
|
|
216
|
-
expectOperationNotCalled: () => void;
|
|
217
|
-
}) {
|
|
218
|
-
setGatewayRuntimeScopes(params.scopes);
|
|
219
|
-
const { ctx, res, run } = createProfileHttpHarness(params.method, params.url, {
|
|
220
|
-
body: params.body,
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
await run();
|
|
224
|
-
|
|
225
|
-
expect(res._getStatusCode()).toBe(403);
|
|
226
|
-
const data = JSON.parse(res._getData());
|
|
227
|
-
expect(data.error).toBe("missing scope: operator.admin");
|
|
228
|
-
params.expectOperationNotCalled();
|
|
229
|
-
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ============================================================================
|
|
233
|
-
// Tests
|
|
234
|
-
// ============================================================================
|
|
235
|
-
|
|
236
|
-
describe("nostr-profile-http", () => {
|
|
237
|
-
beforeEach(() => {
|
|
238
|
-
vi.clearAllMocks();
|
|
239
|
-
clearNostrProfileRateLimitStateForTest();
|
|
240
|
-
setGatewayRuntimeScopes(["operator.admin"]);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("route matching", () => {
|
|
244
|
-
it("returns false for non-nostr paths", async () => {
|
|
245
|
-
const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
|
|
246
|
-
const result = await run();
|
|
247
|
-
|
|
248
|
-
expect(result).toBe(false);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("returns false for paths without accountId", async () => {
|
|
252
|
-
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
|
|
253
|
-
const result = await run();
|
|
254
|
-
|
|
255
|
-
expect(result).toBe(false);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
|
259
|
-
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
|
|
260
|
-
|
|
261
|
-
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
|
262
|
-
|
|
263
|
-
const result = await run();
|
|
264
|
-
|
|
265
|
-
expect(result).toBe(true);
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
|
270
|
-
it("returns profile and publish state", async () => {
|
|
271
|
-
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
|
|
272
|
-
ctx: {
|
|
273
|
-
getConfigProfile: vi.fn().mockReturnValue({
|
|
274
|
-
name: "testuser",
|
|
275
|
-
displayName: "Test User",
|
|
276
|
-
}),
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
vi.mocked(getNostrProfileState).mockResolvedValue({
|
|
281
|
-
lastPublishedAt: 1234567890,
|
|
282
|
-
lastPublishedEventId: "abc123",
|
|
283
|
-
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
await run();
|
|
287
|
-
|
|
288
|
-
expect(res._getStatusCode()).toBe(200);
|
|
289
|
-
const data = JSON.parse(res._getData());
|
|
290
|
-
expect(data.ok).toBe(true);
|
|
291
|
-
expect(data.profile.name).toBe("testuser");
|
|
292
|
-
expect(data.publishState.lastPublishedAt).toBe(1234567890);
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
describe("PUT /api/channels/nostr/:accountId/profile", () => {
|
|
297
|
-
function mockPublishSuccess() {
|
|
298
|
-
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
299
|
-
eventId: "event123",
|
|
300
|
-
createdAt: 1234567890,
|
|
301
|
-
successes: [TEST_PROFILE_RELAY_URL],
|
|
302
|
-
failures: [],
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function expectBadRequestResponse(res: ReturnType<typeof createMockResponse>) {
|
|
307
|
-
expect(res._getStatusCode()).toBe(400);
|
|
308
|
-
const data = JSON.parse(res._getData());
|
|
309
|
-
expect(data.ok).toBe(false);
|
|
310
|
-
return data;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async function expectPrivatePictureRejected(pictureUrl: string) {
|
|
314
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
315
|
-
body: {
|
|
316
|
-
name: "hacker",
|
|
317
|
-
picture: pictureUrl,
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
await run();
|
|
322
|
-
|
|
323
|
-
const data = expectBadRequestResponse(res);
|
|
324
|
-
expect(data.error).toContain("private");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
it("validates profile and publishes", async () => {
|
|
328
|
-
const { ctx, res, run } = createProfileHttpHarness(
|
|
329
|
-
"PUT",
|
|
330
|
-
"/api/channels/nostr/default/profile",
|
|
331
|
-
{
|
|
332
|
-
body: {
|
|
333
|
-
name: "satoshi",
|
|
334
|
-
displayName: "Satoshi Nakamoto",
|
|
335
|
-
about: "Creator of Bitcoin",
|
|
336
|
-
},
|
|
337
|
-
},
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
mockPublishSuccess();
|
|
341
|
-
|
|
342
|
-
await run();
|
|
343
|
-
|
|
344
|
-
const data = expectOkResponse(res);
|
|
345
|
-
expect(data.eventId).toBe("event123");
|
|
346
|
-
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
|
|
347
|
-
expect(data.persisted).toBe(true);
|
|
348
|
-
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it("rejects profile mutation from non-loopback remote address", async () => {
|
|
352
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
353
|
-
body: { name: "attacker" },
|
|
354
|
-
req: { remoteAddress: "198.51.100.10" },
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
await run();
|
|
358
|
-
expect(res._getStatusCode()).toBe(403);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it("rejects cross-origin profile mutation attempts", async () => {
|
|
362
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
363
|
-
body: { name: "attacker" },
|
|
364
|
-
req: { headers: { origin: "https://evil.example" } },
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
await run();
|
|
368
|
-
expect(res._getStatusCode()).toBe(403);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
|
372
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
373
|
-
body: { name: "attacker" },
|
|
374
|
-
req: { headers: { "sec-fetch-site": "cross-site" } },
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
await run();
|
|
378
|
-
expect(res._getStatusCode()).toBe(403);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
|
382
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
383
|
-
body: { name: "attacker" },
|
|
384
|
-
req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
await run();
|
|
388
|
-
expect(res._getStatusCode()).toBe(403);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
|
|
392
|
-
await expectAdminScopeRejected({
|
|
393
|
-
scopes: ["operator.read"],
|
|
394
|
-
method: "PUT",
|
|
395
|
-
url: "/api/channels/nostr/default/profile",
|
|
396
|
-
body: { name: "attacker" },
|
|
397
|
-
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it("rejects profile mutation when gateway scope context is missing", async () => {
|
|
402
|
-
await expectAdminScopeRejected({
|
|
403
|
-
scopes: undefined,
|
|
404
|
-
method: "PUT",
|
|
405
|
-
url: "/api/channels/nostr/default/profile",
|
|
406
|
-
body: { name: "attacker" },
|
|
407
|
-
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
|
412
|
-
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
|
|
416
|
-
await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it("rejects non-https URLs", async () => {
|
|
420
|
-
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
421
|
-
body: {
|
|
422
|
-
name: "test",
|
|
423
|
-
picture: "http://example.com/pic.jpg",
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
await run();
|
|
428
|
-
|
|
429
|
-
const data = expectBadRequestResponse(res);
|
|
430
|
-
// The schema validation catches non-https URLs before SSRF check
|
|
431
|
-
expect(data.error).toBe("Validation failed");
|
|
432
|
-
expect(data.details).toBeDefined();
|
|
433
|
-
expect(data.details.some((d: string) => d.includes("https"))).toBe(true);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it("does not persist if all relays fail", async () => {
|
|
437
|
-
const { ctx, res, run } = createProfileHttpHarness(
|
|
438
|
-
"PUT",
|
|
439
|
-
"/api/channels/nostr/default/profile",
|
|
440
|
-
{
|
|
441
|
-
body: {
|
|
442
|
-
name: "test",
|
|
443
|
-
},
|
|
444
|
-
},
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
448
|
-
eventId: "event123",
|
|
449
|
-
createdAt: 1234567890,
|
|
450
|
-
successes: [],
|
|
451
|
-
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
await run();
|
|
455
|
-
|
|
456
|
-
expect(res._getStatusCode()).toBe(200);
|
|
457
|
-
const data = JSON.parse(res._getData());
|
|
458
|
-
expect(data.persisted).toBe(false);
|
|
459
|
-
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
it("enforces rate limiting", async () => {
|
|
463
|
-
mockPublishSuccess();
|
|
464
|
-
|
|
465
|
-
// Make 6 requests (limit is 5/min)
|
|
466
|
-
for (let i = 0; i < 6; i++) {
|
|
467
|
-
const { res, run } = createProfileHttpHarness(
|
|
468
|
-
"PUT",
|
|
469
|
-
"/api/channels/nostr/rate-test/profile",
|
|
470
|
-
{
|
|
471
|
-
body: {
|
|
472
|
-
name: `user${i}`,
|
|
473
|
-
},
|
|
474
|
-
},
|
|
475
|
-
);
|
|
476
|
-
await run();
|
|
477
|
-
|
|
478
|
-
if (i < 5) {
|
|
479
|
-
expectOkResponse(res);
|
|
480
|
-
} else {
|
|
481
|
-
expect(res._getStatusCode()).toBe(429);
|
|
482
|
-
const data = JSON.parse(res._getData());
|
|
483
|
-
expect(data.error).toContain("Rate limit");
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("caps tracked rate-limit keys to prevent unbounded growth", () => {
|
|
489
|
-
const now = 1_000_000;
|
|
490
|
-
for (let i = 0; i < 2_500; i += 1) {
|
|
491
|
-
isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
|
|
492
|
-
}
|
|
493
|
-
expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it("prunes stale rate-limit keys after the window elapses", () => {
|
|
497
|
-
const now = 2_000_000;
|
|
498
|
-
for (let i = 0; i < 100; i += 1) {
|
|
499
|
-
isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
|
|
500
|
-
}
|
|
501
|
-
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
|
|
502
|
-
|
|
503
|
-
isNostrProfileRateLimitedForTest("fresh", now + 60_001);
|
|
504
|
-
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
|
509
|
-
function expectImportSuccessResponse(res: ReturnType<typeof createMockResponse>) {
|
|
510
|
-
const data = expectOkResponse(res);
|
|
511
|
-
expect(data.imported.name).toBe("imported");
|
|
512
|
-
return data;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
it("imports profile from relays", async () => {
|
|
516
|
-
const { res, run } = createProfileHttpHarness(
|
|
517
|
-
"POST",
|
|
518
|
-
"/api/channels/nostr/default/profile/import",
|
|
519
|
-
{ body: {} },
|
|
520
|
-
);
|
|
521
|
-
|
|
522
|
-
mockSuccessfulProfileImport();
|
|
523
|
-
|
|
524
|
-
await run();
|
|
525
|
-
|
|
526
|
-
const data = expectImportSuccessResponse(res);
|
|
527
|
-
expect(data.saved).toBe(false); // autoMerge not requested
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
it("rejects import mutation from non-loopback remote address", async () => {
|
|
531
|
-
const { res, run } = createProfileHttpHarness(
|
|
532
|
-
"POST",
|
|
533
|
-
"/api/channels/nostr/default/profile/import",
|
|
534
|
-
{
|
|
535
|
-
body: {},
|
|
536
|
-
req: { remoteAddress: "203.0.113.10" },
|
|
537
|
-
},
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
await run();
|
|
541
|
-
expect(res._getStatusCode()).toBe(403);
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it("rejects cross-origin import mutation attempts", async () => {
|
|
545
|
-
const { res, run } = createProfileHttpHarness(
|
|
546
|
-
"POST",
|
|
547
|
-
"/api/channels/nostr/default/profile/import",
|
|
548
|
-
{
|
|
549
|
-
body: {},
|
|
550
|
-
req: { headers: { origin: "https://evil.example" } },
|
|
551
|
-
},
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
await run();
|
|
555
|
-
expect(res._getStatusCode()).toBe(403);
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
|
559
|
-
const { res, run } = createProfileHttpHarness(
|
|
560
|
-
"POST",
|
|
561
|
-
"/api/channels/nostr/default/profile/import",
|
|
562
|
-
{
|
|
563
|
-
body: {},
|
|
564
|
-
req: { headers: { "x-real-ip": "198.51.100.55" } },
|
|
565
|
-
},
|
|
566
|
-
);
|
|
567
|
-
|
|
568
|
-
await run();
|
|
569
|
-
expect(res._getStatusCode()).toBe(403);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
it("rejects profile import when gateway caller is missing operator.admin", async () => {
|
|
573
|
-
await expectAdminScopeRejected({
|
|
574
|
-
scopes: ["operator.read"],
|
|
575
|
-
method: "POST",
|
|
576
|
-
url: "/api/channels/nostr/default/profile/import",
|
|
577
|
-
body: { autoMerge: true },
|
|
578
|
-
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
|
579
|
-
});
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
it("rejects profile import when gateway scope context is missing", async () => {
|
|
583
|
-
await expectAdminScopeRejected({
|
|
584
|
-
scopes: undefined,
|
|
585
|
-
method: "POST",
|
|
586
|
-
url: "/api/channels/nostr/default/profile/import",
|
|
587
|
-
body: { autoMerge: true },
|
|
588
|
-
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
|
589
|
-
});
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it("auto-merges when requested", async () => {
|
|
593
|
-
const { ctx, res, run } = createProfileHttpHarness(
|
|
594
|
-
"POST",
|
|
595
|
-
"/api/channels/nostr/default/profile/import",
|
|
596
|
-
{
|
|
597
|
-
body: { autoMerge: true },
|
|
598
|
-
ctx: {
|
|
599
|
-
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
|
600
|
-
},
|
|
601
|
-
},
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
mockSuccessfulProfileImport();
|
|
605
|
-
|
|
606
|
-
await run();
|
|
607
|
-
|
|
608
|
-
const data = expectImportSuccessResponse(res);
|
|
609
|
-
expect(data.saved).toBe(true);
|
|
610
|
-
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
it("returns error when account not found", async () => {
|
|
614
|
-
const { res, run } = createProfileHttpHarness(
|
|
615
|
-
"POST",
|
|
616
|
-
"/api/channels/nostr/unknown/profile/import",
|
|
617
|
-
{
|
|
618
|
-
body: {},
|
|
619
|
-
ctx: {
|
|
620
|
-
getAccountInfo: vi.fn().mockReturnValue(null),
|
|
621
|
-
},
|
|
622
|
-
},
|
|
623
|
-
);
|
|
624
|
-
|
|
625
|
-
await run();
|
|
626
|
-
|
|
627
|
-
expect(res._getStatusCode()).toBe(404);
|
|
628
|
-
const data = JSON.parse(res._getData());
|
|
629
|
-
expect(data.error).toContain("not found");
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
});
|