import XCTest @testable import LChatCore // 🤖 End-to-end proof of the native claim pipeline against a REAL lchat server // (wire formats are exactly where a re-implementation lies). Skipped unless the // env points at one — run locally with: // // go build -o /tmp/lchat-itest . && LCHAT_ADMIN_TOKEN=itest-token \ // LCHAT_DB=/tmp/lchat-itest.db ADDR=127.0.0.1:8091 /tmp/lchat-itest & // LCHAT_ITEST_ORIGIN=http://127.0.0.1:8091 LCHAT_ITEST_ADMIN_TOKEN=itest-token \ // swift test --filter SpaceClaimIntegrationTests // // The test mints its own setup invite (the exact shape setup_invite.go mints), // claims it natively, and asserts the space is genuinely stood up: token valid, // user in the roster, instance key minted + unwrappable, label decryptable, // group named, setup marked complete. final class SpaceClaimIntegrationTests: XCTestCase { func testClaimSetupInviteAgainstRealServer() async throws { let env = ProcessInfo.processInfo.environment guard let origin = env["LCHAT_ITEST_ORIGIN"], let admin = env["LCHAT_ITEST_ADMIN_TOKEN"] else { throw XCTSkip("set LCHAT_ITEST_ORIGIN + LCHAT_ITEST_ADMIN_TOKEN to run") } let base = URL(string: origin)! // Mint a setup invite the way setup_invite.go does (admin-token API). let t = InviteCrypto.randomSecret() let derived = try InviteCrypto.derive(t) var mint = URLRequest(url: base.appending(path: "/api/admin/key-invites")) mint.httpMethod = "POST" mint.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization") mint.setValue("application/json", forHTTPHeaderField: "Content-Type") mint.httpBody = try JSONSerialization.data(withJSONObject: [ "id": derived.id, "claim_hash": derived.claimHash, "grants_admin": true, "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970), ]) let (_, mintResp) = try await URLSession.shared.data(for: mint) XCTAssertEqual((mintResp as? HTTPURLResponse)?.statusCode, 200, "invite mint failed") // Claim it natively — the full pipeline. let inst = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t, screenName: "Overnight Tester") XCTAssertEqual(inst.origin, origin) XCTAssertFalse(inst.deviceToken.isEmpty) XCTAssertFalse(inst.myUserId.isEmpty, "roster should already know us") // The token works and the roster carries our (encrypted) label. let api = LChatAPI(baseURL: base, token: inst.deviceToken) let me = await api.roster().first { $0.user_id == inst.myUserId } XCTAssertNotNil(me) XCTAssertEqual(me?.public_key, inst.userPub) // First-member instance-key mint: our wrapped copy exists and opens // with our private key; the published label decrypts under it. let status = await api.instanceKeyStatus() let wrapped = try XCTUnwrap(status?.wrapped, "instance key should be minted + granted to us") let raw = try ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv) if let labelBlob = me?.label_blob { let label = try GroupKey(rawKey: raw).decryptString(blob: labelBlob) XCTAssertEqual(label, "Overnight Tester") } else { XCTFail("label_blob should be published at claim time") } // The minimal native setup: name + setup_complete via the admin-ROLE // device token (never the admin token). let ok = await SpaceSetup.finish(origin: origin, token: inst.deviceToken, name: "Goes To Eleven") XCTAssertTrue(ok) let groupName = await api.groupName() XCTAssertEqual(groupName, "Goes To Eleven") // A second claim of the same (single-use) invite must fail. do { _ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t, screenName: "Second Claimer") XCTFail("single-use invite claimed twice") } catch {} // A setup (admin-granting) claim lands in the SETUP conversation, so // the claim must NOT have created a welcome thread for the admin. let adminThreads = await api.threads() for th in adminThreads { guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, let w = th.wrapped, !w.isEmpty, let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: inst.userPriv), let payload = try? GroupKey(rawKey: rawT) .decryptJSON(AppThreadPayload.self, blob: nb) else { continue } XCTAssertNotEqual(payload.app, "welcome", "an admin-granting claim must not create a welcome thread") } // ---- the joiner side (#1376): a MEMBER invite claimed natively ------ // Mint a plain member invite (no grants_admin) and claim it as a // second person — the Mac/iOS "Add from link" path. let t2 = InviteCrypto.randomSecret() let derived2 = try InviteCrypto.derive(t2) var mint2 = URLRequest(url: base.appending(path: "/api/admin/key-invites")) mint2.httpMethod = "POST" mint2.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization") mint2.setValue("application/json", forHTTPHeaderField: "Content-Type") mint2.httpBody = try JSONSerialization.data(withJSONObject: [ "id": derived2.id, "claim_hash": derived2.claimHash, "label": "Joiner", "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970), ]) let (_, mint2Resp) = try await URLSession.shared.data(for: mint2) XCTAssertEqual((mint2Resp as? HTTPURLResponse)?.statusCode, 200, "member invite mint failed") let joiner = try await SpaceClaim.claim(setupLink: origin + "/join/member#" + t2, screenName: "") XCTAssertFalse(joiner.myUserId.isEmpty) // The joiner is keyless (the key exists, held by the admin, and no // grant has landed — no live holder answered the claim-time nudge) // and the claim must NOT have re-minted the instance key over it. let joinerAPI = LChatAPI(baseURL: base, token: joiner.deviceToken) let joinerStatus = await joinerAPI.instanceKeyStatus() XCTAssertNil(joinerStatus?.wrapped, "no holder was online to grant") XCTAssertEqual(joinerStatus?.have, [inst.myUserId], "the admin's key stands — a joiner never re-mints") // The welcome app-thread exists, keyed to the joiner, its encrypted // name_blob naming the welcome app — what WelcomeLanding.threadToLand // scans for, so the bundled flow runs and asks the name. let joinerThreads = await joinerAPI.threads() let welcome = joinerThreads.first { th in guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, let w = th.wrapped, !w.isEmpty, let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: joiner.userPriv), let payload = try? GroupKey(rawKey: rawT) .decryptJSON(AppThreadPayload.self, blob: nb) else { return false } return payload.app == "welcome" } XCTAssertNotNil(welcome, "a member-invite claim creates Eleven's welcome thread (#1376)") } }