1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14
15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import { base64UrlEncode, signPayload } from "../auth/utils.ts";
17import * as ServerConfig from "../config.ts";
18import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
19import {
20 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
21 deletePendingAttachment,
22 issueAttachmentUploadUrl,
23 storeAttachmentUpload,
24 validateAttachmentUploadToken,
25} from "./AttachmentUpload.ts";
26
27const layerTest = ServerSecretStore.layer.pipe(
28 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })),
29 Layer.provideMerge(NodeServices.layer),
30);
31
32const uploadInput = {
33 name: "screenshot.png",
34 mimeType: "image/png",
35 sizeBytes: 6,
36} as const;
37
38const LegacyAttachmentUploadClaims = Schema.Struct({
39 version: Schema.Literal(1),
40 kind: Schema.Literal("attachment-upload"),
41 attachmentId: Schema.String,
42 name: Schema.String,
43 mimeType: Schema.String,
44 sizeBytes: Schema.Number,
45 expiresAt: Schema.Number,
46});
47const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect(
48 Schema.fromJsonString(LegacyAttachmentUploadClaims),
49);
50
51describe("AttachmentUpload", () => {
52 it.effect("signs the attachment metadata and validates the upload token", () =>
53 Effect.gen(function* () {
54 const issued = yield* issueAttachmentUploadUrl(uploadInput);
55 expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending");
56
57 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
58 expect(yield* validateAttachmentUploadToken(token)).toMatchObject({
59 kind: "attachment-upload",
60 attachmentId: issued.attachmentId,
61 name: "screenshot.png",
62 mimeType: "image/png",
63 sizeBytes: 6,
64 });
65 }).pipe(Effect.provide(layerTest)),
66 );
67
68 it.effect("rejects tampered and malformed upload tokens", () =>
69 Effect.gen(function* () {
70 const issued = yield* issueAttachmentUploadUrl(uploadInput);
71 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
72 const [payload, signature] = token.split(".");
73
74 expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull();
75 expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull();
76 expect(yield* validateAttachmentUploadToken("garbage")).toBeNull();
77 }).pipe(Effect.provide(layerTest)),
78 );
79
80 it.effect("accepts unexpired image upload tokens issued before file support", () =>
81 Effect.gen(function* () {
82 const issued = yield* issueAttachmentUploadUrl(uploadInput);
83 const secretStore = yield* ServerSecretStore.ServerSecretStore;
84 const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32);
85 const encodedPayload = base64UrlEncode(
86 yield* encodeLegacyAttachmentUploadClaims({
87 version: 1,
88 kind: "attachment-upload",
89 attachmentId: issued.attachmentId,
90 name: uploadInput.name,
91 mimeType: uploadInput.mimeType,
92 sizeBytes: uploadInput.sizeBytes,
93 expiresAt: issued.expiresAt,
94 }),
95 );
96 const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`;
97
98 expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({
99 type: "image",
100 attachmentId: issued.attachmentId,
101 });
102 }).pipe(Effect.provide(layerTest)),
103 );
104
105 it.effect("rejects expired upload tokens", () =>
106 Effect.gen(function* () {
107 const issued = yield* issueAttachmentUploadUrl(uploadInput);
108 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
109
110 yield* TestClock.adjust("11 minutes");
111 expect(yield* validateAttachmentUploadToken(token)).toBeNull();
112 }).pipe(Effect.provide(layerTest)),
113 );
114
115 it.effect("removes expired pending uploads while issuing a new upload URL", () =>
116 Effect.gen(function* () {
117 const config = yield* ServerConfig.ServerConfig;
118 const staleId = "pending-00000000-0000-4000-8000-0000000000cc";
119 const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`);
120 NodeFS.writeFileSync(stalePath, Buffer.from("pixels"));
121 NodeFS.utimesSync(stalePath, 0, 0);
122
123 yield* TestClock.adjust("25 hours");
124 yield* issueAttachmentUploadUrl(uploadInput);
125
126 expect(NodeFS.existsSync(stalePath)).toBe(false);
127 }).pipe(Effect.provide(layerTest)),
128 );
129
130 it.effect("stores the expected bytes without leaving temporary files", () =>
131 Effect.gen(function* () {
132 const config = yield* ServerConfig.ServerConfig;
133 const issued = yield* issueAttachmentUploadUrl(uploadInput);
134 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
135 const claims = yield* validateAttachmentUploadToken(token);
136 if (!claims) {
137 throw new Error("Expected valid upload claims.");
138 }
139
140 expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({
141 ok: false,
142 status: 400,
143 });
144 expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true });
145 expect(
146 NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)),
147 ).toBe(true);
148 expect(
149 NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
150 ).toEqual([]);
151 }).pipe(Effect.provide(layerTest)),
152 );
153
154 it.effect("streams generic files to a path with their original extension", () =>
155 Effect.gen(function* () {
156 const config = yield* ServerConfig.ServerConfig;
157 const issued = yield* issueAttachmentUploadUrl({
158 type: "file",
159 name: "report.PDF",
160 mimeType: "application/pdf",
161 sizeBytes: 6,
162 });
163 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
164 const claims = yield* validateAttachmentUploadToken(token);
165 if (!claims) {
166 throw new Error("Expected valid upload claims.");
167 }
168
169 expect(
170 yield* storeAttachmentUpload(
171 claims,
172 Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])),
173 ),
174 ).toEqual({ ok: true });
175 expect(issued.attachmentId).toMatch(/-pdf$/);
176 expect(
177 NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)),
178 ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6]));
179
180 yield* deletePendingAttachment(issued.attachmentId);
181 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
182 }).pipe(Effect.provide(layerTest)),
183 );
184
185 it.effect("removes partial streamed uploads that exceed their signed size", () =>
186 Effect.gen(function* () {
187 const config = yield* ServerConfig.ServerConfig;
188 const issued = yield* issueAttachmentUploadUrl(uploadInput);
189 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
190 const claims = yield* validateAttachmentUploadToken(token);
191 if (!claims) {
192 throw new Error("Expected valid upload claims.");
193 }
194
195 expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({
196 ok: false,
197 status: 400,
198 });
199 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
200 }).pipe(Effect.provide(layerTest)),
201 );
202
203 it.effect("removes partial streamed uploads when the upload is interrupted", () =>
204 Effect.gen(function* () {
205 const config = yield* ServerConfig.ServerConfig;
206 const issued = yield* issueAttachmentUploadUrl(uploadInput);
207 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
208 const claims = yield* validateAttachmentUploadToken(token);
209 if (!claims) {
210 throw new Error("Expected valid upload claims.");
211 }
212
213 const nextChunkRequested = yield* Deferred.make<void>();
214 const body = Stream.make(new Uint8Array([1, 2, 3])).pipe(
215 Stream.concat(
216 Stream.fromEffect(
217 Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)),
218 ),
219 ),
220 );
221 const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped);
222
223 yield* Deferred.await(nextChunkRequested);
224 expect(
225 NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
226 ).toHaveLength(1);
227
228 yield* Fiber.interrupt(upload);
229 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
230 }).pipe(Effect.provide(layerTest)),
231 );
232
233 it.effect("deletes pending uploads without deleting thread-owned copies", () =>
234 Effect.gen(function* () {
235 const config = yield* ServerConfig.ServerConfig;
236 const uuid = "00000000-0000-4000-8000-0000000000dd";
237 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`);
238 const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`);
239 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
240 NodeFS.writeFileSync(claimedPath, Buffer.from("pixels"));
241
242 yield* deletePendingAttachment(`pending-${uuid}`);
243 yield* deletePendingAttachment(`pending-${uuid}`);
244 yield* deletePendingAttachment(`thread-1-${uuid}`);
245
246 expect(NodeFS.existsSync(pendingPath)).toBe(false);
247 expect(NodeFS.existsSync(claimedPath)).toBe(true);
248 }).pipe(Effect.provide(layerTest)),
249 );
250});