@@ -18,6 +18,7 @@ import {
1818 type Zne1ChunkCipherParams
1919} from '@zennotes/shared-domain/cloud-e2ee'
2020import { CloudE2eeLockedError , CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
21+ import { CloudSyncPathRefusedError , cloudSyncPathRefusal } from '@zennotes/shared-domain/cloud-sync-refusal'
2122import {
2223 CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES ,
2324 MobileDirectUploadError ,
@@ -197,6 +198,79 @@ describe('mutateWithMobileDirectUploads', () => {
197198 } )
198199} )
199200
201+ describe ( 'a path Cloud refuses (#1004)' , ( ) => {
202+ const refusedText = 'This path is not eligible for vault sync.'
203+ const validation = ( errors : Record < string , string [ ] > ) =>
204+ new MobileDirectUploadError ( refusedText , 422 , 'VALIDATION_FAILED' , { errors } )
205+ const small = ( operation : string , path : string ) : CloudSyncUpsertMutation =>
206+ ( { ...upsertMutation ( 4 , 'YWJjZA==' ) , operation_id : operation , item_id : `${ operation } -item` , path } )
207+ const large = ( operation : string , path : string ) : CloudSyncUpsertMutation => {
208+ const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
209+ return { ...upsertMutation ( byteLength , Buffer . alloc ( byteLength , 5 ) . toString ( 'base64' ) ) , operation_id : operation , item_id : `${ operation } -item` , path }
210+ }
211+ const ack = ( mutation : CloudSyncUpsertMutation , sequence : number ) =>
212+ ( { operation_id : mutation . operation_id , item_id : mutation . item_id , revision : 1 , sequence } )
213+
214+ it ( 'names an inline mutation by its place in the whole batch, with the direct upload that went before' , async ( ) => {
215+ const big = large ( 'big' , 'assets/photo.png' )
216+ const kept = small ( 'kept' , 'inbox/Note.md' )
217+ const refused = small ( 'refused' , '.zennotes/agent-edits/inbox/Note.md.json' )
218+ const body = { mutations : [ big , kept , refused ] }
219+ const api = fakeApi ( {
220+ initiateUpload : async ( ) => uploadInstruction ( big , 'https://objects.example.test/upload' ) ,
221+ completeUpload : async ( ) => ( {
222+ data : { id : 'upload-1' , operation_id : big . operation_id , status : 'completed' as const , result : { acknowledged : [ ack ( big , 4 ) ] , conflicts : [ ] , cursor : 4 } }
223+ } ) ,
224+ // The inline request carries kept and refused; Cloud names its own index 1.
225+ mutate : async ( ) => { throw validation ( { 'mutations.1.path' : [ refusedText ] } ) }
226+ } )
227+
228+ const error = await mutateWithMobileDirectUploads ( api , 'vault-1' , body , async ( ) => { } ) . catch ( ( caught : unknown ) => caught )
229+
230+ assert . ok ( error instanceof CloudSyncPathRefusedError )
231+ assert . deepEqual ( error . refusal . indices , [ 2 ] )
232+ assert . deepEqual ( error . refusal . applied , { acknowledged : [ ack ( big , 4 ) ] , conflicts : [ ] , cursor : 4 } )
233+ assert . deepEqual ( cloudSyncPathRefusal ( error , body ) ?. indices , [ 2 ] )
234+ } )
235+
236+ it ( 'names a direct upload refused for its own path by its place in the batch, with what went before' , async ( ) => {
237+ const kept = small ( 'kept' , 'inbox/Note.md' )
238+ const big = large ( 'big' , '.zennotes/agent-edits/assets/photo.png.json' )
239+ const body = { mutations : [ kept , big ] }
240+ let uploaded = false
241+ const api = fakeApi ( {
242+ mutate : async ( ) => ( { acknowledged : [ ack ( kept , 3 ) ] , conflicts : [ ] , cursor : 3 } ) ,
243+ initiateUpload : async ( ) => { throw validation ( { path : [ refusedText ] } ) }
244+ } )
245+
246+ const error = await mutateWithMobileDirectUploads ( api , 'vault-1' , body , async ( ) => { uploaded = true } )
247+ . catch ( ( caught : unknown ) => caught )
248+
249+ assert . ok ( error instanceof CloudSyncPathRefusedError )
250+ assert . deepEqual ( error . refusal . indices , [ 1 ] )
251+ assert . deepEqual ( error . refusal . applied , { acknowledged : [ ack ( kept , 3 ) ] , conflicts : [ ] , cursor : 3 } )
252+ assert . equal ( uploaded , false )
253+ } )
254+
255+ it ( 'stops as before when Cloud refuses anything but a path' , async ( ) => {
256+ const big = large ( 'big' , 'assets/photo.png' )
257+ const other = small ( 'other' , 'inbox/Note.md' )
258+ const original = validation ( { 'mutations.0.base_revision' : [ 'The base revision is invalid.' ] } )
259+ const api = fakeApi ( {
260+ initiateUpload : async ( ) => uploadInstruction ( big , 'https://objects.example.test/upload' ) ,
261+ completeUpload : async ( ) => ( {
262+ data : { id : 'upload-1' , operation_id : big . operation_id , status : 'completed' as const , result : { acknowledged : [ ack ( big , 4 ) ] , conflicts : [ ] , cursor : 4 } }
263+ } ) ,
264+ mutate : async ( ) => { throw original }
265+ } )
266+
267+ const error = await mutateWithMobileDirectUploads ( api , 'vault-1' , { mutations : [ big , other ] } , async ( ) => { } )
268+ . catch ( ( caught : unknown ) => caught )
269+
270+ assert . equal ( error , original )
271+ } )
272+ } )
273+
200274describe ( 'encrypted vaults' , ( ) => {
201275 const SIGNED_URL = 'https://objects.example.test/upload?signature=signed'
202276
0 commit comments