@openclaw/nostr 2026.3.13 → 2026.5.2-beta.1
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/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +147 -283
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +276 -167
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -116
- package/src/types.test.ts +0 -175
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
6
6
|
import { Socket } from "node:net";
|
|
7
|
-
import {
|
|
7
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
clearNostrProfileRateLimitStateForTest,
|
|
10
10
|
createNostrProfileHttpHandler,
|
|
@@ -13,6 +13,19 @@ import {
|
|
|
13
13
|
type NostrProfileHttpContext,
|
|
14
14
|
} from "./nostr-profile-http.js";
|
|
15
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
|
+
|
|
16
29
|
// Mock the channel exports
|
|
17
30
|
vi.mock("./channel.js", () => ({
|
|
18
31
|
publishNostrProfile: vi.fn(),
|
|
@@ -27,11 +40,42 @@ vi.mock("./nostr-profile-import.js", () => ({
|
|
|
27
40
|
|
|
28
41
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
29
42
|
import { importProfileFromRelays } from "./nostr-profile-import.js";
|
|
43
|
+
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
|
30
44
|
|
|
31
45
|
// ============================================================================
|
|
32
46
|
// Test Helpers
|
|
33
47
|
// ============================================================================
|
|
34
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
|
+
|
|
35
79
|
function createMockRequest(
|
|
36
80
|
method: string,
|
|
37
81
|
url: string,
|
|
@@ -46,7 +90,7 @@ function createMockRequest(
|
|
|
46
90
|
const req = new IncomingMessage(socket);
|
|
47
91
|
req.method = method;
|
|
48
92
|
req.url = url;
|
|
49
|
-
req.headers = { host: "localhost:3000", ...
|
|
93
|
+
req.headers = { host: "localhost:3000", ...opts?.headers };
|
|
50
94
|
|
|
51
95
|
if (body) {
|
|
52
96
|
const bodyStr = JSON.stringify(body);
|
|
@@ -63,24 +107,30 @@ function createMockRequest(
|
|
|
63
107
|
return req;
|
|
64
108
|
}
|
|
65
109
|
|
|
66
|
-
|
|
110
|
+
type MockResponse = {
|
|
67
111
|
_getData: () => string;
|
|
68
112
|
_getStatusCode: () => number;
|
|
69
|
-
|
|
70
|
-
|
|
113
|
+
write: (chunk: unknown) => boolean;
|
|
114
|
+
end: (chunk?: unknown) => MockResponse;
|
|
115
|
+
statusCode: number;
|
|
116
|
+
};
|
|
71
117
|
|
|
118
|
+
function createMockResponse(): MockResponse {
|
|
72
119
|
let data = "";
|
|
73
120
|
let statusCode = 200;
|
|
121
|
+
const res = Object.assign(new ServerResponse({} as IncomingMessage), {
|
|
122
|
+
_getData: () => data,
|
|
123
|
+
_getStatusCode: () => statusCode,
|
|
124
|
+
}) as MockResponse;
|
|
74
125
|
|
|
75
126
|
res.write = function (chunk: unknown) {
|
|
76
|
-
data +=
|
|
127
|
+
data += responseChunkText(chunk);
|
|
77
128
|
return true;
|
|
78
129
|
};
|
|
79
130
|
|
|
80
131
|
res.end = function (chunk?: unknown) {
|
|
81
132
|
if (chunk) {
|
|
82
|
-
|
|
83
|
-
data += String(chunk);
|
|
133
|
+
data += responseChunkText(chunk);
|
|
84
134
|
}
|
|
85
135
|
return this;
|
|
86
136
|
};
|
|
@@ -92,10 +142,7 @@ function createMockResponse(): ServerResponse & {
|
|
|
92
142
|
},
|
|
93
143
|
});
|
|
94
144
|
|
|
95
|
-
|
|
96
|
-
(res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
|
|
97
|
-
|
|
98
|
-
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
|
|
145
|
+
return res;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
|
@@ -103,8 +150,8 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
|
|
|
103
150
|
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
|
104
151
|
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
|
105
152
|
getAccountInfo: vi.fn().mockReturnValue({
|
|
106
|
-
pubkey:
|
|
107
|
-
relays: [
|
|
153
|
+
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
154
|
+
relays: [TEST_PROFILE_RELAY_URL],
|
|
108
155
|
}),
|
|
109
156
|
log: {
|
|
110
157
|
info: vi.fn(),
|
|
@@ -115,7 +162,29 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
|
|
|
115
162
|
};
|
|
116
163
|
}
|
|
117
164
|
|
|
118
|
-
function
|
|
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) {
|
|
119
188
|
expect(res._getStatusCode()).toBe(200);
|
|
120
189
|
const data = JSON.parse(res._getData());
|
|
121
190
|
expect(data.ok).toBe(true);
|
|
@@ -131,14 +200,35 @@ function mockSuccessfulProfileImport() {
|
|
|
131
200
|
},
|
|
132
201
|
event: {
|
|
133
202
|
id: "evt123",
|
|
134
|
-
pubkey:
|
|
203
|
+
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
135
204
|
created_at: 1234567890,
|
|
136
205
|
},
|
|
137
|
-
relaysQueried: [
|
|
138
|
-
sourceRelay:
|
|
206
|
+
relaysQueried: [TEST_PROFILE_RELAY_URL],
|
|
207
|
+
sourceRelay: TEST_PROFILE_RELAY_URL,
|
|
139
208
|
});
|
|
140
209
|
}
|
|
141
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
|
+
|
|
142
232
|
// ============================================================================
|
|
143
233
|
// Tests
|
|
144
234
|
// ============================================================================
|
|
@@ -147,40 +237,30 @@ describe("nostr-profile-http", () => {
|
|
|
147
237
|
beforeEach(() => {
|
|
148
238
|
vi.clearAllMocks();
|
|
149
239
|
clearNostrProfileRateLimitStateForTest();
|
|
240
|
+
setGatewayRuntimeScopes(["operator.admin"]);
|
|
150
241
|
});
|
|
151
242
|
|
|
152
243
|
describe("route matching", () => {
|
|
153
244
|
it("returns false for non-nostr paths", async () => {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const req = createMockRequest("GET", "/api/channels/telegram/profile");
|
|
157
|
-
const res = createMockResponse();
|
|
158
|
-
|
|
159
|
-
const result = await handler(req, res);
|
|
245
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
|
|
246
|
+
const result = await run();
|
|
160
247
|
|
|
161
248
|
expect(result).toBe(false);
|
|
162
249
|
});
|
|
163
250
|
|
|
164
251
|
it("returns false for paths without accountId", async () => {
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const req = createMockRequest("GET", "/api/channels/nostr/");
|
|
168
|
-
const res = createMockResponse();
|
|
169
|
-
|
|
170
|
-
const result = await handler(req, res);
|
|
252
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
|
|
253
|
+
const result = await run();
|
|
171
254
|
|
|
172
255
|
expect(result).toBe(false);
|
|
173
256
|
});
|
|
174
257
|
|
|
175
258
|
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
|
176
|
-
const
|
|
177
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
178
|
-
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
|
179
|
-
const res = createMockResponse();
|
|
259
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
|
|
180
260
|
|
|
181
261
|
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
|
182
262
|
|
|
183
|
-
const result = await
|
|
263
|
+
const result = await run();
|
|
184
264
|
|
|
185
265
|
expect(result).toBe(true);
|
|
186
266
|
});
|
|
@@ -188,23 +268,22 @@ describe("nostr-profile-http", () => {
|
|
|
188
268
|
|
|
189
269
|
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
|
190
270
|
it("returns profile and publish state", async () => {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
},
|
|
196
278
|
});
|
|
197
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
198
|
-
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
|
199
|
-
const res = createMockResponse();
|
|
200
279
|
|
|
201
280
|
vi.mocked(getNostrProfileState).mockResolvedValue({
|
|
202
281
|
lastPublishedAt: 1234567890,
|
|
203
282
|
lastPublishedEventId: "abc123",
|
|
204
|
-
lastPublishResults: {
|
|
283
|
+
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
|
|
205
284
|
});
|
|
206
285
|
|
|
207
|
-
await
|
|
286
|
+
await run();
|
|
208
287
|
|
|
209
288
|
expect(res._getStatusCode()).toBe(200);
|
|
210
289
|
const data = JSON.parse(res._getData());
|
|
@@ -219,7 +298,7 @@ describe("nostr-profile-http", () => {
|
|
|
219
298
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
220
299
|
eventId: "event123",
|
|
221
300
|
createdAt: 1234567890,
|
|
222
|
-
successes: [
|
|
301
|
+
successes: [TEST_PROFILE_RELAY_URL],
|
|
223
302
|
failures: [],
|
|
224
303
|
});
|
|
225
304
|
}
|
|
@@ -232,101 +311,103 @@ describe("nostr-profile-http", () => {
|
|
|
232
311
|
}
|
|
233
312
|
|
|
234
313
|
async function expectPrivatePictureRejected(pictureUrl: string) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
314
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
315
|
+
body: {
|
|
316
|
+
name: "hacker",
|
|
317
|
+
picture: pictureUrl,
|
|
318
|
+
},
|
|
240
319
|
});
|
|
241
|
-
const res = createMockResponse();
|
|
242
320
|
|
|
243
|
-
await
|
|
321
|
+
await run();
|
|
244
322
|
|
|
245
323
|
const data = expectBadRequestResponse(res);
|
|
246
324
|
expect(data.error).toContain("private");
|
|
247
325
|
}
|
|
248
326
|
|
|
249
327
|
it("validates profile and publishes", async () => {
|
|
250
|
-
const ctx =
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
);
|
|
258
339
|
|
|
259
340
|
mockPublishSuccess();
|
|
260
341
|
|
|
261
|
-
await
|
|
342
|
+
await run();
|
|
262
343
|
|
|
263
344
|
const data = expectOkResponse(res);
|
|
264
345
|
expect(data.eventId).toBe("event123");
|
|
265
|
-
expect(data.successes).toContain(
|
|
346
|
+
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
|
|
266
347
|
expect(data.persisted).toBe(true);
|
|
267
348
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
268
349
|
});
|
|
269
350
|
|
|
270
351
|
it("rejects profile mutation from non-loopback remote address", async () => {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
"/api/channels/nostr/default/profile",
|
|
276
|
-
{ name: "attacker" },
|
|
277
|
-
{ remoteAddress: "198.51.100.10" },
|
|
278
|
-
);
|
|
279
|
-
const res = createMockResponse();
|
|
352
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
353
|
+
body: { name: "attacker" },
|
|
354
|
+
req: { remoteAddress: "198.51.100.10" },
|
|
355
|
+
});
|
|
280
356
|
|
|
281
|
-
await
|
|
357
|
+
await run();
|
|
282
358
|
expect(res._getStatusCode()).toBe(403);
|
|
283
359
|
});
|
|
284
360
|
|
|
285
361
|
it("rejects cross-origin profile mutation attempts", async () => {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
"/api/channels/nostr/default/profile",
|
|
291
|
-
{ name: "attacker" },
|
|
292
|
-
{ headers: { origin: "https://evil.example" } },
|
|
293
|
-
);
|
|
294
|
-
const res = createMockResponse();
|
|
362
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
363
|
+
body: { name: "attacker" },
|
|
364
|
+
req: { headers: { origin: "https://evil.example" } },
|
|
365
|
+
});
|
|
295
366
|
|
|
296
|
-
await
|
|
367
|
+
await run();
|
|
297
368
|
expect(res._getStatusCode()).toBe(403);
|
|
298
369
|
});
|
|
299
370
|
|
|
300
371
|
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
"/api/channels/nostr/default/profile",
|
|
306
|
-
{ name: "attacker" },
|
|
307
|
-
{ headers: { "sec-fetch-site": "cross-site" } },
|
|
308
|
-
);
|
|
309
|
-
const res = createMockResponse();
|
|
372
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
373
|
+
body: { name: "attacker" },
|
|
374
|
+
req: { headers: { "sec-fetch-site": "cross-site" } },
|
|
375
|
+
});
|
|
310
376
|
|
|
311
|
-
await
|
|
377
|
+
await run();
|
|
312
378
|
expect(res._getStatusCode()).toBe(403);
|
|
313
379
|
});
|
|
314
380
|
|
|
315
381
|
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"/api/channels/nostr/default/profile",
|
|
321
|
-
{ name: "attacker" },
|
|
322
|
-
{ headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
|
323
|
-
);
|
|
324
|
-
const res = createMockResponse();
|
|
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
|
+
});
|
|
325
386
|
|
|
326
|
-
await
|
|
387
|
+
await run();
|
|
327
388
|
expect(res._getStatusCode()).toBe(403);
|
|
328
389
|
});
|
|
329
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
|
+
|
|
330
411
|
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
|
331
412
|
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
|
332
413
|
});
|
|
@@ -336,15 +417,14 @@ describe("nostr-profile-http", () => {
|
|
|
336
417
|
});
|
|
337
418
|
|
|
338
419
|
it("rejects non-https URLs", async () => {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
420
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
421
|
+
body: {
|
|
422
|
+
name: "test",
|
|
423
|
+
picture: "http://example.com/pic.jpg",
|
|
424
|
+
},
|
|
344
425
|
});
|
|
345
|
-
const res = createMockResponse();
|
|
346
426
|
|
|
347
|
-
await
|
|
427
|
+
await run();
|
|
348
428
|
|
|
349
429
|
const data = expectBadRequestResponse(res);
|
|
350
430
|
// The schema validation catches non-https URLs before SSRF check
|
|
@@ -354,21 +434,24 @@ describe("nostr-profile-http", () => {
|
|
|
354
434
|
});
|
|
355
435
|
|
|
356
436
|
it("does not persist if all relays fail", async () => {
|
|
357
|
-
const ctx =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
437
|
+
const { ctx, res, run } = createProfileHttpHarness(
|
|
438
|
+
"PUT",
|
|
439
|
+
"/api/channels/nostr/default/profile",
|
|
440
|
+
{
|
|
441
|
+
body: {
|
|
442
|
+
name: "test",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
);
|
|
363
446
|
|
|
364
447
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
365
448
|
eventId: "event123",
|
|
366
449
|
createdAt: 1234567890,
|
|
367
450
|
successes: [],
|
|
368
|
-
failures: [{ relay:
|
|
451
|
+
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
|
|
369
452
|
});
|
|
370
453
|
|
|
371
|
-
await
|
|
454
|
+
await run();
|
|
372
455
|
|
|
373
456
|
expect(res._getStatusCode()).toBe(200);
|
|
374
457
|
const data = JSON.parse(res._getData());
|
|
@@ -377,18 +460,20 @@ describe("nostr-profile-http", () => {
|
|
|
377
460
|
});
|
|
378
461
|
|
|
379
462
|
it("enforces rate limiting", async () => {
|
|
380
|
-
const ctx = createMockContext();
|
|
381
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
382
|
-
|
|
383
463
|
mockPublishSuccess();
|
|
384
464
|
|
|
385
465
|
// Make 6 requests (limit is 5/min)
|
|
386
466
|
for (let i = 0; i < 6; i++) {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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();
|
|
392
477
|
|
|
393
478
|
if (i < 5) {
|
|
394
479
|
expectOkResponse(res);
|
|
@@ -428,77 +513,97 @@ describe("nostr-profile-http", () => {
|
|
|
428
513
|
}
|
|
429
514
|
|
|
430
515
|
it("imports profile from relays", async () => {
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
516
|
+
const { res, run } = createProfileHttpHarness(
|
|
517
|
+
"POST",
|
|
518
|
+
"/api/channels/nostr/default/profile/import",
|
|
519
|
+
{ body: {} },
|
|
520
|
+
);
|
|
435
521
|
|
|
436
522
|
mockSuccessfulProfileImport();
|
|
437
523
|
|
|
438
|
-
await
|
|
524
|
+
await run();
|
|
439
525
|
|
|
440
526
|
const data = expectImportSuccessResponse(res);
|
|
441
527
|
expect(data.saved).toBe(false); // autoMerge not requested
|
|
442
528
|
});
|
|
443
529
|
|
|
444
530
|
it("rejects import mutation from non-loopback remote address", async () => {
|
|
445
|
-
const
|
|
446
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
447
|
-
const req = createMockRequest(
|
|
531
|
+
const { res, run } = createProfileHttpHarness(
|
|
448
532
|
"POST",
|
|
449
533
|
"/api/channels/nostr/default/profile/import",
|
|
450
|
-
{
|
|
451
|
-
|
|
534
|
+
{
|
|
535
|
+
body: {},
|
|
536
|
+
req: { remoteAddress: "203.0.113.10" },
|
|
537
|
+
},
|
|
452
538
|
);
|
|
453
|
-
const res = createMockResponse();
|
|
454
539
|
|
|
455
|
-
await
|
|
540
|
+
await run();
|
|
456
541
|
expect(res._getStatusCode()).toBe(403);
|
|
457
542
|
});
|
|
458
543
|
|
|
459
544
|
it("rejects cross-origin import mutation attempts", async () => {
|
|
460
|
-
const
|
|
461
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
462
|
-
const req = createMockRequest(
|
|
545
|
+
const { res, run } = createProfileHttpHarness(
|
|
463
546
|
"POST",
|
|
464
547
|
"/api/channels/nostr/default/profile/import",
|
|
465
|
-
{
|
|
466
|
-
|
|
548
|
+
{
|
|
549
|
+
body: {},
|
|
550
|
+
req: { headers: { origin: "https://evil.example" } },
|
|
551
|
+
},
|
|
467
552
|
);
|
|
468
|
-
const res = createMockResponse();
|
|
469
553
|
|
|
470
|
-
await
|
|
554
|
+
await run();
|
|
471
555
|
expect(res._getStatusCode()).toBe(403);
|
|
472
556
|
});
|
|
473
557
|
|
|
474
558
|
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
|
475
|
-
const
|
|
476
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
477
|
-
const req = createMockRequest(
|
|
559
|
+
const { res, run } = createProfileHttpHarness(
|
|
478
560
|
"POST",
|
|
479
561
|
"/api/channels/nostr/default/profile/import",
|
|
480
|
-
{
|
|
481
|
-
|
|
562
|
+
{
|
|
563
|
+
body: {},
|
|
564
|
+
req: { headers: { "x-real-ip": "198.51.100.55" } },
|
|
565
|
+
},
|
|
482
566
|
);
|
|
483
|
-
const res = createMockResponse();
|
|
484
567
|
|
|
485
|
-
await
|
|
568
|
+
await run();
|
|
486
569
|
expect(res._getStatusCode()).toBe(403);
|
|
487
570
|
});
|
|
488
571
|
|
|
489
|
-
it("
|
|
490
|
-
|
|
491
|
-
|
|
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(),
|
|
492
579
|
});
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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(),
|
|
496
589
|
});
|
|
497
|
-
|
|
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
|
+
);
|
|
498
603
|
|
|
499
604
|
mockSuccessfulProfileImport();
|
|
500
605
|
|
|
501
|
-
await
|
|
606
|
+
await run();
|
|
502
607
|
|
|
503
608
|
const data = expectImportSuccessResponse(res);
|
|
504
609
|
expect(data.saved).toBe(true);
|
|
@@ -506,14 +611,18 @@ describe("nostr-profile-http", () => {
|
|
|
506
611
|
});
|
|
507
612
|
|
|
508
613
|
it("returns error when account not found", async () => {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
+
);
|
|
515
624
|
|
|
516
|
-
await
|
|
625
|
+
await run();
|
|
517
626
|
|
|
518
627
|
expect(res._getStatusCode()).toBe(404);
|
|
519
628
|
const data = JSON.parse(res._getData());
|