eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2@testable import LChatCore
3
4// πŸ€– End-to-end proof of the native claim pipeline against a REAL lchat server
5// (wire formats are exactly where a re-implementation lies). Skipped unless the
6// env points at one β€” run locally with:
7//
8// go build -o /tmp/lchat-itest . && LCHAT_ADMIN_TOKEN=itest-token \
9// LCHAT_DB=/tmp/lchat-itest.db ADDR=127.0.0.1:8091 /tmp/lchat-itest &
10// LCHAT_ITEST_ORIGIN=http://127.0.0.1:8091 LCHAT_ITEST_ADMIN_TOKEN=itest-token \
11// swift test --filter SpaceClaimIntegrationTests
12//
13// The test mints its own setup invite (the exact shape setup_invite.go mints),
14// claims it natively, and asserts the space is genuinely stood up: token valid,
15// user in the roster, instance key minted + unwrappable, label decryptable,
16// group named, setup marked complete.
17final class SpaceClaimIntegrationTests: XCTestCase {
18 func testClaimSetupInviteAgainstRealServer() async throws {
19 let env = ProcessInfo.processInfo.environment
20 guard let origin = env["LCHAT_ITEST_ORIGIN"], let admin = env["LCHAT_ITEST_ADMIN_TOKEN"] else {
21 throw XCTSkip("set LCHAT_ITEST_ORIGIN + LCHAT_ITEST_ADMIN_TOKEN to run")
22 }
23 let base = URL(string: origin)!
24
25 // Mint a setup invite the way setup_invite.go does (admin-token API).
26 let t = InviteCrypto.randomSecret()
27 let derived = try InviteCrypto.derive(t)
28 var mint = URLRequest(url: base.appending(path: "/api/admin/key-invites"))
29 mint.httpMethod = "POST"
30 mint.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization")
31 mint.setValue("application/json", forHTTPHeaderField: "Content-Type")
32 mint.httpBody = try JSONSerialization.data(withJSONObject: [
33 "id": derived.id, "claim_hash": derived.claimHash, "grants_admin": true,
34 "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970),
35 ])
36 let (_, mintResp) = try await URLSession.shared.data(for: mint)
37 XCTAssertEqual((mintResp as? HTTPURLResponse)?.statusCode, 200, "invite mint failed")
38
39 // Claim it natively β€” the full pipeline.
40 let inst = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t,
41 screenName: "Overnight Tester")
42 XCTAssertEqual(inst.origin, origin)
43 XCTAssertFalse(inst.deviceToken.isEmpty)
44 XCTAssertFalse(inst.myUserId.isEmpty, "roster should already know us")
45
46 // The token works and the roster carries our (encrypted) label.
47 let api = LChatAPI(baseURL: base, token: inst.deviceToken)
48 let me = await api.roster().first { $0.user_id == inst.myUserId }
49 XCTAssertNotNil(me)
50 XCTAssertEqual(me?.public_key, inst.userPub)
51
52 // First-member instance-key mint: our wrapped copy exists and opens
53 // with our private key; the published label decrypts under it.
54 let status = await api.instanceKeyStatus()
55 let wrapped = try XCTUnwrap(status?.wrapped, "instance key should be minted + granted to us")
56 let raw = try ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv)
57 if let labelBlob = me?.label_blob {
58 let label = try GroupKey(rawKey: raw).decryptString(blob: labelBlob)
59 XCTAssertEqual(label, "Overnight Tester")
60 } else {
61 XCTFail("label_blob should be published at claim time")
62 }
63
64 // The minimal native setup: name + setup_complete via the admin-ROLE
65 // device token (never the admin token).
66 let ok = await SpaceSetup.finish(origin: origin, token: inst.deviceToken,
67 name: "Goes To Eleven")
68 XCTAssertTrue(ok)
69 let groupName = await api.groupName()
70 XCTAssertEqual(groupName, "Goes To Eleven")
71
72 // The setup conversation's contact-email step (setGroupSettings), which
73 // could never succeed: the trio write carried an empty name β€” rejected
74 // by setGroup β€” because it read a GET /api/admin/group that does not
75 // exist. So: the email lands, AND the name it had to carry survives.
76 let emailOK = await api.setGroupSettings(adminEmail: "admin@example.com")
77 XCTAssertTrue(emailOK, "the contact email must save")
78 let nameAfterEmail = await api.groupName()
79 XCTAssertEqual(nameAfterEmail, "Goes To Eleven",
80 "writing the email must not clear the space's name")
81
82 // And the mirror: a later name change must not silently clear the
83 // email that is already stored (the same read, the other direction).
84 let renamed = await api.setGroupSettings(name: "Renamed Space")
85 XCTAssertTrue(renamed)
86 let nameAfterRename = await api.groupName()
87 XCTAssertEqual(nameAfterRename, "Renamed Space")
88 struct GroupRead: Codable { let admin_email: String? }
89 let after = try await api.getJSON("/api/group", GroupRead.self)
90 XCTAssertEqual(after.admin_email, "admin@example.com",
91 "renaming the space must not clear the contact email")
92 // Put the name back for the assertions further down.
93 let restored = await api.setGroupSettings(name: "Goes To Eleven")
94 XCTAssertTrue(restored)
95
96 // A second claim of the same (single-use) invite must fail.
97 do {
98 _ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t,
99 screenName: "Second Claimer")
100 XCTFail("single-use invite claimed twice")
101 } catch {}
102
103 // A setup (admin-granting) claim lands in the SETUP conversation, so
104 // the claim must NOT have created a welcome thread for the admin.
105 let adminThreads = await api.threads()
106 for th in adminThreads {
107 guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty,
108 let w = th.wrapped, !w.isEmpty,
109 let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: inst.userPriv),
110 let payload = try? GroupKey(rawKey: rawT)
111 .decryptJSON(AppThreadPayload.self, blob: nb) else { continue }
112 XCTAssertNotEqual(payload.app, "welcome",
113 "an admin-granting claim must not create a welcome thread")
114 }
115
116 // ---- the joiner side (#1376): a MEMBER invite claimed natively ------
117 // Mint a plain member invite (no grants_admin) and claim it as a
118 // second person β€” the Mac/iOS "Add from link" path.
119 let t2 = InviteCrypto.randomSecret()
120 let derived2 = try InviteCrypto.derive(t2)
121 var mint2 = URLRequest(url: base.appending(path: "/api/admin/key-invites"))
122 mint2.httpMethod = "POST"
123 mint2.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization")
124 mint2.setValue("application/json", forHTTPHeaderField: "Content-Type")
125 mint2.httpBody = try JSONSerialization.data(withJSONObject: [
126 "id": derived2.id, "claim_hash": derived2.claimHash, "label": "Joiner",
127 "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970),
128 ])
129 let (_, mint2Resp) = try await URLSession.shared.data(for: mint2)
130 XCTAssertEqual((mint2Resp as? HTTPURLResponse)?.statusCode, 200, "member invite mint failed")
131
132 let joiner = try await SpaceClaim.claim(setupLink: origin + "/join/member#" + t2,
133 screenName: "")
134 XCTAssertFalse(joiner.myUserId.isEmpty)
135
136 // The joiner is keyless (the key exists, held by the admin, and no
137 // grant has landed β€” no live holder answered the claim-time nudge)
138 // and the claim must NOT have re-minted the instance key over it.
139 let joinerAPI = LChatAPI(baseURL: base, token: joiner.deviceToken)
140 let joinerStatus = await joinerAPI.instanceKeyStatus()
141 XCTAssertNil(joinerStatus?.wrapped, "no holder was online to grant")
142 XCTAssertEqual(joinerStatus?.have, [inst.myUserId],
143 "the admin's key stands β€” a joiner never re-mints")
144
145 // The welcome app-thread exists, keyed to the joiner, its encrypted
146 // name_blob naming the welcome app β€” what WelcomeLanding.threadToLand
147 // scans for, so the bundled flow runs and asks the name.
148 let joinerThreads = await joinerAPI.threads()
149 let welcome = joinerThreads.first { th in
150 guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty,
151 let w = th.wrapped, !w.isEmpty,
152 let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: joiner.userPriv),
153 let payload = try? GroupKey(rawKey: rawT)
154 .decryptJSON(AppThreadPayload.self, blob: nb) else { return false }
155 return payload.app == "welcome"
156 }
157 XCTAssertNotNil(welcome, "a member-invite claim creates Eleven's welcome thread (#1376)")
158 }
159}
160