@@ -109,6 +109,223 @@ test("unlinked relation should get included in collector", () =>
109109 expect ( items [ 0 ] . items [ 0 ] . id ) . toBe ( "cd93df7a4c64fbd5f100361d629ac5b5" ) ;
110110 } ) ) ;
111111
112+ test ( "collector should use latest key version for encryption" , ( ) =>
113+ databaseTest ( ) . then ( async ( db ) => {
114+ await loginFakeUser ( db ) ;
115+ const collector = new Collector ( db ) ;
116+
117+ const noteId = await db . notes . add ( TEST_NOTE ) ;
118+
119+ const items = await iteratorToArray ( collector . collect ( 100 , false ) ) ;
120+
121+ // Find the note item
122+ const noteItem = items . find ( ( i ) => i . type === "note" ) ;
123+ expect ( noteItem ) . toBeDefined ( ) ;
124+ expect ( noteItem . items [ 0 ] . keyVersion ) . toBeDefined ( ) ;
125+
126+ // Should use the latest key version available
127+ const keys = await db . user . getDataEncryptionKeys ( ) ;
128+ const latestKeyVersion = Math . max ( ...keys . map ( ( k ) => k . version ) ) ;
129+ expect ( noteItem . items [ 0 ] . keyVersion ) . toBe ( latestKeyVersion ) ;
130+ } ) ) ;
131+
132+ test ( "collector should assign keyVersion to all encrypted items" , ( ) =>
133+ databaseTest ( ) . then ( async ( db ) => {
134+ await loginFakeUser ( db ) ;
135+ const collector = new Collector ( db ) ;
136+
137+ await db . notes . add ( TEST_NOTE ) ;
138+ await db . notes . add ( { ...TEST_NOTE , title : "Note 2" } ) ;
139+ await db . notes . add ( { ...TEST_NOTE , title : "Note 3" } ) ;
140+
141+ const items = await iteratorToArray ( collector . collect ( 100 , false ) ) ;
142+
143+ // All items should have keyVersion set
144+ for ( const chunk of items ) {
145+ for ( const item of chunk . items ) {
146+ expect ( item . keyVersion ) . toBeDefined ( ) ;
147+ expect ( typeof item . keyVersion ) . toBe ( "number" ) ;
148+ }
149+ }
150+ } ) ) ;
151+
152+ test ( "sync roundtrip: items encrypted with keyVersion can be decrypted" , ( ) =>
153+ databaseTest ( ) . then ( async ( db ) => {
154+ await loginFakeUser ( db ) ;
155+ const { Sync } = await import ( "../index.ts" ) ;
156+ const sync = new Sync ( db ) ;
157+ const collector = new Collector ( db ) ;
158+
159+ const noteId = await db . notes . add ( {
160+ ...TEST_NOTE ,
161+ title : "Sync Test Note"
162+ } ) ;
163+ const note = await db . notes . note ( noteId ) ;
164+
165+ const items = await iteratorToArray ( collector . collect ( 100 , false ) ) ;
166+ const noteChunk = items . find ( ( i ) => i . type === "note" ) ;
167+
168+ expect ( noteChunk ) . toBeDefined ( ) ;
169+ expect ( noteChunk . items [ 0 ] . keyVersion ) . toBeDefined ( ) ;
170+
171+ // Simulate receiving the same item back from server
172+ const keys = await db . user . getDataEncryptionKeys ( ) ;
173+ await sync . processChunk ( noteChunk , keys , { type : "fetch" } ) ;
174+
175+ // Verify the note is still intact
176+ const syncedNote = await db . notes . note ( noteId ) ;
177+ expect ( syncedNote . title ) . toBe ( "Sync Test Note" ) ;
178+ expect ( syncedNote . id ) . toBe ( note . id ) ;
179+ } ) ) ;
180+
181+ test ( "sync should handle mixed keyVersion items in same chunk" , ( ) =>
182+ databaseTest ( ) . then ( async ( db ) => {
183+ await loginFakeUser ( db ) ;
184+ const { Sync } = await import ( "../index.ts" ) ;
185+ const sync = new Sync ( db ) ;
186+
187+ const keys = await db . user . getDataEncryptionKeys ( ) ;
188+
189+ // Create mock items with different key versions
190+ const note1 = JSON . stringify ( {
191+ id : "note1" ,
192+ type : "note" ,
193+ title : "Note 1" ,
194+ dateModified : Date . now ( )
195+ } ) ;
196+ const note2 = JSON . stringify ( {
197+ id : "note2" ,
198+ type : "note" ,
199+ title : "Note 2" ,
200+ dateModified : Date . now ( )
201+ } ) ;
202+
203+ const cipher1 = await db . storage ( ) . encrypt ( keys [ 0 ] . key , note1 ) ;
204+ const cipher2 =
205+ keys . length > 1
206+ ? await db . storage ( ) . encrypt ( keys [ 1 ] . key , note2 )
207+ : await db . storage ( ) . encrypt ( keys [ 0 ] . key , note2 ) ;
208+
209+ const chunk = {
210+ type : "note" ,
211+ count : 2 ,
212+ items : [
213+ { ...cipher1 , id : "note1" , v : 5 , keyVersion : keys [ 0 ] . version } ,
214+ {
215+ ...cipher2 ,
216+ id : "note2" ,
217+ v : 5 ,
218+ keyVersion : keys . length > 1 ? keys [ 1 ] . version : keys [ 0 ] . version
219+ }
220+ ]
221+ } ;
222+
223+ // Process the chunk with mixed key versions
224+ await sync . processChunk ( chunk , keys , { type : "fetch" } ) ;
225+
226+ // Verify both notes were decrypted correctly
227+ const savedNote1 = await db . notes . note ( "note1" ) ;
228+ const savedNote2 = await db . notes . note ( "note2" ) ;
229+
230+ expect ( savedNote1 ) . toBeDefined ( ) ;
231+ expect ( savedNote2 ) . toBeDefined ( ) ;
232+ expect ( savedNote1 . title ) . toBe ( "Note 1" ) ;
233+ expect ( savedNote2 . title ) . toBe ( "Note 2" ) ;
234+ } ) ) ;
235+
236+ test ( "sync should maintain stable ordering across decryptMulti" , ( ) =>
237+ databaseTest ( ) . then ( async ( db ) => {
238+ await loginFakeUser ( db ) ;
239+ const collector = new Collector ( db ) ;
240+
241+ // Create multiple notes with predictable order
242+ const noteIds = [ ] ;
243+ for ( let i = 0 ; i < 5 ; i ++ ) {
244+ const id = await db . notes . add ( {
245+ ...TEST_NOTE ,
246+ title : `Note ${ i } `
247+ } ) ;
248+ noteIds . push ( id ) ;
249+ }
250+
251+ const items = await iteratorToArray ( collector . collect ( 100 , false ) ) ;
252+ const noteChunk = items . find ( ( i ) => i . type === "note" ) ;
253+
254+ expect ( noteChunk ) . toBeDefined ( ) ;
255+ expect ( noteChunk . items ) . toHaveLength ( 5 ) ;
256+
257+ // Verify all items have IDs
258+ const collectedIds = noteChunk . items . map ( ( item ) => item . id ) ;
259+ expect ( collectedIds ) . toHaveLength ( 5 ) ;
260+
261+ // All IDs should be present
262+ for ( const id of noteIds ) {
263+ expect ( collectedIds ) . toContain ( id ) ;
264+ }
265+
266+ // Decrypt and verify ID mapping is preserved
267+ const keys = await db . user . getDataEncryptionKeys ( ) ;
268+ const { Sync } = await import ( "../index.ts" ) ;
269+ const sync = new Sync ( db ) ;
270+
271+ await sync . processChunk ( noteChunk , keys , { type : "fetch" } ) ;
272+
273+ // Verify each note can be retrieved with correct content
274+ for ( let i = 0 ; i < 5 ; i ++ ) {
275+ const note = await db . notes . note ( noteIds [ i ] ) ;
276+ expect ( note ) . toBeDefined ( ) ;
277+ expect ( note . title ) . toBe ( `Note ${ i } ` ) ;
278+ }
279+ } ) ) ;
280+
281+ test ( "sync should correctly select key based on keyVersion" , ( ) =>
282+ databaseTest ( ) . then ( async ( db ) => {
283+ await loginFakeUser ( db ) ;
284+ const { Sync } = await import ( "../index.ts" ) ;
285+ const sync = new Sync ( db ) ;
286+
287+ const keys = await db . user . getDataEncryptionKeys ( ) ;
288+
289+ // Create items encrypted with specific key versions
290+ const testCases = keys . map ( ( keyInfo , idx ) => ( {
291+ id : `note${ idx } ` ,
292+ title : `Note with keyVersion ${ keyInfo . version } ` ,
293+ keyVersion : keyInfo . version ,
294+ key : keyInfo . key
295+ } ) ) ;
296+
297+ const chunks = [ ] ;
298+ for ( const testCase of testCases ) {
299+ const noteData = JSON . stringify ( {
300+ id : testCase . id ,
301+ type : "note" ,
302+ title : testCase . title ,
303+ dateModified : Date . now ( )
304+ } ) ;
305+ const cipher = await db . storage ( ) . encrypt ( testCase . key , noteData ) ;
306+
307+ chunks . push ( {
308+ type : "note" ,
309+ count : 1 ,
310+ items : [
311+ { ...cipher , id : testCase . id , v : 5 , keyVersion : testCase . keyVersion }
312+ ]
313+ } ) ;
314+ }
315+
316+ // Process each chunk
317+ for ( const chunk of chunks ) {
318+ await sync . processChunk ( chunk , keys , { type : "fetch" } ) ;
319+ }
320+
321+ // Verify each note was decrypted with the correct key
322+ for ( const testCase of testCases ) {
323+ const note = await db . notes . note ( testCase . id ) ;
324+ expect ( note ) . toBeDefined ( ) ;
325+ expect ( note . title ) . toBe ( testCase . title ) ;
326+ }
327+ } ) ) ;
328+
112329async function iteratorToArray ( iterator ) {
113330 let items = [ ] ;
114331 for await ( const item of iterator ) {
0 commit comments