@@ -41,6 +41,12 @@ const json = (body: unknown, headers: Record<string, string> = authed) => ({
4141 body : JSON . stringify ( body ) ,
4242} ) ;
4343
44+ const patchJson = ( body : unknown , headers : Record < string , string > = authed ) => ( {
45+ method : "PATCH" ,
46+ headers : { "content-type" : "application/json" , ...headers } ,
47+ body : JSON . stringify ( body ) ,
48+ } ) ;
49+
4450describe ( "auth" , ( ) => {
4551 test ( "401s with no bearer token" , async ( ) => {
4652 const db = await testDb ( ) ;
@@ -90,6 +96,59 @@ describe("POST /artifacts", () => {
9096 expect ( res . status ) . toBe ( 400 ) ;
9197 } ) ;
9298
99+ test ( "stores an opaque metadata object on version 1" , async ( ) => {
100+ const db = await testDb ( ) ;
101+ const app = host ( db ) ;
102+ const res = await app . request (
103+ "/artifacts" ,
104+ json ( {
105+ title : "Brief" ,
106+ kind : "document" ,
107+ content : "hello" ,
108+ metadata : { project : "acme-onboarding" , stage : "draft" } ,
109+ } ) ,
110+ ) ;
111+ expect ( res . status ) . toBe ( 201 ) ;
112+ const { data } = ( await res . json ( ) ) as { data : { id : string ; version : number } } ;
113+ expect ( data . version ) . toBe ( 1 ) ;
114+
115+ const row = await getArtifact ( db , data . id ) ;
116+ expect ( row ?. metadata ) . toEqual ( { project : "acme-onboarding" , stage : "draft" } ) ;
117+ } ) ;
118+
119+ test ( "rejects a metadata value that is not a JSON object" , async ( ) => {
120+ const db = await testDb ( ) ;
121+ const app = host ( db ) ;
122+ for ( const metadata of [ "a string" , 42 , [ "array" ] ] ) {
123+ const res = await app . request (
124+ "/artifacts" ,
125+ json ( { title : "Brief" , kind : "document" , content : "hello" , metadata } ) ,
126+ ) ;
127+ expect ( res . status ) . toBe ( 400 ) ;
128+ }
129+ } ) ;
130+
131+ test ( "a body trying to smuggle source/runId is ignored: source stays server-stamped" , async ( ) => {
132+ const db = await testDb ( ) ;
133+ const app = host ( db ) ;
134+ const res = await app . request (
135+ "/artifacts" ,
136+ json ( {
137+ title : "Brief" ,
138+ kind : "document" ,
139+ content : "hello" ,
140+ source : { origin : "manual" } ,
141+ runId : "attacker-run" ,
142+ generatedBy : "attacker" ,
143+ } ) ,
144+ ) ;
145+ expect ( res . status ) . toBe ( 201 ) ;
146+ const { data } = ( await res . json ( ) ) as { data : { id : string } } ;
147+
148+ const row = await getArtifact ( db , data . id ) ;
149+ expect ( row ?. source ) . toEqual ( { origin : "workflow" , runId : "run-1" } ) ;
150+ } ) ;
151+
93152 test ( "413s content over the configured character ceiling" , async ( ) => {
94153 const db = await testDb ( ) ;
95154 const app = mountWorkflowArtifacts ( new Hono < WorkflowArtifactEnv > ( ) , {
@@ -179,6 +238,27 @@ describe("POST /artifacts/binary", () => {
179238 expect ( ( row ?. source as { generatedBy ?: string } ) ?. generatedBy ) . toBe ( "run-1" ) ;
180239 } ) ;
181240
241+ test ( "stores an opaque metadata object on version 1" , async ( ) => {
242+ const db = await testDb ( ) ;
243+ const app = host ( db ) ;
244+ const contentBase64 = Buffer . from ( "<h1>rendered</h1>" ) . toString ( "base64" ) ;
245+
246+ const res = await app . request (
247+ "/artifacts/binary" ,
248+ json ( {
249+ filename : "brief.html" ,
250+ mimeType : "text/html" ,
251+ contentBase64,
252+ metadata : { project : "acme-onboarding" } ,
253+ } ) ,
254+ ) ;
255+ expect ( res . status ) . toBe ( 201 ) ;
256+ const { data } = ( await res . json ( ) ) as { data : { id : string } } ;
257+
258+ const row = await getArtifact ( db , data . id ) ;
259+ expect ( row ?. metadata ) . toEqual ( { project : "acme-onboarding" } ) ;
260+ } ) ;
261+
182262 test ( "415s an unsupported file type" , async ( ) => {
183263 const db = await testDb ( ) ;
184264 const app = host ( db ) ;
@@ -212,3 +292,64 @@ describe("POST /artifacts/binary", () => {
212292 expect ( res . status ) . toBe ( 413 ) ;
213293 } ) ;
214294} ) ;
295+
296+ describe ( "PATCH /artifacts/:id" , ( ) => {
297+ test ( "sets metadata and returns it in the response" , async ( ) => {
298+ const db = await testDb ( ) ;
299+ const app = host ( db ) ;
300+ const row = await seedArtifact ( db , { tenantId : "acme" , title : "Draft" , content : "v1" } ) ;
301+
302+ const res = await app . request (
303+ `/artifacts/${ row . id } ` ,
304+ patchJson ( { content : "v2" , metadata : { stage : "review" } } ) ,
305+ ) ;
306+ expect ( res . status ) . toBe ( 200 ) ;
307+ const { data } = ( await res . json ( ) ) as { data : { version : number } } ;
308+ expect ( data . version ) . toBe ( 2 ) ;
309+
310+ const updated = await getArtifact ( db , row . id ) ;
311+ expect ( updated ?. metadata ) . toEqual ( { stage : "review" } ) ;
312+ } ) ;
313+
314+ test ( "omitting metadata carries the prior version's metadata forward" , async ( ) => {
315+ const db = await testDb ( ) ;
316+ const app = host ( db ) ;
317+ const row = await seedArtifact ( db , { tenantId : "acme" , title : "Draft" , content : "v1" } ) ;
318+ await app . request ( `/artifacts/${ row . id } ` , patchJson ( { metadata : { stage : "draft" } } ) ) ;
319+
320+ await app . request ( `/artifacts/${ row . id } ` , patchJson ( { content : "v3" } ) ) ;
321+
322+ const updated = await getArtifact ( db , row . id ) ;
323+ expect ( updated ?. metadata ) . toEqual ( { stage : "draft" } ) ;
324+ } ) ;
325+
326+ test ( "an explicit null metadata clears it" , async ( ) => {
327+ const db = await testDb ( ) ;
328+ const app = host ( db ) ;
329+ const row = await seedArtifact ( db , { tenantId : "acme" , title : "Draft" , content : "v1" } ) ;
330+ await app . request ( `/artifacts/${ row . id } ` , patchJson ( { metadata : { stage : "draft" } } ) ) ;
331+
332+ await app . request ( `/artifacts/${ row . id } ` , patchJson ( { metadata : null } ) ) ;
333+
334+ const updated = await getArtifact ( db , row . id ) ;
335+ expect ( updated ?. metadata ) . toBeNull ( ) ;
336+ } ) ;
337+
338+ test ( "rejects a metadata value that is not a JSON object" , async ( ) => {
339+ const db = await testDb ( ) ;
340+ const app = host ( db ) ;
341+ const row = await seedArtifact ( db , { tenantId : "acme" } ) ;
342+
343+ const res = await app . request ( `/artifacts/${ row . id } ` , patchJson ( { metadata : "nope" } ) ) ;
344+ expect ( res . status ) . toBe ( 400 ) ;
345+ } ) ;
346+
347+ test ( "rejects an empty body with no title, content, or metadata" , async ( ) => {
348+ const db = await testDb ( ) ;
349+ const app = host ( db ) ;
350+ const row = await seedArtifact ( db , { tenantId : "acme" } ) ;
351+
352+ const res = await app . request ( `/artifacts/${ row . id } ` , patchJson ( { } ) ) ;
353+ expect ( res . status ) . toBe ( 400 ) ;
354+ } ) ;
355+ } ) ;
0 commit comments