@@ -4,11 +4,16 @@ import * as NodePath from "node:path";
44
55import * as NodeServices from "@effect/platform-node/NodeServices" ;
66import { describe , expect , it } from "@effect/vitest" ;
7+ import * as Deferred from "effect/Deferred" ;
78import * as Effect from "effect/Effect" ;
9+ import * as Fiber from "effect/Fiber" ;
810import * as Layer from "effect/Layer" ;
11+ import * as Schema from "effect/Schema" ;
12+ import * as Stream from "effect/Stream" ;
913import * as TestClock from "effect/testing/TestClock" ;
1014
1115import * as ServerSecretStore from "../auth/ServerSecretStore.ts" ;
16+ import { base64UrlEncode , signPayload } from "../auth/utils.ts" ;
1217import * as ServerConfig from "../config.ts" ;
1318import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts" ;
1419import {
@@ -30,6 +35,19 @@ const uploadInput = {
3035 sizeBytes : 6 ,
3136} as const ;
3237
38+ const 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+ } ) ;
47+ const encodeLegacyAttachmentUploadClaims = Schema . encodeEffect (
48+ Schema . fromJsonString ( LegacyAttachmentUploadClaims ) ,
49+ ) ;
50+
3351describe ( "AttachmentUpload" , ( ) => {
3452 it . effect ( "signs the attachment metadata and validates the upload token" , ( ) =>
3553 Effect . gen ( function * ( ) {
@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {
5977 } ) . pipe ( Effect . provide ( testLayer ) ) ,
6078 ) ;
6179
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 ( testLayer ) ) ,
103+ ) ;
104+
62105 it . effect ( "rejects expired upload tokens" , ( ) =>
63106 Effect . gen ( function * ( ) {
64107 const issued = yield * issueAttachmentUploadUrl ( uploadInput ) ;
@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {
108151 } ) . pipe ( Effect . provide ( testLayer ) ) ,
109152 ) ;
110153
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 ( / - p d f $ / ) ;
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 ( testLayer ) ) ,
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 ( testLayer ) ) ,
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 ( testLayer ) ) ,
231+ ) ;
232+
111233 it . effect ( "deletes pending uploads without deleting thread-owned copies" , ( ) =>
112234 Effect . gen ( function * ( ) {
113235 const config = yield * ServerConfig . ServerConfig ;
0 commit comments