eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import 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).
8final 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.
147extension 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.
188extension 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