@marktiderman/genesis-chat 0.1.0 → 0.2.0
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 +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/** @feat:chat */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createChatRunner, chatRunnerHandler, type ChatEffect, type ChatNotification, type Transcriber } from "../server";
|
|
4
|
+
import { fakeSupabase } from "./server-fakes";
|
|
5
|
+
|
|
6
|
+
const effect = (id: number, kind: string, payload: Record<string, unknown>, attempts = 1): ChatEffect => ({
|
|
7
|
+
id,
|
|
8
|
+
kind,
|
|
9
|
+
key: `${kind}:${id}`,
|
|
10
|
+
payload,
|
|
11
|
+
attempts,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const target = (userId: string, over: Record<string, unknown> = {}) => ({
|
|
15
|
+
user_id: userId,
|
|
16
|
+
conversation_id: "c1",
|
|
17
|
+
title: null,
|
|
18
|
+
sender_id: "ana",
|
|
19
|
+
preview: "see you at 6",
|
|
20
|
+
mentioned: false,
|
|
21
|
+
unread_total: 3,
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const names: Record<string, string> = { ana: "Ana" };
|
|
26
|
+
|
|
27
|
+
describe("createChatRunner: push", () => {
|
|
28
|
+
it("delivers one notification per message with every recipient, titled by the sender in a direct conversation", async () => {
|
|
29
|
+
const sb = fakeSupabase({
|
|
30
|
+
rpc: {
|
|
31
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1", conversation_id: "c1" })],
|
|
32
|
+
push_targets: () => [target("bo"), target("cy", { mentioned: true, unread_total: 7 })],
|
|
33
|
+
},
|
|
34
|
+
tables: { conversations: [{ id: "c1", kind: "direct" }] },
|
|
35
|
+
});
|
|
36
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
37
|
+
const runner = createChatRunner({ supabase: sb.client, deliver, resolveName: (id) => names[id] });
|
|
38
|
+
expect(await runner.run()).toMatchObject({ claimed: 1, done: 1, failed: 0 });
|
|
39
|
+
expect(deliver).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(deliver.mock.calls[0]![0]).toEqual({
|
|
41
|
+
category: "messages",
|
|
42
|
+
messageId: "m1",
|
|
43
|
+
conversationId: "c1",
|
|
44
|
+
recipients: [
|
|
45
|
+
{ userId: "bo", badge: 3, mentioned: false },
|
|
46
|
+
{ userId: "cy", badge: 7, mentioned: true },
|
|
47
|
+
],
|
|
48
|
+
title: "Ana",
|
|
49
|
+
body: "see you at 6",
|
|
50
|
+
senderId: "ana",
|
|
51
|
+
threadKey: "c1",
|
|
52
|
+
idempotencyKey: "push:m1",
|
|
53
|
+
data: { url: "/messages/c1", conversationId: "c1", messageId: "m1", seq: null },
|
|
54
|
+
mutableContent: false,
|
|
55
|
+
categoryId: "messages",
|
|
56
|
+
});
|
|
57
|
+
// Only the kinds it handles are claimed; the effect is settled as done.
|
|
58
|
+
expect(sb.rpcCalls[0]).toEqual({ fn: "claim_effects", args: { p_kinds: ["push", "transcribe", "storage.remove", "video.remove"], p_limit: 50 } });
|
|
59
|
+
expect(sb.rpcCalls.at(-1)).toEqual({ fn: "complete_effect", args: { p_id: 1, p_error: null } });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("titles a group by its title and prefixes the preview with the sender", async () => {
|
|
63
|
+
const sb = fakeSupabase({
|
|
64
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
65
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
66
|
+
});
|
|
67
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
68
|
+
await createChatRunner({ supabase: sb.client, deliver, resolveName: (id) => names[id] }).run();
|
|
69
|
+
expect(deliver.mock.calls[0]![0]).toMatchObject({ title: "Cohort 4", body: "Ana: see you at 6" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets a collapse key only when the app asks, and always carries the message id and seq", async () => {
|
|
73
|
+
const make = () =>
|
|
74
|
+
fakeSupabase({
|
|
75
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo")] },
|
|
76
|
+
tables: { conversations: [{ id: "c1", kind: "group" }], messages: [{ id: "m1", seq: 42 }] },
|
|
77
|
+
});
|
|
78
|
+
const plain = vi.fn(async (_n: ChatNotification) => {});
|
|
79
|
+
await createChatRunner({ supabase: make().client, deliver: plain }).run();
|
|
80
|
+
const n = plain.mock.calls[0]![0];
|
|
81
|
+
expect("collapseKey" in n).toBe(false);
|
|
82
|
+
expect(n.threadKey).toBe("c1");
|
|
83
|
+
expect(n.data).toMatchObject({ messageId: "m1", seq: 42, conversationId: "c1" });
|
|
84
|
+
|
|
85
|
+
const collapsing = vi.fn(async (_n: ChatNotification) => {});
|
|
86
|
+
await createChatRunner({ supabase: make().client, deliver: collapsing, collapse: true }).run();
|
|
87
|
+
expect(collapsing.mock.calls[0]![0].collapseKey).toBe("chat:c1");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("asks for a push badge without the kinds the app leaves out of its own", async () => {
|
|
91
|
+
const sb = fakeSupabase({
|
|
92
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
93
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
94
|
+
});
|
|
95
|
+
await createChatRunner({ supabase: sb.client, deliver: vi.fn(async () => {}), badgeExcludeKinds: ["support"] }).run();
|
|
96
|
+
expect(sb.rpcCalls.find((c) => c.fn === "push_targets")?.args).toEqual({ p_message_id: "m1", p_badge_exclude_kinds: ["support"] });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("builds an iOS rich push: the sender's name and photo, the group, and the message ids", async () => {
|
|
100
|
+
const conv = "0a1b2c3d-0000-4000-8000-000000000001";
|
|
101
|
+
const sb = fakeSupabase({
|
|
102
|
+
rpc: {
|
|
103
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1" })],
|
|
104
|
+
push_targets: () => [{ ...target("bo", { title: "Cohort 4" }), conversation_id: conv }],
|
|
105
|
+
},
|
|
106
|
+
tables: { conversations: [{ id: conv, kind: "group" }] },
|
|
107
|
+
});
|
|
108
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
109
|
+
await createChatRunner({
|
|
110
|
+
supabase: sb.client,
|
|
111
|
+
deliver,
|
|
112
|
+
resolveName: (id) => names[id],
|
|
113
|
+
resolveAvatar: () => "https://cdn.example.test/ana.jpg",
|
|
114
|
+
}).run();
|
|
115
|
+
const n = deliver.mock.calls[0]![0];
|
|
116
|
+
expect(n.mutableContent).toBe(true);
|
|
117
|
+
expect(n.categoryId).toBe("messages");
|
|
118
|
+
expect(n.data).toMatchObject({ conversationId: conv, messageId: "m1", senderId: "ana", senderName: "Ana", senderAvatar: "https://cdn.example.test/ana.jpg", groupName: "Cohort 4" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("opens the app's own route when a notification is tapped", async () => {
|
|
122
|
+
const sb = fakeSupabase({
|
|
123
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
124
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
125
|
+
});
|
|
126
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
127
|
+
await createChatRunner({ supabase: sb.client, deliver, linkFor: (c, m) => `/inbox?c=${c}&m=${m}` }).run();
|
|
128
|
+
expect(deliver.mock.calls[0]![0].data.url).toBe("/inbox?c=c1&m=m1");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("settles a message with no targets as done without delivering", async () => {
|
|
132
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [] } });
|
|
133
|
+
const deliver = vi.fn(async () => {});
|
|
134
|
+
expect(await createChatRunner({ supabase: sb.client, deliver }).run()).toMatchObject({ claimed: 1, done: 1, failed: 0 });
|
|
135
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("settles a failed delivery with its error, and keeps working the others", async () => {
|
|
139
|
+
const sb = fakeSupabase({
|
|
140
|
+
rpc: {
|
|
141
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1" }), effect(2, "push", { message_id: "m2" }), effect(3, "storage.remove", { bucket: "genesis-chat", path: "c/u/a.jpg" })],
|
|
142
|
+
push_targets: (a) => [target("bo", { conversation_id: String(a.p_message_id) })],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const deliver = vi.fn(async (n: ChatNotification) => {
|
|
146
|
+
if (n.messageId === "m1") throw new Error("push service down");
|
|
147
|
+
});
|
|
148
|
+
expect(await createChatRunner({ supabase: sb.client, deliver }).run()).toMatchObject({ claimed: 3, done: 2, failed: 1 });
|
|
149
|
+
const settled = sb.rpcCalls.filter((c) => c.fn === "complete_effect").map((c) => c.args);
|
|
150
|
+
expect(settled).toContainEqual({ p_id: 1, p_error: "push service down" });
|
|
151
|
+
expect(settled).toContainEqual({ p_id: 2, p_error: null });
|
|
152
|
+
expect(settled).toContainEqual({ p_id: 3, p_error: null });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("caps effects of one kind in flight", async () => {
|
|
156
|
+
let inFlight = 0;
|
|
157
|
+
let most = 0;
|
|
158
|
+
const sb = fakeSupabase({
|
|
159
|
+
rpc: {
|
|
160
|
+
claim_effects: () => Array.from({ length: 10 }, (_, i) => effect(i, "push", { message_id: `m${i}` })),
|
|
161
|
+
push_targets: () => [target("bo")],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const deliver = async () => {
|
|
165
|
+
most = Math.max(most, ++inFlight);
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
167
|
+
inFlight--;
|
|
168
|
+
};
|
|
169
|
+
const report = await createChatRunner({ supabase: sb.client, deliver, batch: { concurrency: 3, limit: 10 } }).run();
|
|
170
|
+
expect(report.done).toBe(10);
|
|
171
|
+
expect(most).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("claims only the kinds it was given", async () => {
|
|
175
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [] } });
|
|
176
|
+
await createChatRunner({ supabase: sb.client, deliver: async () => {}, kinds: ["transcribe"] }).run();
|
|
177
|
+
expect(sb.rpcCalls[0]!.args.p_kinds).toEqual(["transcribe"]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("createChatRunner: storage.remove and video.remove", () => {
|
|
182
|
+
it("removes the file, and counts not found as done", async () => {
|
|
183
|
+
const ok = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "storage.remove", { bucket: "genesis-chat", path: "c/u/a.jpg" })] } });
|
|
184
|
+
expect(await createChatRunner({ supabase: ok.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
185
|
+
expect(ok.removed).toEqual([{ bucket: "genesis-chat", paths: ["c/u/a.jpg"] }]);
|
|
186
|
+
|
|
187
|
+
const gone = fakeSupabase({
|
|
188
|
+
rpc: { claim_effects: () => [effect(1, "storage.remove", { path: "c/u/b.jpg" })] },
|
|
189
|
+
removeError: { message: "Object not found", statusCode: "404" },
|
|
190
|
+
});
|
|
191
|
+
expect(await createChatRunner({ supabase: gone.client, deliver: async () => {} }).run()).toMatchObject({ done: 1, failed: 0 });
|
|
192
|
+
|
|
193
|
+
const down = fakeSupabase({
|
|
194
|
+
rpc: { claim_effects: () => [effect(1, "storage.remove", { path: "c/u/b.jpg" })] },
|
|
195
|
+
removeError: { message: "Internal", statusCode: "500" },
|
|
196
|
+
});
|
|
197
|
+
expect(await createChatRunner({ supabase: down.client, deliver: async () => {} }).run()).toMatchObject({ done: 0, failed: 1 });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("removes a hosted video through the adapter, and is done without one", async () => {
|
|
201
|
+
const remove = vi.fn(async () => {});
|
|
202
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "video.remove", { provider: "mux", provider_id: "asset1" })] } });
|
|
203
|
+
await createChatRunner({ supabase: sb.client, deliver: async () => {}, video: { name: "mux", remove } }).run();
|
|
204
|
+
expect(remove).toHaveBeenCalledWith("asset1");
|
|
205
|
+
const none = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "video.remove", { provider: "mux", provider_id: "asset1" })] } });
|
|
206
|
+
expect(await createChatRunner({ supabase: none.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("createChatRunner: transcribe", () => {
|
|
211
|
+
const voice = { id: "a1", kind: "voice", provider: "storage", path: "c/u/v.m4a", mime: "audio/mp4", duration_ms: 4200, transcript_status: "pending" };
|
|
212
|
+
|
|
213
|
+
it("signs a 10 minute URL, transcribes it and stores the transcript", async () => {
|
|
214
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" })] }, tables: { attachments: [voice] } });
|
|
215
|
+
const transcribe = vi.fn(async () => ({ text: "Running late, sorry" }));
|
|
216
|
+
const transcriber: Transcriber = { name: "fake", transcribe };
|
|
217
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ done: 1 });
|
|
218
|
+
expect(sb.signed).toEqual(["c/u/v.m4a@600"]);
|
|
219
|
+
expect(transcribe).toHaveBeenCalledWith({ url: "https://files.test/c/u/v.m4a?token=x", mime: "audio/mp4", durationMs: 4200, language: undefined });
|
|
220
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
221
|
+
fn: "update_attachment",
|
|
222
|
+
args: { p_attachment_id: "a1", p_patch: { transcript: "Running late, sorry", transcript_status: "ready" } },
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("turns transcripts off without a transcriber", async () => {
|
|
227
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" })] }, tables: { attachments: [voice] } });
|
|
228
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
229
|
+
expect(sb.rpcCalls).toContainEqual({ fn: "update_attachment", args: { p_attachment_id: "a1", p_patch: { transcript_status: "off" } } });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("retries a provider failure, and marks it failed on the last attempt", async () => {
|
|
233
|
+
const transcriber: Transcriber = { name: "fake", transcribe: async () => Promise.reject(new Error("Deepgram answered HTTP 503")) };
|
|
234
|
+
const early = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" }, 3)] }, tables: { attachments: [voice] } });
|
|
235
|
+
expect(await createChatRunner({ supabase: early.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ failed: 1 });
|
|
236
|
+
expect(early.rpcCalls.filter((c) => c.fn === "update_attachment")).toEqual([]);
|
|
237
|
+
expect(early.rpcCalls).toContainEqual({ fn: "complete_effect", args: { p_id: 1, p_error: "Deepgram answered HTTP 503" } });
|
|
238
|
+
|
|
239
|
+
const last = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" }, 8)] }, tables: { attachments: [voice] } });
|
|
240
|
+
expect(await createChatRunner({ supabase: last.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ failed: 1 });
|
|
241
|
+
expect(last.rpcCalls).toContainEqual({ fn: "update_attachment", args: { p_attachment_id: "a1", p_patch: { transcript_status: "failed" } } });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("leaves a hosted video's transcript to its provider, and a deleted attachment alone", async () => {
|
|
245
|
+
const sb = fakeSupabase({
|
|
246
|
+
rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "v1" }), effect(2, "transcribe", { attachment_id: "gone" })] },
|
|
247
|
+
tables: { attachments: [{ ...voice, id: "v1", kind: "video", provider: "mux", path: null }] },
|
|
248
|
+
});
|
|
249
|
+
const transcribe = vi.fn();
|
|
250
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {}, transcriber: { name: "fake", transcribe } }).run()).toMatchObject({ done: 2 });
|
|
251
|
+
expect(transcribe).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("chatRunnerHandler", () => {
|
|
256
|
+
const secret = "s".repeat(40);
|
|
257
|
+
const runner = { run: vi.fn(async () => ({ claimed: 2, done: 2, failed: 0 })) };
|
|
258
|
+
const handler = chatRunnerHandler({ runner, secret });
|
|
259
|
+
|
|
260
|
+
it("rejects a missing or wrong secret without running", async () => {
|
|
261
|
+
expect((await handler(new Request("https://app.test/api/chat-runner", { method: "POST" }))).status).toBe(401);
|
|
262
|
+
const wrong = await handler(new Request("https://app.test/api/chat-runner", { method: "POST", headers: { authorization: `Bearer ${"t".repeat(40)}` } }));
|
|
263
|
+
expect(wrong.status).toBe(401);
|
|
264
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("runs with the right secret and answers the counts", async () => {
|
|
268
|
+
const res = await handler(new Request("https://app.test/api/chat-runner", { method: "POST", headers: { authorization: `Bearer ${secret}` } }));
|
|
269
|
+
expect(res.status).toBe(200);
|
|
270
|
+
expect(await res.json()).toEqual({ claimed: 2, done: 2, failed: 0 });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("refuses everyone when the secret is too short", async () => {
|
|
274
|
+
const weak = chatRunnerHandler({ runner, secret: "short" });
|
|
275
|
+
expect((await weak(new Request("https://app.test/x", { method: "POST", headers: { authorization: "Bearer short" } }))).status).toBe(500);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/** @feat:chat Moderation mapping and the SSRF-safe link unfurler. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { carriesCredential, createLinkUnfurler, guardedLookup, isPrivateAddress, moderationVerdict, openAiModerator, parseOpenGraph } from "../server";
|
|
4
|
+
import { fakeFetch, jsonResponse } from "./server-fakes";
|
|
5
|
+
|
|
6
|
+
describe("moderationVerdict", () => {
|
|
7
|
+
it("maps scores to clean, flagged and hidden with default thresholds", () => {
|
|
8
|
+
expect(moderationVerdict({ harassment: 0.1, violence: 0.2 })).toEqual({ result: "clean" });
|
|
9
|
+
expect(moderationVerdict({ harassment: 0.6 })).toEqual({ result: "flagged", reason: "Automatic moderation: harassment 0.60" });
|
|
10
|
+
expect(moderationVerdict({ harassment: 0.95, violence: 0.55 })).toEqual({
|
|
11
|
+
result: "hidden",
|
|
12
|
+
reason: "Automatic moderation: harassment 0.95, violence 0.55",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is stricter for sexual/minors and softer to flag self-harm by default", () => {
|
|
17
|
+
expect(moderationVerdict({ "sexual/minors": 0.55 }).result).toBe("hidden");
|
|
18
|
+
expect(moderationVerdict({ "self-harm/intent": 0.35 }).result).toBe("flagged");
|
|
19
|
+
expect(moderationVerdict({ "self-harm/intent": 0.92 }).result).toBe("flagged");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("takes configured thresholds, and the provider's flag", () => {
|
|
23
|
+
expect(moderationVerdict({ harassment: 0.6 }, { thresholds: { flag: 0.7 } }).result).toBe("clean");
|
|
24
|
+
expect(moderationVerdict({ violence: 0.6 }, { categories: { violence: { hide: 0.5 } } }).result).toBe("hidden");
|
|
25
|
+
expect(moderationVerdict({ harassment: 0.1 }, { flagged: true }).result).toBe("flagged");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("openAiModerator", () => {
|
|
30
|
+
it("sends text with the first photo, further photos alone, and keeps the highest scores", async () => {
|
|
31
|
+
const f = fakeFetch((_url, init) => {
|
|
32
|
+
const body = JSON.parse(String(init!.body)) as { input: { type: string }[] };
|
|
33
|
+
const withText = body.input.some((i) => i.type === "text");
|
|
34
|
+
return jsonResponse({ results: [{ flagged: false, category_scores: withText ? { harassment: 0.2, violence: 0.1 } : { violence: 0.93 } }] });
|
|
35
|
+
});
|
|
36
|
+
const verdict = await openAiModerator({ apiKey: "sk-test", fetch: f.fetch }).moderate({ text: "hi", imageUrls: ["https://f.test/1", "https://f.test/2"] });
|
|
37
|
+
expect(verdict).toEqual({ result: "hidden", reason: "Automatic moderation: violence 0.93" });
|
|
38
|
+
expect(f.calls).toHaveLength(2);
|
|
39
|
+
expect(f.calls[0]!.url).toBe("https://api.openai.com/v1/moderations");
|
|
40
|
+
expect((f.calls[0]!.init!.headers as Record<string, string>).authorization).toBe("Bearer sk-test");
|
|
41
|
+
expect(JSON.parse(String(f.calls[0]!.init!.body))).toEqual({
|
|
42
|
+
model: "omni-moderation-latest",
|
|
43
|
+
input: [
|
|
44
|
+
{ type: "text", text: "hi" },
|
|
45
|
+
{ type: "image_url", image_url: { url: "https://f.test/1" } },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws on an HTTP error so the effect retries", async () => {
|
|
51
|
+
const m = openAiModerator({ apiKey: "k", fetch: fakeFetch(() => new Response("no", { status: 429 })).fetch });
|
|
52
|
+
await expect(m.moderate({ text: "x", imageUrls: [] })).rejects.toThrow("HTTP 429");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("isPrivateAddress", () => {
|
|
57
|
+
it.each([
|
|
58
|
+
"127.0.0.1",
|
|
59
|
+
"10.1.2.3",
|
|
60
|
+
"172.16.0.1",
|
|
61
|
+
"192.168.1.1",
|
|
62
|
+
"169.254.169.254",
|
|
63
|
+
"100.64.0.1",
|
|
64
|
+
"0.0.0.0",
|
|
65
|
+
"224.0.0.1",
|
|
66
|
+
"::1",
|
|
67
|
+
"::",
|
|
68
|
+
"[::1]",
|
|
69
|
+
"fe80::1",
|
|
70
|
+
"fd00:ec2::254",
|
|
71
|
+
"::ffff:127.0.0.1",
|
|
72
|
+
"::ffff:7f00:1",
|
|
73
|
+
"64:ff9b::a9fe:a9fe",
|
|
74
|
+
"::ffff:0:7f00:1", // IPv4-translated 127.0.0.1
|
|
75
|
+
"::ffff:0:a00:1", // IPv4-translated 10.0.0.1
|
|
76
|
+
"2002:7f00:1::", // 6to4 of 127.0.0.1
|
|
77
|
+
"2002:a9fe:a9fe::1", // 6to4 of 169.254.169.254
|
|
78
|
+
"2001::1", // Teredo
|
|
79
|
+
"64:ff9b:1::1", // local-use NAT64
|
|
80
|
+
"3fff::1", // documentation
|
|
81
|
+
"192.88.99.1", // 6to4 relay
|
|
82
|
+
"not-an-ip",
|
|
83
|
+
])("blocks %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
|
|
84
|
+
|
|
85
|
+
it.each(["93.184.216.34", "1.1.1.1", "2606:4700:4700::1111", "::ffff:93.184.216.34", "2002:5db8:d822::1", "2001:4860:4860::8888"])("allows %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const html = (head: string, extra = "") => `<!doctype html><html><head>${head}</head><body>${extra}</body></html>`;
|
|
89
|
+
const page = (body: string, headers: Record<string, string> = {}) =>
|
|
90
|
+
new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", ...headers } });
|
|
91
|
+
const publicDns = async () => ["93.184.216.34"];
|
|
92
|
+
|
|
93
|
+
describe("createLinkUnfurler", () => {
|
|
94
|
+
it("parses Open Graph data with entities, caps and an https image", async () => {
|
|
95
|
+
const f = fakeFetch(() =>
|
|
96
|
+
page(
|
|
97
|
+
html(
|
|
98
|
+
`<title>Fallback</title><meta property="og:title" content="Tom & Jerry's 😀 plan">` +
|
|
99
|
+
`<meta content='Week 3\n of 12' property='og:description'><meta property="og:image" content="/img/cover.png">` +
|
|
100
|
+
`<meta property="og:site_name" content="Coach Co">`,
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const preview = await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl("https://coach.example/plan");
|
|
105
|
+
expect(preview).toEqual({
|
|
106
|
+
url: "https://coach.example/plan",
|
|
107
|
+
status: "ok",
|
|
108
|
+
title: "Tom & Jerry's \u{1F600} plan",
|
|
109
|
+
description: "Week 3 of 12",
|
|
110
|
+
imageUrl: "https://coach.example/img/cover.png",
|
|
111
|
+
siteName: "Coach Co",
|
|
112
|
+
});
|
|
113
|
+
expect(f.calls[0]!.init!.redirect).toBe("manual");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("falls back to <title> and meta description, caps lengths and drops a non-https image", () => {
|
|
117
|
+
const og = parseOpenGraph(
|
|
118
|
+
html(`<title> A page </title><meta name="description" content="${"d".repeat(700)}"><meta property="og:image" content="http://x.example/i.png">`),
|
|
119
|
+
"https://x.example/",
|
|
120
|
+
);
|
|
121
|
+
expect(og.title).toBe("A page");
|
|
122
|
+
expect(og.description).toHaveLength(600);
|
|
123
|
+
expect(og.imageUrl).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("ignores tags outside the head and inside scripts or comments", () => {
|
|
127
|
+
const og = parseOpenGraph(
|
|
128
|
+
`<head><!-- <meta property="og:title" content="comment"> --><script>var s='<meta property="og:title" content="script">'</script>` +
|
|
129
|
+
`<meta property="og:title" content="real"></head><body><meta property="og:description" content="body"></body>`,
|
|
130
|
+
"https://x.example/",
|
|
131
|
+
);
|
|
132
|
+
expect(og).toMatchObject({ title: "real", description: null });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Each of these took seconds when every unclosed start rescanned to the end of the page.
|
|
136
|
+
it.each([
|
|
137
|
+
["unclosed scripts", "<script "],
|
|
138
|
+
["unclosed comments", "<!--"],
|
|
139
|
+
["unclosed metas", "<meta "],
|
|
140
|
+
["unclosed titles", "<title>"],
|
|
141
|
+
["an attribute's open quotes", "<meta x='"],
|
|
142
|
+
["a long attribute name", "a"],
|
|
143
|
+
["unclosed tags in a title", "<title>" + "<".repeat(1000)],
|
|
144
|
+
])("parses a 512 KiB page of %s in linear time", (_, unit) => {
|
|
145
|
+
const page = unit.repeat(Math.ceil((512 * 1024) / unit.length));
|
|
146
|
+
const started = performance.now();
|
|
147
|
+
parseOpenGraph(unit === "a" ? `<meta ${page}>` : page, "https://x.example/");
|
|
148
|
+
expect(performance.now() - started).toBeLessThan(500);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("still reads a title and an unclosed script runs to the end", () => {
|
|
152
|
+
expect(parseOpenGraph(`<title>Hi</title><script>var x = 1;<meta property="og:title" content="no">`, "https://x.example/").title).toBe("Hi");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each([
|
|
156
|
+
["http://coach.example/", "blocked"],
|
|
157
|
+
["https://127.0.0.1/", "blocked"],
|
|
158
|
+
["https://[::1]/", "blocked"],
|
|
159
|
+
["https://169.254.169.254/latest/meta-data", "blocked"],
|
|
160
|
+
["https://2130706433/", "blocked"],
|
|
161
|
+
["https://localhost/", "blocked"],
|
|
162
|
+
["https://metadata.google.internal/", "blocked"],
|
|
163
|
+
["https://intranet/", "blocked"],
|
|
164
|
+
["https://user:pw@coach.example/", "blocked"],
|
|
165
|
+
["https://coach.example:8443/", "blocked"],
|
|
166
|
+
])("refuses %s without fetching", async (url, status) => {
|
|
167
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
168
|
+
expect((await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl(url)).status).toBe(status);
|
|
169
|
+
expect(f.calls).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("refuses a name that resolves to a private address", async () => {
|
|
173
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
174
|
+
const u = createLinkUnfurler({ fetch: f.fetch, resolve: async () => ["93.184.216.34", "10.0.0.5"] });
|
|
175
|
+
expect((await u.unfurl("https://sneaky.example/")).status).toBe("blocked");
|
|
176
|
+
expect(f.calls).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("checks every redirect hop, and refuses a redirect into a private address", async () => {
|
|
180
|
+
const f = fakeFetch((url) =>
|
|
181
|
+
url === "https://coach.example/a"
|
|
182
|
+
? new Response(null, { status: 302, headers: { location: "https://internal.example/admin" } })
|
|
183
|
+
: page(html("<title>secret</title>")),
|
|
184
|
+
);
|
|
185
|
+
const resolve = async (host: string) => (host === "internal.example" ? ["192.168.0.10"] : ["93.184.216.34"]);
|
|
186
|
+
expect((await createLinkUnfurler({ fetch: f.fetch, resolve }).unfurl("https://coach.example/a")).status).toBe("blocked");
|
|
187
|
+
expect(f.calls.map((c) => c.url)).toEqual(["https://coach.example/a"]);
|
|
188
|
+
|
|
189
|
+
const literal = fakeFetch(() => new Response(null, { status: 301, headers: { location: "https://169.254.169.254/" } }));
|
|
190
|
+
expect((await createLinkUnfurler({ fetch: literal.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("blocked");
|
|
191
|
+
const downgrade = fakeFetch(() => new Response(null, { status: 301, headers: { location: "http://coach.example/" } }));
|
|
192
|
+
expect((await createLinkUnfurler({ fetch: downgrade.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("blocked");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("follows at most 3 redirects", async () => {
|
|
196
|
+
let n = 0;
|
|
197
|
+
const f = fakeFetch(() => new Response(null, { status: 302, headers: { location: `/hop${++n}` } }));
|
|
198
|
+
expect((await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("failed");
|
|
199
|
+
expect(f.calls).toHaveLength(4);
|
|
200
|
+
|
|
201
|
+
let m = 0;
|
|
202
|
+
const ok = fakeFetch(() => (++m <= 3 ? new Response(null, { status: 302, headers: { location: `/hop${m}` } }) : page(html("<title>Here</title>"))));
|
|
203
|
+
expect(await createLinkUnfurler({ fetch: ok.fetch, resolve: publicDns }).unfurl("https://coach.example/")).toMatchObject({ status: "ok", title: "Here" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("fails on non-HTML, a declared oversize body, an HTTP error or a network error", async () => {
|
|
207
|
+
const run = (respond: () => Response | Promise<Response>, maxBytes?: number) =>
|
|
208
|
+
createLinkUnfurler({ fetch: fakeFetch(respond).fetch, resolve: publicDns, ...(maxBytes ? { maxBytes } : {}) }).unfurl("https://coach.example/");
|
|
209
|
+
expect((await run(() => new Response("%PDF", { headers: { "content-type": "application/pdf" } }))).status).toBe("failed");
|
|
210
|
+
expect((await run(() => new Response("{}", { headers: { "content-type": "application/json" } }))).status).toBe("failed");
|
|
211
|
+
expect((await run(() => page(html("<title>x</title>"), { "content-length": "999999" }), 1000)).status).toBe("failed");
|
|
212
|
+
expect((await run(() => new Response("gone", { status: 404, headers: { "content-type": "text/html" } }))).status).toBe("failed");
|
|
213
|
+
expect((await run(() => Promise.reject(new TypeError("fetch failed")))).status).toBe("failed");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("reads no more than maxBytes of an undeclared body", async () => {
|
|
217
|
+
let pulled = 0;
|
|
218
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
219
|
+
pull(controller) {
|
|
220
|
+
pulled++;
|
|
221
|
+
controller.enqueue(new TextEncoder().encode(pulled === 1 ? '<head><meta property="og:title" content="Top"></head>' : "x".repeat(1000)));
|
|
222
|
+
if (pulled > 1000) controller.close();
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const f = fakeFetch(() => new Response(stream, { headers: { "content-type": "text/html" } }));
|
|
226
|
+
expect(await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns, maxBytes: 4096 }).unfurl("https://coach.example/")).toMatchObject({ status: "ok", title: "Top" });
|
|
227
|
+
expect(pulled).toBeLessThan(10);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("without a resolver, refuses every named host (logged once) unless allowUnresolved", async () => {
|
|
231
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
232
|
+
const log = vi.fn();
|
|
233
|
+
const u = createLinkUnfurler({ fetch: f.fetch, resolve: async () => null, log });
|
|
234
|
+
expect((await u.unfurl("https://10.0.0.1/")).status).toBe("blocked");
|
|
235
|
+
expect((await u.unfurl("https://localtest.me/")).status).toBe("blocked");
|
|
236
|
+
expect((await u.unfurl("https://coach.example/")).status).toBe("blocked");
|
|
237
|
+
expect(f.calls).toHaveLength(0);
|
|
238
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(log.mock.calls[0]![0]).toContain("allowUnresolved");
|
|
240
|
+
|
|
241
|
+
const opted = createLinkUnfurler({ fetch: f.fetch, resolve: async () => null, allowUnresolved: true, log });
|
|
242
|
+
expect((await opted.unfurl("https://coach.example/")).status).toBe("ok");
|
|
243
|
+
expect((await opted.unfurl("https://10.0.0.1/")).status).toBe("blocked");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("times out a DNS lookup that hangs", async () => {
|
|
247
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
248
|
+
const u = createLinkUnfurler({ fetch: f.fetch, resolve: () => new Promise<string[]>(() => {}), timeoutMs: 50 });
|
|
249
|
+
expect((await u.unfurl("https://slow.example/")).status).toBe("failed");
|
|
250
|
+
expect(f.calls).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("never fetches a link whose query carries a credential", async () => {
|
|
254
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
255
|
+
const u = createLinkUnfurler({ fetch: f.fetch, resolve: publicDns });
|
|
256
|
+
for (const url of ["https://app.example/reset?token=abc", "https://s3.example/f?X-Amz-Signature=1&a=b", "https://app.example/login?a=1&code=xyz"]) {
|
|
257
|
+
expect((await u.unfurl(url)).status).toBe("blocked");
|
|
258
|
+
}
|
|
259
|
+
expect(f.calls).toHaveLength(0);
|
|
260
|
+
expect(carriesCredential("https://app.example/page?ref=newsletter")).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("drops an og:image whose host is private or local", async () => {
|
|
264
|
+
const run = async (image: string, resolve: (host: string) => Promise<string[]> = publicDns) =>
|
|
265
|
+
(await createLinkUnfurler({ fetch: fakeFetch(() => page(html(`<title>T</title><meta property="og:image" content="${image}">`))).fetch, resolve }).unfurl(
|
|
266
|
+
"https://coach.example/",
|
|
267
|
+
)).imageUrl;
|
|
268
|
+
expect(await run("https://10.0.0.1/i.png")).toBeNull();
|
|
269
|
+
expect(await run("https://intranet.local/i.png")).toBeNull();
|
|
270
|
+
expect(await run("https://cdn.example/i.png", async (h) => (h === "cdn.example" ? ["192.168.1.9"] : ["93.184.216.34"]))).toBeNull();
|
|
271
|
+
expect(await run("https://cdn.example/i.png?sig=abc")).toBe("https://cdn.example/i.png?sig=abc");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("passes a dispatcher to fetch, and guardedLookup refuses a private answer at connect time", async () => {
|
|
275
|
+
const f = fakeFetch(() => page(html("<title>x</title>")));
|
|
276
|
+
const dispatcher = { pinned: true };
|
|
277
|
+
await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns, dispatcher }).unfurl("https://coach.example/");
|
|
278
|
+
expect((f.calls[0]!.init as RequestInit & { dispatcher?: unknown }).dispatcher).toBe(dispatcher);
|
|
279
|
+
|
|
280
|
+
const lookup = (host: string, all: boolean) =>
|
|
281
|
+
new Promise<{ error: Error | null; address?: unknown }>((resolve) =>
|
|
282
|
+
guardedLookup(host, { all }, (error, address) => resolve({ error, address })),
|
|
283
|
+
);
|
|
284
|
+
const local = await lookup("localhost", false);
|
|
285
|
+
expect(local.error?.message).toContain("private address");
|
|
286
|
+
const ip = await lookup("1.1.1.1", true);
|
|
287
|
+
expect(ip.error).toBeNull();
|
|
288
|
+
expect(ip.address).toEqual([{ address: "1.1.1.1", family: 4 }]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* @feat:chat
|
|
4
|
+
*/
|
|
5
|
+
import { cleanup, render } from "@testing-library/react";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { ChatMarkdown, isSafeHref, parseMarkdown } from "../web/markdown";
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
|
|
11
|
+
const UUID = "3f2b8c1e-0d4a-4c6b-9e1f-2a3b4c5d6e7f";
|
|
12
|
+
|
|
13
|
+
describe("parseMarkdown", () => {
|
|
14
|
+
it("reads bold, italic, code, links, mentions and line breaks", () => {
|
|
15
|
+
expect(parseMarkdown(`**hi** *there* _you_ \`x<y\` [site](https://example.com) @[Sam Lee](user:${UUID})\nnext`)).toEqual([
|
|
16
|
+
{ type: "bold", children: [{ type: "text", text: "hi" }] },
|
|
17
|
+
{ type: "text", text: " " },
|
|
18
|
+
{ type: "italic", children: [{ type: "text", text: "there" }] },
|
|
19
|
+
{ type: "text", text: " " },
|
|
20
|
+
{ type: "italic", children: [{ type: "text", text: "you" }] },
|
|
21
|
+
{ type: "text", text: " " },
|
|
22
|
+
{ type: "code", text: "x<y" },
|
|
23
|
+
{ type: "text", text: " " },
|
|
24
|
+
{ type: "link", href: "https://example.com", children: [{ type: "text", text: "site" }] },
|
|
25
|
+
{ type: "text", text: " " },
|
|
26
|
+
{ type: "mention", name: "Sam Lee", userId: UUID },
|
|
27
|
+
{ type: "break" },
|
|
28
|
+
{ type: "text", text: "next" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("leaves snake_case, lone stars and unclosed markers as written", () => {
|
|
33
|
+
expect(parseMarkdown("my_var_name and 2 * 3 and **open")).toEqual([{ type: "text", text: "my_var_name and 2 * 3 and **open" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("links bare https URLs without their trailing punctuation", () => {
|
|
37
|
+
expect(parseMarkdown("see https://example.com/a?b=1.")).toEqual([
|
|
38
|
+
{ type: "text", text: "see " },
|
|
39
|
+
{ type: "link", href: "https://example.com/a?b=1", children: [{ type: "text", text: "https://example.com/a?b=1" }] },
|
|
40
|
+
{ type: "text", text: "." },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps a link's words but drops a javascript:, data: or relative target", () => {
|
|
45
|
+
for (const href of ["javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,<b>x</b>", "/relative", "vbscript:x"]) {
|
|
46
|
+
const nodes = parseMarkdown(`[click](${href})`);
|
|
47
|
+
expect(nodes.some((n) => n.type === "link")).toBe(false);
|
|
48
|
+
expect(nodes).toEqual([{ type: "text", text: "click" }]);
|
|
49
|
+
}
|
|
50
|
+
expect(isSafeHref("https://ok.example")).toBe(true);
|
|
51
|
+
expect(isSafeHref("http://ok.example")).toBe(true);
|
|
52
|
+
expect(isSafeHref("javascript:alert(1)")).toBe(false);
|
|
53
|
+
expect(isSafeHref("mailto:a@b.c")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("honours backslash escapes", () => {
|
|
57
|
+
expect(parseMarkdown("\\*not italic\\*")).toEqual([{ type: "text", text: "*not italic*" }]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("ChatMarkdown", () => {
|
|
62
|
+
it("renders HTML in a message as text, never as elements", () => {
|
|
63
|
+
const { container } = render(<ChatMarkdown text={'<img src=x onerror="alert(1)"><script>alert(2)</script> **<b>bold</b>**'} />);
|
|
64
|
+
expect(container.querySelector("img")).toBeNull();
|
|
65
|
+
expect(container.querySelector("script")).toBeNull();
|
|
66
|
+
expect(container.querySelector("b")).toBeNull();
|
|
67
|
+
expect(container.textContent).toContain('<img src=x onerror="alert(1)">');
|
|
68
|
+
expect(container.querySelector("strong")?.textContent).toBe("<b>bold</b>");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens links in a new tab with noopener noreferrer, and never a javascript: one", () => {
|
|
72
|
+
const { container } = render(<ChatMarkdown text="[ok](https://example.com) [bad](javascript:alert(1)) https://x.example" />);
|
|
73
|
+
const links = [...container.querySelectorAll("a")];
|
|
74
|
+
expect(links.map((a) => a.getAttribute("href"))).toEqual(["https://example.com", "https://x.example"]);
|
|
75
|
+
for (const a of links) {
|
|
76
|
+
expect(a.getAttribute("target")).toBe("_blank");
|
|
77
|
+
expect(a.getAttribute("rel")).toBe("noopener noreferrer");
|
|
78
|
+
}
|
|
79
|
+
expect(container.textContent).toContain("bad");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("draws mentions as a chip, and line breaks as <br>", () => {
|
|
83
|
+
const { container } = render(<ChatMarkdown text={`hey @[Sam](user:${UUID})\nthere`} me={UUID} />);
|
|
84
|
+
const chip = container.querySelector(`[data-mention="${UUID}"]`);
|
|
85
|
+
expect(chip?.textContent).toBe("@Sam");
|
|
86
|
+
expect(container.querySelectorAll("br")).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
});
|