From 7070f6c754336271027c317bba1c37b17d4fc5c5 Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 30 Dec 2025 09:37:20 -0600 Subject: [PATCH] sync-down unit tests for the linked records --- .../src/__tests__/SyncDownResponseBuilder.ts | 25 +++ keeperapi/src/__tests__/vault.test.ts | 197 ++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/keeperapi/src/__tests__/SyncDownResponseBuilder.ts b/keeperapi/src/__tests__/SyncDownResponseBuilder.ts index 2ceadbb..8163d97 100644 --- a/keeperapi/src/__tests__/SyncDownResponseBuilder.ts +++ b/keeperapi/src/__tests__/SyncDownResponseBuilder.ts @@ -258,6 +258,31 @@ export class SyncDownResponseBuilder { this.data.removedSharedFolderFolderRecords?.push(sharedFolderFolderRecord) } + addRecordLink(recordLink: Vault.IRecordLink) { + this.data.recordLinks?.push(recordLink) + } + + addRemovedRecordLink(recordLink: Vault.IRecordLink) { + this.data.removedRecordLinks?.push(recordLink) + } + + async addLinkedRecord(data: DecryptedRecordData, version: 3 | 4, encryptionKey: Uint8Array) { + const decryptedLinkedRecordKey = this.platform.getRandomBytes(32) + const linkedRecordUid = platform.getRandomBytes(16) + const linkedRecordKey = await this.platform.aesGcmEncrypt(decryptedLinkedRecordKey, encryptionKey) + const linkedRecordData = await this.platform.aesGcmEncrypt(this.platform.stringToBytes(JSON.stringify(data)), decryptedLinkedRecordKey) + const linkedRecord: Vault.IRecord = { + recordUid: linkedRecordUid, + version, + data: linkedRecordData, + extra: new Uint8Array([]), + revision: Date.now() + } + this.data.records?.push(linkedRecord) + + return {linkedRecord, linkedRecordUid, linkedRecordKey} + } + build() { return this.data } diff --git a/keeperapi/src/__tests__/vault.test.ts b/keeperapi/src/__tests__/vault.test.ts index 4abc562..6a048aa 100644 --- a/keeperapi/src/__tests__/vault.test.ts +++ b/keeperapi/src/__tests__/vault.test.ts @@ -1762,4 +1762,201 @@ describe('Sync Down', () => { }) }) }) + describe('Linked Records', () => { + describe('V4 Linked Records - File Attachments', () => { + it('saves the record data when a new linked record is added to a record', async () => { + const recordData = { + title: 'parent record', + } + const linkedRecordData = { + title: "file attached to the parent record" + } + const {recordUid, record, decryptedRecordKey, recordKey} = await syncDownResponseBuilder.addRecord(recordData) + const recordUidStr = webSafe64FromBytes(recordUid) + const {linkedRecordUid, linkedRecordKey, linkedRecord} = await syncDownResponseBuilder.addLinkedRecord(linkedRecordData, 4, decryptedRecordKey) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + const recordLink: Vault.IRecordLink = { + parentRecordUid: recordUid, + childRecordUid: linkedRecordUid, + recordKey: linkedRecordKey, + } + syncDownResponseBuilder.addRecordLink(recordLink) + await platform.unwrapKey(recordKey, recordUidStr, 'data', 'gcm', 'aes') + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: recordData, + uid: recordUidStr, + revision: record.revision, + })) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: linkedRecordData, + uid: linkedRecordUidStr, + revision: linkedRecord.revision, + })) + }) + it('saves the record data when a linked record is updated, attached to another record', async () => { + const recordData = { + title: 'parent record', + } + const linkedRecordData = { + title: "file attachment updated" + } + const {recordUid, record, decryptedRecordKey, recordKey} = await syncDownResponseBuilder.addRecord(recordData) + const recordUidStr = webSafe64FromBytes(recordUid) + const {linkedRecordUid, linkedRecord, linkedRecordKey} = await syncDownResponseBuilder.addLinkedRecord(linkedRecordData, 4, decryptedRecordKey) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + await platform.unwrapKey(recordKey, recordUidStr, 'data', 'gcm', 'aes') + await platform.unwrapKey(linkedRecordKey, linkedRecordUidStr, recordUidStr, 'gcm', 'aes') + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: recordData, + uid: recordUidStr, + revision: record.revision, + })) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: linkedRecordData, + uid: linkedRecordUidStr, + revision: linkedRecord.revision, + })) + }) + it('update the record data when a linked record is unlinked from a record', async () => { + const recordData = { + title: 'parent record', + } + const {recordUid, record, recordKey} = await syncDownResponseBuilder.addRecord(recordData) + const recordUidStr = webSafe64FromBytes(recordUid) + await platform.unwrapKey(recordKey, recordUidStr, 'data', 'gcm', 'aes') + const linkedRecordUid = platform.getRandomBytes(16) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + const removedRecordLink: Vault.IRecordLink = { + parentRecordUid: recordUid, + childRecordUid: linkedRecordUid, + } + syncDownResponseBuilder.addRemovedRecordLink(removedRecordLink) + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: recordData, + uid: recordUidStr, + revision: record.revision, + })) + expect(storage.removeDependencies).toHaveBeenCalledWith({ + [recordUidStr]: new Set([linkedRecordUidStr]) + }) + }) + }) + describe('V3 Linked Records - Credit Cards / Addresses', () => { + it('saves the record data when a new linked record is added to a record', async () => { + const recordData = { + title: 'parent record', + } + const linkedRecordData = { + title: "credit card" + } + const {recordUid, record, decryptedRecordKey, recordKey} = await syncDownResponseBuilder.addRecord(recordData) + const recordUidStr = webSafe64FromBytes(recordUid) + const {linkedRecordUid, linkedRecord, linkedRecordKey} = await syncDownResponseBuilder.addLinkedRecord(linkedRecordData, 3, decryptedRecordKey) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + const recordLink: Vault.IRecordLink = { + parentRecordUid: recordUid, + childRecordUid: linkedRecordUid, + recordKey: linkedRecordKey, + } + syncDownResponseBuilder.addRecordLink(recordLink) + await platform.unwrapKey(recordKey, recordUidStr, 'data', 'gcm', 'aes') + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: recordData, + uid: recordUidStr, + revision: record.revision, + })) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: linkedRecordData, + uid: linkedRecordUidStr, + revision: linkedRecord.revision, + })) + }) + it('saves the record data when a linked record is updated, attached to another record', async () => { + const linkedRecordData = { + title: "credit card updated" + } + const {linkedRecordUid, linkedRecordKey, linkedRecord} = await syncDownResponseBuilder.addLinkedRecord(linkedRecordData, 3, auth.dataKey!) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + await platform.unwrapKey(linkedRecordKey, linkedRecordUidStr, 'data', 'gcm', 'aes') + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: linkedRecordData, + uid: linkedRecordUidStr, + revision: linkedRecord.revision, + })) + }) + it('updates the record data when a linked record is unlinked from a record', async () => { + const recordData = { + title: 'parent record', + } + const {recordUid, record, recordKey} = await syncDownResponseBuilder.addRecord(recordData) + const recordUidStr = webSafe64FromBytes(recordUid) + await platform.unwrapKey(recordKey, recordUidStr, 'data', 'gcm', 'aes') + const linkedRecordUid = platform.getRandomBytes(16) + const linkedRecordUidStr = webSafe64FromBytes(linkedRecordUid) + const removedRecordLink: Vault.IRecordLink = { + parentRecordUid: recordUid, + childRecordUid: linkedRecordUid, + } + syncDownResponseBuilder.addRemovedRecordLink(removedRecordLink) + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.put).toHaveBeenCalledWith(expect.objectContaining({ + kind: "record", + data: recordData, + uid: recordUidStr, + revision: record.revision, + })) + expect(storage.removeDependencies).toHaveBeenCalledWith({ + [recordUidStr]: new Set([linkedRecordUidStr]) + }) + }) + it(`deletes the record data when a linked record is deleted - owned linked records`, async () => { + const recordUid = platform.getRandomBytes(16) + syncDownResponseBuilder.addRemovedRecord(recordUid) + mockSyncDownCommand.mockResolvedValue(syncDownResponseBuilder.build()) + await syncDown({ + auth, + storage, + }) + expect(storage.delete).toHaveBeenCalledWith("record", webSafe64FromBytes(recordUid)) + }) + it('does nothing when a linked record is deleted - shared linked record', async () => {}) + }) + }) })