| 1 | import Foundation |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | /// A handoff the browser never sees: it says what it was begun with, and |
| 7 | /// hands back `result` when the test lets it. |
| 8 | final class FakeHandoff: Handoff, @unchecked Sendable { |
| 9 | private let gate = DispatchSemaphore(value: 0) |
| 10 | private let result: String |
| 11 | private(set) var cancelled = false |
| 12 | init(result: String) { self.result = result } |
| 13 | func browserURL() -> String { "https://calls.oficina.works/native/start?id=x" } |
| 14 | func confirmationCode() -> String { "ABCD-1234" } |
| 15 | func wait() throws -> String { |
| 16 | gate.wait() |
| 17 | if cancelled { throw NSError(domain: "test", code: 1) } |
| 18 | return result |
| 19 | } |
| 20 | func approve() { gate.signal() } |
| 21 | func cancel() { |
| 22 | cancelled = true |
| 23 | gate.signal() |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | final class BeginLog: @unchecked Sendable { |
| 28 | private let lock = NSLock() |
| 29 | private var calls: [String] = [] |
| 30 | var log: [String] { lock.withLock { calls } } |
| 31 | func add(_ s: String) { lock.withLock { calls.append(s) } } |
| 32 | } |
| 33 | |
| 34 | @MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| 35 | let deadline = Date().addingTimeInterval(5) |
| 36 | while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| 37 | } |
| 38 | |
| 39 | private let offer = String(repeating: "A", count: 42) + "w" |
| 40 | /// A server that cannot be reached (.invalid never resolves), so that a |
| 41 | /// test that ends in a join sends nothing anywhere. |
| 42 | private let origin = "https://calls.invalid" |
| 43 | private let kept = StoredSession( |
| 44 | origin: origin, cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| 45 | |
| 46 | @MainActor private func makeModel(_ store: SessionStore, _ remote: SessionRemote) -> CallsModel { |
| 47 | let defaults = UserDefaults(suiteName: "AppLinkTests")! |
| 48 | defaults.removePersistentDomain(forName: "AppLinkTests") |
| 49 | defaults.set(origin, forKey: CallsServer.key) |
| 50 | return CallsModel(store: store, remote: remote, defaults: defaults) |
| 51 | } |
| 52 | |
| 53 | private func link(_ path: String, offer: String? = nil) -> URL { |
| 54 | var url = "oficina-calls://join?url=https%3A%2F%2Fcalls.invalid%2F" |
| 55 | + path.replacingOccurrences(of: "/", with: "%2F") |
| 56 | if let offer { url += "&offer=" + offer } |
| 57 | return URL(string: url)! |
| 58 | } |
| 59 | |
| 60 | /// The web's "Join in the app" (2026-10-02): reported by a Calls user as |
| 61 | /// "App link doesn't work if you're signed in". The link named the |
| 62 | /// meeting and nothing else, and the app has a session of its own or none, |
| 63 | /// so a guest signed in on the web — or anyone whose default browser was |
| 64 | /// not the one they were signed in to — could not get there. |
| 65 | final class AppLinkTests: XCTestCase { |
| 66 | private func remote(_ server: ScriptedRemote, _ begun: BeginLog, _ handoff: FakeHandoff) |
| 67 | -> SessionRemote |
| 68 | { |
| 69 | var remote = server.remote |
| 70 | remote.begin = { address, offer in |
| 71 | begun.add("\(address) \(offer ?? "-")") |
| 72 | return handoff |
| 73 | } |
| 74 | return remote |
| 75 | } |
| 76 | |
| 77 | /// Signed out, from the web: the offer is taken up at once, no browser |
| 78 | /// is opened (the one the person came from is waiting), and once it |
| 79 | /// confirms, the app joins that meeting as that person. |
| 80 | @MainActor func testAnOfferFromTheWebIsTakenUpAndJoins() async { |
| 81 | // Asked once, when the sign-in is adopted: a guest's is not kept. |
| 82 | let server = ScriptedRemote(checks: [.success("participant")]) |
| 83 | let begun = BeginLog() |
| 84 | let handoff = FakeHandoff( |
| 85 | result: |
| 86 | #"{"origin":"https://calls.invalid","room":"abc123","cookies":[{"name":"__Host-rastrillo_session","value":"guest"}]}"# |
| 87 | ) |
| 88 | let model = makeModel(MemorySessionStore(), remote(server, begun, handoff)) |
| 89 | model.openMeetingURL(link("m/abc123", offer: offer)) |
| 90 | XCTAssertTrue(model.authenticating) |
| 91 | XCTAssertTrue(model.handingOff, "confirmed in the browser it came from") |
| 92 | XCTAssertEqual(model.pendingLink, "", "started, not offered") |
| 93 | await settle { !model.confirmationCode.isEmpty } |
| 94 | XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| 95 | XCTAssertEqual(model.confirmationCode, "ABCD-1234") |
| 96 | handoff.approve() |
| 97 | // The join is real and calls.invalid does not resolve, so it is over |
| 98 | // quickly: joined, or tried and said why it could not. |
| 99 | await settle { model.joining || !model.error.isEmpty } |
| 100 | XCTAssertTrue(model.signedIn) |
| 101 | XCTAssertEqual(model.room, "abc123") |
| 102 | XCTAssertTrue(model.joining || !model.error.isEmpty, "the meeting they asked for, joined") |
| 103 | XCTAssertFalse(model.authenticating) |
| 104 | model.leave() |
| 105 | } |
| 106 | |
| 107 | /// Signed in, as somebody that meeting is not for, and no offer: the |
| 108 | /// app says so and offers a sign-in for that meeting only, rather than |
| 109 | /// joining and failing. |
| 110 | @MainActor func testASignInThatCannotJoinSaysSoAndOffersAVisit() async { |
| 111 | let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"","noAccess":true}"#]) |
| 112 | let begun = BeginLog() |
| 113 | let model = makeModel(MemorySessionStore(kept), remote(server, begun, FakeHandoff(result: ""))) |
| 114 | model.restore() |
| 115 | await settle { model.signedIn && !model.restoring } |
| 116 | model.openMeetingURL(link("m/abc123")) |
| 117 | await settle { !model.opening && !model.pendingLink.isEmpty } |
| 118 | XCTAssertEqual(model.pendingLink, "https://calls.invalid/m/abc123") |
| 119 | XCTAssertTrue(model.pendingNoAccess) |
| 120 | XCTAssertFalse(model.joining) |
| 121 | XCTAssertTrue(model.signedIn, "the app's own sign-in stays") |
| 122 | XCTAssertTrue(begun.log.isEmpty, "offered, not started") |
| 123 | } |
| 124 | |
| 125 | /// Signed in, as somebody that meeting is not for, from the web: the |
| 126 | /// person in that browser is handed over for it, as a visit. |
| 127 | @MainActor func testASignInThatCannotJoinTakesUpTheWebsOffer() async { |
| 128 | let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"","noAccess":true}"#]) |
| 129 | let begun = BeginLog() |
| 130 | let handoff = FakeHandoff(result: "") |
| 131 | let model = makeModel(MemorySessionStore(kept), remote(server, begun, handoff)) |
| 132 | model.restore() |
| 133 | await settle { model.signedIn && !model.restoring } |
| 134 | model.openMeetingURL(link("m/abc123", offer: offer)) |
| 135 | await settle { !begun.log.isEmpty } |
| 136 | XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| 137 | XCTAssertTrue(model.handingOff) |
| 138 | model.cancelSignIn() |
| 139 | } |
| 140 | |
| 141 | /// Signed in, as someone who may join: the app's own session opens it, |
| 142 | /// with no browser, offer or not — the case that always worked. |
| 143 | @MainActor func testASignInThatCanJoinNeedsNoBrowser() async { |
| 144 | let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":"abc123"}"#]) |
| 145 | let begun = BeginLog() |
| 146 | let model = makeModel(MemorySessionStore(kept), remote(server, begun, FakeHandoff(result: ""))) |
| 147 | model.restore() |
| 148 | await settle { model.signedIn && !model.restoring } |
| 149 | model.openMeetingURL(link("m/abc123", offer: offer)) |
| 150 | await settle { model.joining } |
| 151 | XCTAssertEqual(model.room, "abc123") |
| 152 | XCTAssertTrue(begun.log.isEmpty) |
| 153 | model.leave() |
| 154 | } |
| 155 | |
| 156 | /// The link launched the app with nothing kept: the offer waits for the |
| 157 | /// check of the kept sign-in, then is taken up. |
| 158 | @MainActor func testAnOfferThatLaunchedTheAppIsTakenUpAfterTheCheck() async { |
| 159 | let server = ScriptedRemote() |
| 160 | let begun = BeginLog() |
| 161 | let handoff = FakeHandoff(result: "") |
| 162 | let model = makeModel(MemorySessionStore(), remote(server, begun, handoff)) |
| 163 | model.restore() |
| 164 | model.openMeetingURL(link("m/abc123", offer: offer)) |
| 165 | await settle { !begun.log.isEmpty } |
| 166 | XCTAssertEqual(begun.log, ["https://calls.invalid/m/abc123 \(offer)"]) |
| 167 | model.cancelSignIn() |
| 168 | } |
| 169 | |
| 170 | /// A link on another server, with nobody able to take it up, says which |
| 171 | /// server it is on before anything opens. |
| 172 | @MainActor func testALinkOnAnotherServerSaysWhichServer() { |
| 173 | let model = makeModel(MemorySessionStore(), ScriptedRemote().remote) |
| 174 | model.openMeetingURL( |
| 175 | URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.org%2Fm%2Fabc123")!) |
| 176 | XCTAssertEqual(model.pendingLink, "https://calls.example.org/m/abc123") |
| 177 | XCTAssertFalse(model.pendingOnServer) |
| 178 | XCTAssertEqual(model.pendingHost, "calls.example.org") |
| 179 | XCTAssertFalse(model.authenticating) |
| 180 | } |
| 181 | |
| 182 | func testOnlyTheOffersTheServerMakesAreTaken() { |
| 183 | XCTAssertTrue(CallsModel.validOffer(offer)) |
| 184 | XCTAssertTrue(CallsModel.validOffer(String(repeating: "_-", count: 21) + "a")) |
| 185 | for bad in ["", "short", String(repeating: "A", count: 44), String(repeating: "A", count: 42) + "/", |
| 186 | String(repeating: "A", count: 42) + "é"] |
| 187 | { |
| 188 | XCTAssertFalse(CallsModel.validOffer(bad), bad) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |