| 1 | import 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. |
| 17 | final 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 | // A second claim of the same (single-use) invite must fail. |
| 73 | do { |
| 74 | _ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t, |
| 75 | screenName: "Second Claimer") |
| 76 | XCTFail("single-use invite claimed twice") |
| 77 | } catch {} |
| 78 | |
| 79 | // A setup (admin-granting) claim lands in the SETUP conversation, so |
| 80 | // the claim must NOT have created a welcome thread for the admin. |
| 81 | let adminThreads = await api.threads() |
| 82 | for th in adminThreads { |
| 83 | guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, |
| 84 | let w = th.wrapped, !w.isEmpty, |
| 85 | let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: inst.userPriv), |
| 86 | let payload = try? GroupKey(rawKey: rawT) |
| 87 | .decryptJSON(AppThreadPayload.self, blob: nb) else { continue } |
| 88 | XCTAssertNotEqual(payload.app, "welcome", |
| 89 | "an admin-granting claim must not create a welcome thread") |
| 90 | } |
| 91 | |
| 92 | // ---- the joiner side (#1376): a MEMBER invite claimed natively ------ |
| 93 | // Mint a plain member invite (no grants_admin) and claim it as a |
| 94 | // second person β the Mac/iOS "Add from link" path. |
| 95 | let t2 = InviteCrypto.randomSecret() |
| 96 | let derived2 = try InviteCrypto.derive(t2) |
| 97 | var mint2 = URLRequest(url: base.appending(path: "/api/admin/key-invites")) |
| 98 | mint2.httpMethod = "POST" |
| 99 | mint2.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization") |
| 100 | mint2.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 101 | mint2.httpBody = try JSONSerialization.data(withJSONObject: [ |
| 102 | "id": derived2.id, "claim_hash": derived2.claimHash, "label": "Joiner", |
| 103 | "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970), |
| 104 | ]) |
| 105 | let (_, mint2Resp) = try await URLSession.shared.data(for: mint2) |
| 106 | XCTAssertEqual((mint2Resp as? HTTPURLResponse)?.statusCode, 200, "member invite mint failed") |
| 107 | |
| 108 | let joiner = try await SpaceClaim.claim(setupLink: origin + "/join/member#" + t2, |
| 109 | screenName: "") |
| 110 | XCTAssertFalse(joiner.myUserId.isEmpty) |
| 111 | |
| 112 | // The joiner is keyless (the key exists, held by the admin, and no |
| 113 | // grant has landed β no live holder answered the claim-time nudge) |
| 114 | // and the claim must NOT have re-minted the instance key over it. |
| 115 | let joinerAPI = LChatAPI(baseURL: base, token: joiner.deviceToken) |
| 116 | let joinerStatus = await joinerAPI.instanceKeyStatus() |
| 117 | XCTAssertNil(joinerStatus?.wrapped, "no holder was online to grant") |
| 118 | XCTAssertEqual(joinerStatus?.have, [inst.myUserId], |
| 119 | "the admin's key stands β a joiner never re-mints") |
| 120 | |
| 121 | // The welcome app-thread exists, keyed to the joiner, its encrypted |
| 122 | // name_blob naming the welcome app β what WelcomeLanding.threadToLand |
| 123 | // scans for, so the bundled flow runs and asks the name. |
| 124 | let joinerThreads = await joinerAPI.threads() |
| 125 | let welcome = joinerThreads.first { th in |
| 126 | guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, |
| 127 | let w = th.wrapped, !w.isEmpty, |
| 128 | let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: joiner.userPriv), |
| 129 | let payload = try? GroupKey(rawKey: rawT) |
| 130 | .decryptJSON(AppThreadPayload.self, blob: nb) else { return false } |
| 131 | return payload.app == "welcome" |
| 132 | } |
| 133 | XCTAssertNotNil(welcome, "a member-invite claim creates Eleven's welcome thread (#1376)") |
| 134 | } |
| 135 | } |
| 136 | |