| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 The DEK-sync slice's pure halves (issue #1089): the release matching rule |
| 5 | // (a fresh SiwA assertion's account must carry the very anchor the item was |
| 6 | // written under) and the synced item's wire shape. The keychain and network |
| 7 | // edges are deliberately thin and exercised by the device matrix, not here. |
| 8 | final class DEKSyncTests: XCTestCase { |
| 9 | private let item = DEKSync.Item(v: 1, dekB64: "AAAA", siwaAnchor: "hash-1", |
| 10 | credentialID: "cred-1") |
| 11 | private func anchor(_ kind: String, _ id: String) -> HomeClient.AnchorEntry { |
| 12 | HomeClient.AnchorEntry(kind: kind, id: id) |
| 13 | } |
| 14 | |
| 15 | // ---- the release matching rule ------------------------------------------ |
| 16 | |
| 17 | func testReleasableWhenAccountCarriesTheAnchor() { |
| 18 | XCTAssertTrue(DEKSync.releasable(item, anchors: [ |
| 19 | anchor("passkey", "pk-1"), anchor("siwa", "hash-1"), |
| 20 | ])) |
| 21 | } |
| 22 | |
| 23 | func testReleasableAcceptsAnyOfSeveralSiwaAnchors() { |
| 24 | // A combined account may hold two Apple anchors; the item matches the |
| 25 | // one it was written under regardless of which Apple ID asserted. |
| 26 | XCTAssertTrue(DEKSync.releasable(item, anchors: [ |
| 27 | anchor("siwa", "hash-other"), anchor("siwa", "hash-1"), |
| 28 | ])) |
| 29 | } |
| 30 | |
| 31 | func testNotReleasableForADifferentAccount() { |
| 32 | // The family-iPad shape: the assertion resolved someone else's (or a |
| 33 | // brand-new) account — its anchors don't include the item's. |
| 34 | XCTAssertFalse(DEKSync.releasable(item, anchors: [ |
| 35 | anchor("siwa", "hash-other"), |
| 36 | ])) |
| 37 | } |
| 38 | |
| 39 | func testNotReleasableWhenTheAnchorWasRemoved() { |
| 40 | XCTAssertFalse(DEKSync.releasable(item, anchors: [ |
| 41 | anchor("passkey", "pk-1"), anchor("email", "hash-1"), |
| 42 | ])) |
| 43 | } |
| 44 | |
| 45 | func testNotReleasableAgainstAnEmptyAccount() { |
| 46 | XCTAssertFalse(DEKSync.releasable(item, anchors: [])) |
| 47 | } |
| 48 | |
| 49 | func testMatchRequiresTheSiwaKind() { |
| 50 | // Same id under a different kind must not satisfy the gate — the rule |
| 51 | // is "the SiwA anchor", not "any anchor with that hash". |
| 52 | XCTAssertFalse(DEKSync.releasable(item, anchors: [ |
| 53 | anchor("email", "hash-1"), anchor("google", "hash-1"), |
| 54 | ])) |
| 55 | } |
| 56 | |
| 57 | // ---- the item's wire shape ---------------------------------------------- |
| 58 | |
| 59 | func testItemRoundTrips() throws { |
| 60 | let data = try JSONEncoder().encode(item) |
| 61 | XCTAssertEqual(try JSONDecoder().decode(DEKSync.Item.self, from: data), item) |
| 62 | } |
| 63 | |
| 64 | func testItemToleratesUnknownFieldsFromNewerBuilds() throws { |
| 65 | // A newer build may extend the synced item; older builds must still |
| 66 | // read the fields they know (the vault blob's same forward rule). |
| 67 | let json = #"{"v":2,"dekB64":"BBBB","siwaAnchor":"h","credentialID":"c","future":true}"# |
| 68 | let decoded = try JSONDecoder().decode(DEKSync.Item.self, from: Data(json.utf8)) |
| 69 | XCTAssertEqual(decoded.dekB64, "BBBB") |
| 70 | XCTAssertEqual(decoded.siwaAnchor, "h") |
| 71 | } |
| 72 | |
| 73 | // ---- the anchors listing decode ----------------------------------------- |
| 74 | |
| 75 | func testAnchorEntryDecodesTheServerShape() throws { |
| 76 | // home/anchors.go rows: {kind, id, created_at} — created_at ignored. |
| 77 | let json = #"{"anchors":[{"kind":"siwa","id":"hash-1","created_at":1752882000}]}"# |
| 78 | struct R: Decodable { let anchors: [HomeClient.AnchorEntry] } |
| 79 | let r = try JSONDecoder().decode(R.self, from: Data(json.utf8)) |
| 80 | XCTAssertEqual(r.anchors.first?.kind, "siwa") |
| 81 | XCTAssertEqual(r.anchors.first?.id, "hash-1") |
| 82 | } |
| 83 | } |
| 84 | |