| 1 | import CryptoKit |
| 2 | import XCTest |
| 3 | @testable import LChatCore |
| 4 | |
| 5 | // π€ The saved-spaces list helpers, ported from home/test/serverlist.test.mjs so |
| 6 | // the native rail sorts a member's spaces exactly like the home page (same star |
| 7 | // + independent-sort contract). |
| 8 | final class HomeVaultTests: XCTestCase { |
| 9 | private func srv(_ url: String, starred: Bool? = nil, archived: Bool? = nil, |
| 10 | name: String? = nil) -> HomeVault.Server { |
| 11 | HomeVault.Server(url: url, name: name, starred: starred, archived: archived, |
| 12 | credentials: .init(token: "t", userPub: "p", userPriv: "k", name: nil)) |
| 13 | } |
| 14 | private func urls(_ list: [HomeVault.Server]) -> [String] { list.map(\.url) } |
| 15 | |
| 16 | func testPartitionKeepsArrayOrder() { |
| 17 | let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")] |
| 18 | let (starred, others) = HomeVault.partition(servers) |
| 19 | XCTAssertEqual(urls(starred), ["a", "c"]) |
| 20 | XCTAssertEqual(urls(others), ["b", "d"]) |
| 21 | } |
| 22 | |
| 23 | func testPartitionToleratesEmpty() { |
| 24 | let (s, o) = HomeVault.partition([]) |
| 25 | XCTAssertTrue(s.isEmpty && o.isEmpty) |
| 26 | } |
| 27 | |
| 28 | func testToggleStarAddsThenRemovesWithoutMutating() { |
| 29 | let servers = [srv("a"), srv("b")] |
| 30 | let on = HomeVault.toggleStar(servers, url: "a") |
| 31 | XCTAssertEqual(on.first(where: { $0.url == "a" })?.starred, true) |
| 32 | XCTAssertNil(servers[0].starred, "input must be untouched") |
| 33 | let off = HomeVault.toggleStar(on, url: "a") |
| 34 | XCTAssertNil(off.first(where: { $0.url == "a" })?.starred, "unstarred entry carries no flag") |
| 35 | } |
| 36 | |
| 37 | func testStarringSendsToEndOfStarred() { |
| 38 | let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b"), srv("c")] |
| 39 | let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "b")) |
| 40 | XCTAssertEqual(urls(starred), ["x", "y", "b"]) |
| 41 | XCTAssertEqual(urls(others), ["a", "c"]) |
| 42 | } |
| 43 | |
| 44 | func testUnstarringSendsToTopOfRest() { |
| 45 | let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b")] |
| 46 | let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "x")) |
| 47 | XCTAssertEqual(urls(starred), ["y"]) |
| 48 | XCTAssertEqual(urls(others), ["x", "a", "b"]) |
| 49 | } |
| 50 | |
| 51 | func testToggleStarUnknownUrlUnchanged() { |
| 52 | let servers = [srv("a", name: "A")] |
| 53 | XCTAssertEqual(HomeVault.toggleStar(servers, url: "zzz"), servers) |
| 54 | } |
| 55 | |
| 56 | func testMoveInGroupStaysWithinGroup() { |
| 57 | // starred: [a, c] others: [b, d] β move 'a' down swaps with 'c', not 'b'. |
| 58 | let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")] |
| 59 | let moved = HomeVault.moveInGroup(servers, url: "a", dir: 1) |
| 60 | let (starred, others) = HomeVault.partition(moved) |
| 61 | XCTAssertEqual(urls(starred), ["c", "a"]) |
| 62 | XCTAssertEqual(urls(others), ["b", "d"]) |
| 63 | } |
| 64 | |
| 65 | func testMoveInGroupAtEndIsNoOp() { |
| 66 | let servers = [srv("a", starred: true), srv("b")] |
| 67 | XCTAssertEqual(HomeVault.moveInGroup(servers, url: "a", dir: -1), servers) |
| 68 | } |
| 69 | |
| 70 | func testOrderByUrlPreservesEntriesAndTailsUnknowns() { |
| 71 | let servers = [srv("a", starred: true), srv("b"), srv("c")] |
| 72 | let out = HomeVault.order(servers, urlsInOrder: ["c", "a"]) // b not listed β tails |
| 73 | XCTAssertEqual(urls(out), ["c", "a", "b"]) |
| 74 | XCTAssertEqual(out.first(where: { $0.url == "a" })?.starred, true, "flag preserved") |
| 75 | } |
| 76 | |
| 77 | func testPartitionArchivedKeepsArrayOrder() { |
| 78 | let servers = [srv("a", starred: true), srv("b", archived: true), srv("c"), srv("d", archived: true)] |
| 79 | let (active, archived) = HomeVault.partitionArchived(servers) |
| 80 | XCTAssertEqual(urls(active), ["a", "c"]) |
| 81 | XCTAssertEqual(urls(archived), ["b", "d"]) |
| 82 | } |
| 83 | |
| 84 | func testSetArchivedDropsStarWithoutMutating() { |
| 85 | let servers = [srv("a", starred: true), srv("b")] |
| 86 | let next = HomeVault.setArchived(servers, url: "a", true) |
| 87 | let entry = next.first { $0.url == "a" } |
| 88 | XCTAssertEqual(entry?.archived, true) |
| 89 | XCTAssertNil(entry?.starred, "an archived space leaves the rail") |
| 90 | XCTAssertEqual(servers[0].starred, true, "input must be untouched") |
| 91 | XCTAssertNil(servers[0].archived, "input must be untouched") |
| 92 | } |
| 93 | |
| 94 | func testArchivingSendsToTopOfArchive() { |
| 95 | // x active, y already archived; archive 'x' β it lands above y. |
| 96 | let servers = [srv("s", starred: true), srv("x"), srv("z"), srv("y", archived: true)] |
| 97 | let (active, archived) = HomeVault.partitionArchived(HomeVault.setArchived(servers, url: "x", true)) |
| 98 | XCTAssertEqual(urls(active), ["s", "z"], "active keeps order minus x") |
| 99 | XCTAssertEqual(urls(archived), ["x", "y"], "x is the top of the archive") |
| 100 | } |
| 101 | |
| 102 | func testUnarchivingSendsToBottomOfActive() { |
| 103 | let servers = [srv("s", starred: true), srv("a"), srv("z", archived: true), srv("y", archived: true)] |
| 104 | let next = HomeVault.setArchived(servers, url: "z", false) |
| 105 | XCTAssertNil(next.first { $0.url == "z" }?.archived, "unarchived entry carries no flag") |
| 106 | let (active, archived) = HomeVault.partitionArchived(next) |
| 107 | XCTAssertEqual(urls(active), ["s", "a", "z"], "z rejoins at the bottom, after kept spaces") |
| 108 | XCTAssertEqual(urls(archived), ["y"], "y stays archived") |
| 109 | } |
| 110 | |
| 111 | func testSetArchivedUnknownUrlUnchanged() { |
| 112 | let servers = [srv("a", name: "A")] |
| 113 | XCTAssertEqual(HomeVault.setArchived(servers, url: "zzz", true), servers) |
| 114 | } |
| 115 | |
| 116 | // `starred: nil` must serialise as an ABSENT key (like the web's drop), not |
| 117 | // `"starred": null` β otherwise the two impls' blobs would diverge. |
| 118 | func testNilStarredOmittedFromJSON() throws { |
| 119 | let json = try JSONEncoder().encode(srv("a")) |
| 120 | let s = String(data: json, encoding: .utf8)! |
| 121 | XCTAssertFalse(s.contains("starred"), "nil starred must not appear in JSON") |
| 122 | let jsonOn = try JSONEncoder().encode(srv("a", starred: true)) |
| 123 | XCTAssertTrue(String(data: jsonOn, encoding: .utf8)!.contains("\"starred\":true")) |
| 124 | } |
| 125 | |
| 126 | // ---- removal tombstones (mirrors serverlist.test.mjs) ------------------- |
| 127 | |
| 128 | func testTombstonedAtReadsTsAndToleratesAbsence() { |
| 129 | let removed = [HomeVault.Tombstone(url: "a", ts: 42)] |
| 130 | XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "a"), 42) |
| 131 | XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "b"), 0) |
| 132 | XCTAssertEqual(HomeVault.tombstonedAt(nil, url: "a"), 0) |
| 133 | } |
| 134 | |
| 135 | func testContentsDecodesWithAndWithoutRemoved() throws { |
| 136 | let legacy = #"{"v":1,"servers":[]}"#.data(using: .utf8)! |
| 137 | XCTAssertNil(try JSONDecoder().decode(HomeVault.Contents.self, from: legacy).removed) |
| 138 | let modern = #"{"v":1,"servers":[],"removed":[{"url":"a","ts":100}]}"#.data(using: .utf8)! |
| 139 | let contents = try JSONDecoder().decode(HomeVault.Contents.self, from: modern) |
| 140 | XCTAssertEqual(contents.removed, [HomeVault.Tombstone(url: "a", ts: 100)]) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // π€ The raw read-modify-write cycle HomeSync.push uses must round-trip fields |
| 145 | // this build doesn't know β the web spread-merges unknown keys, so a native |
| 146 | // writer that stripped them would corrupt the vault for every other client. |
| 147 | extension HomeVaultTests { |
| 148 | func testRawRoundTripPreservesUnknownFields() throws { |
| 149 | // A vault a NEWER client wrote: unknown top-level key, unknown |
| 150 | // per-server + per-credentials fields, and a credential-less entry. |
| 151 | let obj: [String: Any] = [ |
| 152 | "v": 1, |
| 153 | "futureTopLevel": ["keep": true], |
| 154 | "servers": [ |
| 155 | ["url": "https://a.example", "starred": true, "futureField": "keep-me", |
| 156 | "credentials": ["token": "t", "userPub": "p", "userPriv": "k", |
| 157 | "futureCred": 7]], |
| 158 | ["url": "https://bookmark.example", "name": "Bookmark only"], |
| 159 | ], |
| 160 | ] |
| 161 | let dek = SymmetricKey(size: .bits256) |
| 162 | let objJSON = try JSONSerialization.data(withJSONObject: obj) |
| 163 | let wrapped = try HomeCrypto.wrapDEK(dek, kek: SymmetricKey(size: .bits256)) |
| 164 | let envelope = try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, |
| 165 | keys: ["cred": wrapped]) |
| 166 | |
| 167 | var round = try HomeVault.openRawObject(envelope, dek: dek) |
| 168 | // Patch the way push does: update one known field on server A. |
| 169 | var servers = round["servers"] as! [[String: Any]] |
| 170 | servers[0]["starred"] = true |
| 171 | round["servers"] = servers |
| 172 | let resealed = try HomeVault.sealRawObject(round, existingEnvelope: envelope, dek: dek) |
| 173 | let reopened = try HomeVault.openRawObject(resealed, dek: dek) |
| 174 | |
| 175 | XCTAssertNotNil(reopened["futureTopLevel"], "unknown top-level key must survive") |
| 176 | let outServers = reopened["servers"] as! [[String: Any]] |
| 177 | XCTAssertEqual(outServers[0]["futureField"] as? String, "keep-me") |
| 178 | XCTAssertEqual((outServers[0]["credentials"] as? [String: Any])?["futureCred"] as? Int, 7) |
| 179 | XCTAssertEqual(outServers[1]["name"] as? String, "Bookmark only", |
| 180 | "credential-less entry must ride through untouched") |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // π€ HomeSync.patchedVault β the pure merge step of push. The tombstone rules |
| 185 | // are the load-bearing part: a server deleted on the home page must not be |
| 186 | // resurrected by a device that still holds a copy (the original bug), while a |
| 187 | // deliberate re-add (addedAt newer than the tombstone) wins and clears it. |
| 188 | extension HomeVaultTests { |
| 189 | private func inst(_ origin: String, addedAt: Double? = nil) -> SavedInstance { |
| 190 | SavedInstance(origin: origin, deviceToken: "tok", screenName: "Me", |
| 191 | userPub: "pub", userPriv: "priv", addedAt: addedAt) |
| 192 | } |
| 193 | private func serverUrls(_ obj: [String: Any]) -> [String] { |
| 194 | ((obj["servers"] as? [[String: Any]]) ?? []).compactMap { $0["url"] as? String } |
| 195 | } |
| 196 | |
| 197 | func testPushSkipsTombstonedServerOlderThanRemoval() { |
| 198 | // Device copy predates the removal (no addedAt = 0) β never re-pushed, |
| 199 | // and the tombstone survives for other devices to see. |
| 200 | let vault: [String: Any] = [ |
| 201 | "v": 1, "servers": [], |
| 202 | "removed": [["url": "https://gone.example", "ts": 1000.0]], |
| 203 | ] |
| 204 | let out = HomeSync.patchedVault(vault, instances: [inst("https://gone.example")]) |
| 205 | XCTAssertEqual(serverUrls(out), [], "deleted server must not come back") |
| 206 | XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept") |
| 207 | } |
| 208 | |
| 209 | func testPushReAddAfterRemovalWinsAndClearsTombstone() { |
| 210 | let vault: [String: Any] = [ |
| 211 | "v": 1, "servers": [], |
| 212 | "removed": [["url": "https://back.example", "ts": 1000.0]], |
| 213 | ] |
| 214 | let out = HomeSync.patchedVault(vault, instances: [inst("https://back.example", addedAt: 2000)]) |
| 215 | XCTAssertEqual(serverUrls(out), ["https://back.example"], "deliberate re-add wins") |
| 216 | XCTAssertNil(out["removed"], "its tombstone is cleared (and the empty list dropped)") |
| 217 | } |
| 218 | |
| 219 | func testPushHealsTombstonedVaultOnlyEntry() { |
| 220 | // A stale client re-pushed a removed server this device doesn't hold: |
| 221 | // the tail must not preserve it verbatim. |
| 222 | let vault: [String: Any] = [ |
| 223 | "v": 1, |
| 224 | "servers": [["url": "https://zombie.example", "name": "Zombie"]], |
| 225 | "removed": [["url": "https://zombie.example", "ts": 1000.0]], |
| 226 | ] |
| 227 | let out = HomeSync.patchedVault(vault, instances: [inst("https://kept.example", addedAt: 500)]) |
| 228 | XCTAssertEqual(serverUrls(out), ["https://kept.example"]) |
| 229 | XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept for others") |
| 230 | } |
| 231 | |
| 232 | func testPushWithoutTombstonesKeepsUpsertBehaviour() { |
| 233 | let vault: [String: Any] = [ |
| 234 | "v": 1, |
| 235 | "servers": [["url": "https://other.example", "name": "Elsewhere"]], |
| 236 | ] |
| 237 | let out = HomeSync.patchedVault(vault, instances: [inst("https://mine.example", addedAt: 1)]) |
| 238 | XCTAssertEqual(serverUrls(out), ["https://mine.example", "https://other.example"], |
| 239 | "device order first, vault-only entries tail") |
| 240 | XCTAssertNil(out["removed"]) |
| 241 | } |
| 242 | } |
| 243 | |