@openclaw/nostr 2026.1.29
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/CHANGELOG.md +51 -0
- package/README.md +136 -0
- package/index.ts +69 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +31 -0
- package/src/channel.test.ts +141 -0
- package/src/channel.ts +342 -0
- package/src/config-schema.ts +90 -0
- package/src/metrics.ts +464 -0
- package/src/nostr-bus.fuzz.test.ts +544 -0
- package/src/nostr-bus.integration.test.ts +452 -0
- package/src/nostr-bus.test.ts +199 -0
- package/src/nostr-bus.ts +741 -0
- package/src/nostr-profile-http.test.ts +378 -0
- package/src/nostr-profile-http.ts +500 -0
- package/src/nostr-profile-import.test.ts +120 -0
- package/src/nostr-profile-import.ts +259 -0
- package/src/nostr-profile.fuzz.test.ts +479 -0
- package/src/nostr-profile.test.ts +410 -0
- package/src/nostr-profile.ts +242 -0
- package/src/nostr-state-store.test.ts +129 -0
- package/src/nostr-state-store.ts +226 -0
- package/src/runtime.ts +14 -0
- package/src/seen-tracker.ts +271 -0
- package/src/types.test.ts +161 -0
- package/src/types.ts +99 -0
- package/test/setup.ts +5 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getPublicKey } from "nostr-tools";
|
|
3
|
+
import {
|
|
4
|
+
createProfileEvent,
|
|
5
|
+
profileToContent,
|
|
6
|
+
validateProfile,
|
|
7
|
+
sanitizeProfileForDisplay,
|
|
8
|
+
} from "./nostr-profile.js";
|
|
9
|
+
import type { NostrProfile } from "./config-schema.js";
|
|
10
|
+
|
|
11
|
+
// Test private key
|
|
12
|
+
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
13
|
+
const TEST_SK = new Uint8Array(
|
|
14
|
+
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Unicode Attack Vectors
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
describe("profile unicode attacks", () => {
|
|
22
|
+
describe("zero-width characters", () => {
|
|
23
|
+
it("handles zero-width space in name", () => {
|
|
24
|
+
const profile: NostrProfile = {
|
|
25
|
+
name: "test\u200Buser", // Zero-width space
|
|
26
|
+
};
|
|
27
|
+
const result = validateProfile(profile);
|
|
28
|
+
expect(result.valid).toBe(true);
|
|
29
|
+
// The character should be preserved (not stripped)
|
|
30
|
+
expect(result.profile?.name).toBe("test\u200Buser");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles zero-width joiner in name", () => {
|
|
34
|
+
const profile: NostrProfile = {
|
|
35
|
+
name: "test\u200Duser", // Zero-width joiner
|
|
36
|
+
};
|
|
37
|
+
const result = validateProfile(profile);
|
|
38
|
+
expect(result.valid).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles zero-width non-joiner in about", () => {
|
|
42
|
+
const profile: NostrProfile = {
|
|
43
|
+
about: "test\u200Cabout", // Zero-width non-joiner
|
|
44
|
+
};
|
|
45
|
+
const content = profileToContent(profile);
|
|
46
|
+
expect(content.about).toBe("test\u200Cabout");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("RTL override attacks", () => {
|
|
51
|
+
it("handles RTL override in name", () => {
|
|
52
|
+
const profile: NostrProfile = {
|
|
53
|
+
name: "\u202Eevil\u202C", // Right-to-left override + pop direction
|
|
54
|
+
};
|
|
55
|
+
const result = validateProfile(profile);
|
|
56
|
+
expect(result.valid).toBe(true);
|
|
57
|
+
|
|
58
|
+
// UI should escape or handle this
|
|
59
|
+
const sanitized = sanitizeProfileForDisplay(result.profile!);
|
|
60
|
+
expect(sanitized.name).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles bidi embedding in about", () => {
|
|
64
|
+
const profile: NostrProfile = {
|
|
65
|
+
about: "Normal \u202Breversed\u202C text", // LTR embedding
|
|
66
|
+
};
|
|
67
|
+
const result = validateProfile(profile);
|
|
68
|
+
expect(result.valid).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("homoglyph attacks", () => {
|
|
73
|
+
it("handles Cyrillic homoglyphs", () => {
|
|
74
|
+
const profile: NostrProfile = {
|
|
75
|
+
// Cyrillic 'а' (U+0430) looks like Latin 'a'
|
|
76
|
+
name: "\u0430dmin", // Fake "admin"
|
|
77
|
+
};
|
|
78
|
+
const result = validateProfile(profile);
|
|
79
|
+
expect(result.valid).toBe(true);
|
|
80
|
+
// Profile is accepted but apps should be aware
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles Greek homoglyphs", () => {
|
|
84
|
+
const profile: NostrProfile = {
|
|
85
|
+
// Greek 'ο' (U+03BF) looks like Latin 'o'
|
|
86
|
+
name: "b\u03BFt", // Looks like "bot"
|
|
87
|
+
};
|
|
88
|
+
const result = validateProfile(profile);
|
|
89
|
+
expect(result.valid).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("combining characters", () => {
|
|
94
|
+
it("handles combining diacritics", () => {
|
|
95
|
+
const profile: NostrProfile = {
|
|
96
|
+
name: "cafe\u0301", // 'e' + combining acute = 'é'
|
|
97
|
+
};
|
|
98
|
+
const result = validateProfile(profile);
|
|
99
|
+
expect(result.valid).toBe(true);
|
|
100
|
+
expect(result.profile?.name).toBe("cafe\u0301");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("handles excessive combining characters (Zalgo text)", () => {
|
|
104
|
+
const zalgo =
|
|
105
|
+
"t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
|
|
106
|
+
const profile: NostrProfile = {
|
|
107
|
+
name: zalgo.slice(0, 256), // Truncate to fit limit
|
|
108
|
+
};
|
|
109
|
+
const result = validateProfile(profile);
|
|
110
|
+
// Should be valid but may look weird
|
|
111
|
+
expect(result.valid).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("CJK and other scripts", () => {
|
|
116
|
+
it("handles Chinese characters", () => {
|
|
117
|
+
const profile: NostrProfile = {
|
|
118
|
+
name: "中文用户",
|
|
119
|
+
about: "我是一个机器人",
|
|
120
|
+
};
|
|
121
|
+
const result = validateProfile(profile);
|
|
122
|
+
expect(result.valid).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("handles Japanese hiragana and katakana", () => {
|
|
126
|
+
const profile: NostrProfile = {
|
|
127
|
+
name: "ボット",
|
|
128
|
+
about: "これはテストです",
|
|
129
|
+
};
|
|
130
|
+
const result = validateProfile(profile);
|
|
131
|
+
expect(result.valid).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("handles Korean characters", () => {
|
|
135
|
+
const profile: NostrProfile = {
|
|
136
|
+
name: "한국어사용자",
|
|
137
|
+
};
|
|
138
|
+
const result = validateProfile(profile);
|
|
139
|
+
expect(result.valid).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("handles Arabic text", () => {
|
|
143
|
+
const profile: NostrProfile = {
|
|
144
|
+
name: "مستخدم",
|
|
145
|
+
about: "مرحبا بالعالم",
|
|
146
|
+
};
|
|
147
|
+
const result = validateProfile(profile);
|
|
148
|
+
expect(result.valid).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("handles Hebrew text", () => {
|
|
152
|
+
const profile: NostrProfile = {
|
|
153
|
+
name: "משתמש",
|
|
154
|
+
};
|
|
155
|
+
const result = validateProfile(profile);
|
|
156
|
+
expect(result.valid).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("handles Thai text", () => {
|
|
160
|
+
const profile: NostrProfile = {
|
|
161
|
+
name: "ผู้ใช้",
|
|
162
|
+
};
|
|
163
|
+
const result = validateProfile(profile);
|
|
164
|
+
expect(result.valid).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("emoji edge cases", () => {
|
|
169
|
+
it("handles emoji sequences (ZWJ)", () => {
|
|
170
|
+
const profile: NostrProfile = {
|
|
171
|
+
name: "👨👩👧👦", // Family emoji using ZWJ
|
|
172
|
+
};
|
|
173
|
+
const result = validateProfile(profile);
|
|
174
|
+
expect(result.valid).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("handles flag emojis", () => {
|
|
178
|
+
const profile: NostrProfile = {
|
|
179
|
+
name: "🇺🇸🇯🇵🇬🇧",
|
|
180
|
+
};
|
|
181
|
+
const result = validateProfile(profile);
|
|
182
|
+
expect(result.valid).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("handles skin tone modifiers", () => {
|
|
186
|
+
const profile: NostrProfile = {
|
|
187
|
+
name: "👋🏻👋🏽👋🏿",
|
|
188
|
+
};
|
|
189
|
+
const result = validateProfile(profile);
|
|
190
|
+
expect(result.valid).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// XSS Attack Vectors
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
describe("profile XSS attacks", () => {
|
|
200
|
+
describe("script injection", () => {
|
|
201
|
+
it("escapes script tags", () => {
|
|
202
|
+
const profile: NostrProfile = {
|
|
203
|
+
name: '<script>alert("xss")</script>',
|
|
204
|
+
};
|
|
205
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
206
|
+
expect(sanitized.name).not.toContain("<script>");
|
|
207
|
+
expect(sanitized.name).toContain("<script>");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("escapes nested script tags", () => {
|
|
211
|
+
const profile: NostrProfile = {
|
|
212
|
+
about: '<<script>script>alert("xss")<</script>/script>',
|
|
213
|
+
};
|
|
214
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
215
|
+
expect(sanitized.about).not.toContain("<script>");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("event handler injection", () => {
|
|
220
|
+
it("escapes img onerror", () => {
|
|
221
|
+
const profile: NostrProfile = {
|
|
222
|
+
about: '<img src="x" onerror="alert(1)">',
|
|
223
|
+
};
|
|
224
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
225
|
+
expect(sanitized.about).toContain("<img");
|
|
226
|
+
expect(sanitized.about).not.toContain('onerror="alert');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("escapes svg onload", () => {
|
|
230
|
+
const profile: NostrProfile = {
|
|
231
|
+
about: '<svg onload="alert(1)">',
|
|
232
|
+
};
|
|
233
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
234
|
+
expect(sanitized.about).toContain("<svg");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("escapes body onload", () => {
|
|
238
|
+
const profile: NostrProfile = {
|
|
239
|
+
about: '<body onload="alert(1)">',
|
|
240
|
+
};
|
|
241
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
242
|
+
expect(sanitized.about).toContain("<body");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("URL-based attacks", () => {
|
|
247
|
+
it("rejects javascript: URL in picture", () => {
|
|
248
|
+
const profile = {
|
|
249
|
+
picture: "javascript:alert('xss')",
|
|
250
|
+
};
|
|
251
|
+
const result = validateProfile(profile);
|
|
252
|
+
expect(result.valid).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects javascript: URL with encoding", () => {
|
|
256
|
+
const profile = {
|
|
257
|
+
picture: "javascript:alert('xss')",
|
|
258
|
+
};
|
|
259
|
+
const result = validateProfile(profile);
|
|
260
|
+
expect(result.valid).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("rejects data: URL", () => {
|
|
264
|
+
const profile = {
|
|
265
|
+
picture: "data:text/html,<script>alert('xss')</script>",
|
|
266
|
+
};
|
|
267
|
+
const result = validateProfile(profile);
|
|
268
|
+
expect(result.valid).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("rejects vbscript: URL", () => {
|
|
272
|
+
const profile = {
|
|
273
|
+
website: "vbscript:msgbox('xss')",
|
|
274
|
+
};
|
|
275
|
+
const result = validateProfile(profile);
|
|
276
|
+
expect(result.valid).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("rejects file: URL", () => {
|
|
280
|
+
const profile = {
|
|
281
|
+
picture: "file:///etc/passwd",
|
|
282
|
+
};
|
|
283
|
+
const result = validateProfile(profile);
|
|
284
|
+
expect(result.valid).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("HTML attribute injection", () => {
|
|
289
|
+
it("escapes double quotes in fields", () => {
|
|
290
|
+
const profile: NostrProfile = {
|
|
291
|
+
name: '" onclick="alert(1)" data-x="',
|
|
292
|
+
};
|
|
293
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
294
|
+
expect(sanitized.name).toContain(""");
|
|
295
|
+
expect(sanitized.name).not.toContain('onclick="alert');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("escapes single quotes in fields", () => {
|
|
299
|
+
const profile: NostrProfile = {
|
|
300
|
+
name: "' onclick='alert(1)' data-x='",
|
|
301
|
+
};
|
|
302
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
303
|
+
expect(sanitized.name).toContain("'");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("CSS injection", () => {
|
|
308
|
+
it("escapes style tags", () => {
|
|
309
|
+
const profile: NostrProfile = {
|
|
310
|
+
about: '<style>body{background:url("javascript:alert(1)")}</style>',
|
|
311
|
+
};
|
|
312
|
+
const sanitized = sanitizeProfileForDisplay(profile);
|
|
313
|
+
expect(sanitized.about).toContain("<style>");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// Length Boundary Tests
|
|
320
|
+
// ============================================================================
|
|
321
|
+
|
|
322
|
+
describe("profile length boundaries", () => {
|
|
323
|
+
describe("name field (max 256)", () => {
|
|
324
|
+
it("accepts exactly 256 characters", () => {
|
|
325
|
+
const result = validateProfile({ name: "a".repeat(256) });
|
|
326
|
+
expect(result.valid).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects 257 characters", () => {
|
|
330
|
+
const result = validateProfile({ name: "a".repeat(257) });
|
|
331
|
+
expect(result.valid).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("accepts empty string", () => {
|
|
335
|
+
const result = validateProfile({ name: "" });
|
|
336
|
+
expect(result.valid).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("displayName field (max 256)", () => {
|
|
341
|
+
it("accepts exactly 256 characters", () => {
|
|
342
|
+
const result = validateProfile({ displayName: "b".repeat(256) });
|
|
343
|
+
expect(result.valid).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("rejects 257 characters", () => {
|
|
347
|
+
const result = validateProfile({ displayName: "b".repeat(257) });
|
|
348
|
+
expect(result.valid).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("about field (max 2000)", () => {
|
|
353
|
+
it("accepts exactly 2000 characters", () => {
|
|
354
|
+
const result = validateProfile({ about: "c".repeat(2000) });
|
|
355
|
+
expect(result.valid).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects 2001 characters", () => {
|
|
359
|
+
const result = validateProfile({ about: "c".repeat(2001) });
|
|
360
|
+
expect(result.valid).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("URL fields", () => {
|
|
365
|
+
it("accepts long valid HTTPS URLs", () => {
|
|
366
|
+
const longPath = "a".repeat(1000);
|
|
367
|
+
const result = validateProfile({
|
|
368
|
+
picture: `https://example.com/${longPath}.png`,
|
|
369
|
+
});
|
|
370
|
+
expect(result.valid).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("rejects invalid URL format", () => {
|
|
374
|
+
const result = validateProfile({
|
|
375
|
+
picture: "not-a-url",
|
|
376
|
+
});
|
|
377
|
+
expect(result.valid).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("rejects URL without protocol", () => {
|
|
381
|
+
const result = validateProfile({
|
|
382
|
+
picture: "example.com/pic.png",
|
|
383
|
+
});
|
|
384
|
+
expect(result.valid).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ============================================================================
|
|
390
|
+
// Type Confusion Tests
|
|
391
|
+
// ============================================================================
|
|
392
|
+
|
|
393
|
+
describe("profile type confusion", () => {
|
|
394
|
+
it("rejects number as name", () => {
|
|
395
|
+
const result = validateProfile({ name: 123 as unknown as string });
|
|
396
|
+
expect(result.valid).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("rejects array as about", () => {
|
|
400
|
+
const result = validateProfile({ about: ["hello"] as unknown as string });
|
|
401
|
+
expect(result.valid).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("rejects object as picture", () => {
|
|
405
|
+
const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
|
|
406
|
+
expect(result.valid).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("rejects null as name", () => {
|
|
410
|
+
const result = validateProfile({ name: null as unknown as string });
|
|
411
|
+
expect(result.valid).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("rejects boolean as about", () => {
|
|
415
|
+
const result = validateProfile({ about: true as unknown as string });
|
|
416
|
+
expect(result.valid).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("rejects function as name", () => {
|
|
420
|
+
const result = validateProfile({ name: (() => "test") as unknown as string });
|
|
421
|
+
expect(result.valid).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("handles prototype pollution attempt", () => {
|
|
425
|
+
const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
|
|
426
|
+
const result = validateProfile(malicious);
|
|
427
|
+
// Should not pollute Object.prototype
|
|
428
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ============================================================================
|
|
433
|
+
// Event Creation Edge Cases
|
|
434
|
+
// ============================================================================
|
|
435
|
+
|
|
436
|
+
describe("event creation edge cases", () => {
|
|
437
|
+
it("handles profile with all fields at max length", () => {
|
|
438
|
+
const profile: NostrProfile = {
|
|
439
|
+
name: "a".repeat(256),
|
|
440
|
+
displayName: "b".repeat(256),
|
|
441
|
+
about: "c".repeat(2000),
|
|
442
|
+
nip05: "d".repeat(200) + "@example.com",
|
|
443
|
+
lud16: "e".repeat(200) + "@example.com",
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
447
|
+
expect(event.kind).toBe(0);
|
|
448
|
+
|
|
449
|
+
// Content should be parseable JSON
|
|
450
|
+
expect(() => JSON.parse(event.content)).not.toThrow();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("handles rapid sequential events with monotonic timestamps", () => {
|
|
454
|
+
const profile: NostrProfile = { name: "rapid" };
|
|
455
|
+
|
|
456
|
+
// Create events in quick succession
|
|
457
|
+
let lastTimestamp = 0;
|
|
458
|
+
for (let i = 0; i < 100; i++) {
|
|
459
|
+
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
|
|
460
|
+
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
|
461
|
+
lastTimestamp = event.created_at;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("handles JSON special characters in content", () => {
|
|
466
|
+
const profile: NostrProfile = {
|
|
467
|
+
name: 'test"user',
|
|
468
|
+
about: "line1\nline2\ttab\\backslash",
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const event = createProfileEvent(TEST_SK, profile);
|
|
472
|
+
const parsed = JSON.parse(event.content) as { name: string; about: string };
|
|
473
|
+
|
|
474
|
+
expect(parsed.name).toBe('test"user');
|
|
475
|
+
expect(parsed.about).toContain("\n");
|
|
476
|
+
expect(parsed.about).toContain("\t");
|
|
477
|
+
expect(parsed.about).toContain("\\");
|
|
478
|
+
});
|
|
479
|
+
});
|