import CryptoKit import XCTest @testable import LChatCore // 🤖 The saved-spaces list helpers, ported from home/test/serverlist.test.mjs so // the native rail sorts a member's spaces exactly like the home page (same star // + independent-sort contract). final class HomeVaultTests: XCTestCase { private func srv(_ url: String, starred: Bool? = nil, archived: Bool? = nil, name: String? = nil) -> HomeVault.Server { HomeVault.Server(url: url, name: name, starred: starred, archived: archived, credentials: .init(token: "t", userPub: "p", userPriv: "k", name: nil)) } private func urls(_ list: [HomeVault.Server]) -> [String] { list.map(\.url) } func testPartitionKeepsArrayOrder() { let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")] let (starred, others) = HomeVault.partition(servers) XCTAssertEqual(urls(starred), ["a", "c"]) XCTAssertEqual(urls(others), ["b", "d"]) } func testPartitionToleratesEmpty() { let (s, o) = HomeVault.partition([]) XCTAssertTrue(s.isEmpty && o.isEmpty) } func testToggleStarAddsThenRemovesWithoutMutating() { let servers = [srv("a"), srv("b")] let on = HomeVault.toggleStar(servers, url: "a") XCTAssertEqual(on.first(where: { $0.url == "a" })?.starred, true) XCTAssertNil(servers[0].starred, "input must be untouched") let off = HomeVault.toggleStar(on, url: "a") XCTAssertNil(off.first(where: { $0.url == "a" })?.starred, "unstarred entry carries no flag") } func testStarringSendsToEndOfStarred() { let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b"), srv("c")] let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "b")) XCTAssertEqual(urls(starred), ["x", "y", "b"]) XCTAssertEqual(urls(others), ["a", "c"]) } func testUnstarringSendsToTopOfRest() { let servers = [srv("x", starred: true), srv("y", starred: true), srv("a"), srv("b")] let (starred, others) = HomeVault.partition(HomeVault.toggleStar(servers, url: "x")) XCTAssertEqual(urls(starred), ["y"]) XCTAssertEqual(urls(others), ["x", "a", "b"]) } func testToggleStarUnknownUrlUnchanged() { let servers = [srv("a", name: "A")] XCTAssertEqual(HomeVault.toggleStar(servers, url: "zzz"), servers) } func testMoveInGroupStaysWithinGroup() { // starred: [a, c] others: [b, d] — move 'a' down swaps with 'c', not 'b'. let servers = [srv("a", starred: true), srv("b"), srv("c", starred: true), srv("d")] let moved = HomeVault.moveInGroup(servers, url: "a", dir: 1) let (starred, others) = HomeVault.partition(moved) XCTAssertEqual(urls(starred), ["c", "a"]) XCTAssertEqual(urls(others), ["b", "d"]) } func testMoveInGroupAtEndIsNoOp() { let servers = [srv("a", starred: true), srv("b")] XCTAssertEqual(HomeVault.moveInGroup(servers, url: "a", dir: -1), servers) } func testOrderByUrlPreservesEntriesAndTailsUnknowns() { let servers = [srv("a", starred: true), srv("b"), srv("c")] let out = HomeVault.order(servers, urlsInOrder: ["c", "a"]) // b not listed → tails XCTAssertEqual(urls(out), ["c", "a", "b"]) XCTAssertEqual(out.first(where: { $0.url == "a" })?.starred, true, "flag preserved") } func testPartitionArchivedKeepsArrayOrder() { let servers = [srv("a", starred: true), srv("b", archived: true), srv("c"), srv("d", archived: true)] let (active, archived) = HomeVault.partitionArchived(servers) XCTAssertEqual(urls(active), ["a", "c"]) XCTAssertEqual(urls(archived), ["b", "d"]) } func testSetArchivedDropsStarWithoutMutating() { let servers = [srv("a", starred: true), srv("b")] let next = HomeVault.setArchived(servers, url: "a", true) let entry = next.first { $0.url == "a" } XCTAssertEqual(entry?.archived, true) XCTAssertNil(entry?.starred, "an archived space leaves the rail") XCTAssertEqual(servers[0].starred, true, "input must be untouched") XCTAssertNil(servers[0].archived, "input must be untouched") } func testArchivingSendsToTopOfArchive() { // x active, y already archived; archive 'x' → it lands above y. let servers = [srv("s", starred: true), srv("x"), srv("z"), srv("y", archived: true)] let (active, archived) = HomeVault.partitionArchived(HomeVault.setArchived(servers, url: "x", true)) XCTAssertEqual(urls(active), ["s", "z"], "active keeps order minus x") XCTAssertEqual(urls(archived), ["x", "y"], "x is the top of the archive") } func testUnarchivingSendsToBottomOfActive() { let servers = [srv("s", starred: true), srv("a"), srv("z", archived: true), srv("y", archived: true)] let next = HomeVault.setArchived(servers, url: "z", false) XCTAssertNil(next.first { $0.url == "z" }?.archived, "unarchived entry carries no flag") let (active, archived) = HomeVault.partitionArchived(next) XCTAssertEqual(urls(active), ["s", "a", "z"], "z rejoins at the bottom, after kept spaces") XCTAssertEqual(urls(archived), ["y"], "y stays archived") } func testSetArchivedUnknownUrlUnchanged() { let servers = [srv("a", name: "A")] XCTAssertEqual(HomeVault.setArchived(servers, url: "zzz", true), servers) } // `starred: nil` must serialise as an ABSENT key (like the web's drop), not // `"starred": null` — otherwise the two impls' blobs would diverge. func testNilStarredOmittedFromJSON() throws { let json = try JSONEncoder().encode(srv("a")) let s = String(data: json, encoding: .utf8)! XCTAssertFalse(s.contains("starred"), "nil starred must not appear in JSON") let jsonOn = try JSONEncoder().encode(srv("a", starred: true)) XCTAssertTrue(String(data: jsonOn, encoding: .utf8)!.contains("\"starred\":true")) } // ---- removal tombstones (mirrors serverlist.test.mjs) ------------------- func testTombstonedAtReadsTsAndToleratesAbsence() { let removed = [HomeVault.Tombstone(url: "a", ts: 42)] XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "a"), 42) XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "b"), 0) XCTAssertEqual(HomeVault.tombstonedAt(nil, url: "a"), 0) } func testContentsDecodesWithAndWithoutRemoved() throws { let legacy = #"{"v":1,"servers":[]}"#.data(using: .utf8)! XCTAssertNil(try JSONDecoder().decode(HomeVault.Contents.self, from: legacy).removed) let modern = #"{"v":1,"servers":[],"removed":[{"url":"a","ts":100}]}"#.data(using: .utf8)! let contents = try JSONDecoder().decode(HomeVault.Contents.self, from: modern) XCTAssertEqual(contents.removed, [HomeVault.Tombstone(url: "a", ts: 100)]) } } // 🤖 The raw read-modify-write cycle HomeSync.push uses must round-trip fields // this build doesn't know — the web spread-merges unknown keys, so a native // writer that stripped them would corrupt the vault for every other client. extension HomeVaultTests { func testRawRoundTripPreservesUnknownFields() throws { // A vault a NEWER client wrote: unknown top-level key, unknown // per-server + per-credentials fields, and a credential-less entry. let obj: [String: Any] = [ "v": 1, "futureTopLevel": ["keep": true], "servers": [ ["url": "https://a.example", "starred": true, "futureField": "keep-me", "credentials": ["token": "t", "userPub": "p", "userPriv": "k", "futureCred": 7]], ["url": "https://bookmark.example", "name": "Bookmark only"], ], ] let dek = SymmetricKey(size: .bits256) let objJSON = try JSONSerialization.data(withJSONObject: obj) let wrapped = try HomeCrypto.wrapDEK(dek, kek: SymmetricKey(size: .bits256)) let envelope = try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, keys: ["cred": wrapped]) var round = try HomeVault.openRawObject(envelope, dek: dek) // Patch the way push does: update one known field on server A. var servers = round["servers"] as! [[String: Any]] servers[0]["starred"] = true round["servers"] = servers let resealed = try HomeVault.sealRawObject(round, existingEnvelope: envelope, dek: dek) let reopened = try HomeVault.openRawObject(resealed, dek: dek) XCTAssertNotNil(reopened["futureTopLevel"], "unknown top-level key must survive") let outServers = reopened["servers"] as! [[String: Any]] XCTAssertEqual(outServers[0]["futureField"] as? String, "keep-me") XCTAssertEqual((outServers[0]["credentials"] as? [String: Any])?["futureCred"] as? Int, 7) XCTAssertEqual(outServers[1]["name"] as? String, "Bookmark only", "credential-less entry must ride through untouched") } } // 🤖 HomeSync.patchedVault — the pure merge step of push. The tombstone rules // are the load-bearing part: a server deleted on the home page must not be // resurrected by a device that still holds a copy (the original bug), while a // deliberate re-add (addedAt newer than the tombstone) wins and clears it. extension HomeVaultTests { private func inst(_ origin: String, addedAt: Double? = nil) -> SavedInstance { SavedInstance(origin: origin, deviceToken: "tok", screenName: "Me", userPub: "pub", userPriv: "priv", addedAt: addedAt) } private func serverUrls(_ obj: [String: Any]) -> [String] { ((obj["servers"] as? [[String: Any]]) ?? []).compactMap { $0["url"] as? String } } func testPushSkipsTombstonedServerOlderThanRemoval() { // Device copy predates the removal (no addedAt = 0) → never re-pushed, // and the tombstone survives for other devices to see. let vault: [String: Any] = [ "v": 1, "servers": [], "removed": [["url": "https://gone.example", "ts": 1000.0]], ] let out = HomeSync.patchedVault(vault, instances: [inst("https://gone.example")]) XCTAssertEqual(serverUrls(out), [], "deleted server must not come back") XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept") } func testPushReAddAfterRemovalWinsAndClearsTombstone() { let vault: [String: Any] = [ "v": 1, "servers": [], "removed": [["url": "https://back.example", "ts": 1000.0]], ] let out = HomeSync.patchedVault(vault, instances: [inst("https://back.example", addedAt: 2000)]) XCTAssertEqual(serverUrls(out), ["https://back.example"], "deliberate re-add wins") XCTAssertNil(out["removed"], "its tombstone is cleared (and the empty list dropped)") } func testPushHealsTombstonedVaultOnlyEntry() { // A stale client re-pushed a removed server this device doesn't hold: // the tail must not preserve it verbatim. let vault: [String: Any] = [ "v": 1, "servers": [["url": "https://zombie.example", "name": "Zombie"]], "removed": [["url": "https://zombie.example", "ts": 1000.0]], ] let out = HomeSync.patchedVault(vault, instances: [inst("https://kept.example", addedAt: 500)]) XCTAssertEqual(serverUrls(out), ["https://kept.example"]) XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept for others") } func testPushWithoutTombstonesKeepsUpsertBehaviour() { let vault: [String: Any] = [ "v": 1, "servers": [["url": "https://other.example", "name": "Elsewhere"]], ] let out = HomeSync.patchedVault(vault, instances: [inst("https://mine.example", addedAt: 1)]) XCTAssertEqual(serverUrls(out), ["https://mine.example", "https://other.example"], "device order first, vault-only entries tail") XCTAssertNil(out["removed"]) } }