| 1 | import Foundation |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | /// The Keychain's stand-in: the same three calls, held in memory, and |
| 7 | /// safe to reach from the detached tasks the model uses for the real one. |
| 8 | final class MemorySessionStore: SessionStore, @unchecked Sendable { |
| 9 | private let lock = NSLock() |
| 10 | private var kept: StoredSession? |
| 11 | |
| 12 | init(_ kept: StoredSession? = nil) { self.kept = kept } |
| 13 | |
| 14 | var session: StoredSession? { lock.withLock { kept } } |
| 15 | func load() -> StoredSession? { lock.withLock { kept } } |
| 16 | func save(_ session: StoredSession) { lock.withLock { kept = session } } |
| 17 | func clear() { lock.withLock { kept = nil } } |
| 18 | } |
| 19 | |
| 20 | /// What the server said, scripted, and what the model asked it. |
| 21 | final class ScriptedRemote: @unchecked Sendable { |
| 22 | private let lock = NSLock() |
| 23 | private var checks: [Result<String, Error>] |
| 24 | private var opens: [String] |
| 25 | private(set) var asked: [String] = [] |
| 26 | |
| 27 | init(checks: [Result<String, Error>] = [], opens: [String] = []) { |
| 28 | self.checks = checks |
| 29 | self.opens = opens |
| 30 | } |
| 31 | |
| 32 | var requests: [String] { lock.withLock { asked } } |
| 33 | |
| 34 | var remote: SessionRemote { |
| 35 | SessionRemote( |
| 36 | check: { origin, cookies in |
| 37 | try self.lock.withLock { |
| 38 | self.asked.append("check \(origin) \(cookies)") |
| 39 | return try self.checks.removeFirst().get() |
| 40 | } |
| 41 | }, |
| 42 | open: { origin, _, address in |
| 43 | self.lock.withLock { |
| 44 | self.asked.append("open \(origin) \(address)") |
| 45 | return self.opens.removeFirst() |
| 46 | } |
| 47 | }, |
| 48 | signOut: { origin, cookies in |
| 49 | self.lock.withLock { self.asked.append("signout \(origin) \(cookies)") } |
| 50 | }) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | private let kept = StoredSession( |
| 55 | origin: "https://calls.oficina.works", |
| 56 | cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| 57 | |
| 58 | private struct Unreachable: LocalizedError { |
| 59 | var errorDescription: String? { "cannot reach Calls" } |
| 60 | } |
| 61 | |
| 62 | /// The model's work here happens in tasks it starts itself; this waits |
| 63 | /// for the state they leave rather than for the tasks, which are not |
| 64 | /// the model's to hand out. |
| 65 | @MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| 66 | let deadline = Date().addingTimeInterval(5) |
| 67 | while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| 68 | } |
| 69 | |
| 70 | final class KeptSignInTests: XCTestCase { |
| 71 | @MainActor func testNothingKeptIsTheSignInScreenAndAsksNobody() async { |
| 72 | let server = ScriptedRemote() |
| 73 | let model = CallsModel(store: MemorySessionStore(), remote: server.remote) |
| 74 | model.restore() |
| 75 | await settle { !model.restoring } |
| 76 | XCTAssertFalse(model.signedIn) |
| 77 | XCTAssertTrue(server.requests.isEmpty) |
| 78 | } |
| 79 | |
| 80 | @MainActor func testAKeptMemberSessionLandsHomeWithStartOffered() async { |
| 81 | let server = ScriptedRemote(checks: [.success("member")]) |
| 82 | let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| 83 | model.restore() |
| 84 | XCTAssertTrue(model.restoring, "nothing is offered until the instance answers") |
| 85 | await settle { !model.restoring } |
| 86 | XCTAssertTrue(model.signedIn) |
| 87 | XCTAssertTrue(model.canStart) |
| 88 | XCTAssertFalse(model.unchecked) |
| 89 | XCTAssertEqual(model.room, "") |
| 90 | XCTAssertEqual(model.origin, kept.origin) |
| 91 | XCTAssertEqual(server.requests, ["check \(kept.origin) \(kept.cookies)"]) |
| 92 | } |
| 93 | |
| 94 | @MainActor func testAnEndedSessionIsForgottenAndPointsAtSignIn() async { |
| 95 | let store = MemorySessionStore(kept) |
| 96 | let server = ScriptedRemote(checks: [.success("signed-out")]) |
| 97 | let model = CallsModel(store: store, remote: server.remote) |
| 98 | model.restore() |
| 99 | await settle { !model.restoring && store.session == nil } |
| 100 | XCTAssertFalse(model.signedIn) |
| 101 | XCTAssertNil(store.session) |
| 102 | XCTAssertEqual(model.pendingLink, "", "signing in again is the home's one button") |
| 103 | XCTAssertTrue(model.error.contains("calls.oficina.works")) |
| 104 | XCTAssertTrue(model.error.contains("Choose Sign in")) |
| 105 | } |
| 106 | |
| 107 | @MainActor func testABadConnectionKeepsTheSessionAndOffersToCheckAgain() async { |
| 108 | let store = MemorySessionStore(kept) |
| 109 | let server = ScriptedRemote(checks: [.failure(Unreachable()), .success("member")]) |
| 110 | let model = CallsModel(store: store, remote: server.remote) |
| 111 | model.restore() |
| 112 | await settle { !model.restoring } |
| 113 | XCTAssertEqual(store.session, kept, "no network says nothing about the session") |
| 114 | XCTAssertTrue(model.signedIn) |
| 115 | XCTAssertTrue(model.unchecked) |
| 116 | XCTAssertTrue(model.error.contains("cannot reach Calls")) |
| 117 | model.recheck() |
| 118 | await settle { !model.restoring } |
| 119 | XCTAssertFalse(model.unchecked) |
| 120 | XCTAssertTrue(model.error.isEmpty) |
| 121 | XCTAssertTrue(model.signedIn) |
| 122 | } |
| 123 | |
| 124 | @MainActor func testASessionThatIsNoLongerAMembersIsNotKept() async { |
| 125 | let store = MemorySessionStore(kept) |
| 126 | let server = ScriptedRemote(checks: [.success("participant")]) |
| 127 | let model = CallsModel(store: store, remote: server.remote) |
| 128 | model.restore() |
| 129 | await settle { !model.restoring && store.session == nil } |
| 130 | XCTAssertFalse(model.signedIn) |
| 131 | XCTAssertNil(store.session) |
| 132 | } |
| 133 | |
| 134 | @MainActor func testOnlyAFreshMembersSessionIsKept() async { |
| 135 | for (answer, keptAfter, canStart) in [ |
| 136 | (Result<String, Error>.success("member"), true, true), |
| 137 | (.success("participant"), false, false), |
| 138 | (.failure(Unreachable()), false, true), |
| 139 | ] { |
| 140 | let store = MemorySessionStore() |
| 141 | let server = ScriptedRemote(checks: [answer]) |
| 142 | let model = CallsModel(store: store, remote: server.remote) |
| 143 | model.origin = kept.origin |
| 144 | model.cookies = kept.cookies |
| 145 | model.signedIn = true |
| 146 | model.canStart = true |
| 147 | model.keep(kept) |
| 148 | await settle { !server.requests.isEmpty && (store.session != nil) == keptAfter } |
| 149 | try? await Task.sleep(for: .milliseconds(50)) |
| 150 | XCTAssertEqual(store.session != nil, keptAfter, "\(answer)") |
| 151 | XCTAssertEqual(model.canStart, canStart, "\(answer)") |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | @MainActor func testSigningOutForgetsAtOnceAndTellsTheInstance() async { |
| 156 | let store = MemorySessionStore(kept) |
| 157 | let server = ScriptedRemote(checks: [.success("member")]) |
| 158 | let model = CallsModel(store: store, remote: server.remote) |
| 159 | model.restore() |
| 160 | await settle { model.signedIn } |
| 161 | model.room = "abc123" |
| 162 | model.signOut() |
| 163 | XCTAssertFalse(model.signedIn) |
| 164 | XCTAssertFalse(model.canStart) |
| 165 | XCTAssertEqual(model.room, "") |
| 166 | XCTAssertEqual(model.origin, "") |
| 167 | await settle { store.session == nil && server.requests.count == 2 } |
| 168 | XCTAssertNil(store.session) |
| 169 | XCTAssertEqual(server.requests.last, "signout \(kept.origin) \(kept.cookies)") |
| 170 | } |
| 171 | |
| 172 | @MainActor func testSigningOutIsRefusedDuringACall() { |
| 173 | let store = MemorySessionStore(kept) |
| 174 | let model = CallsModel(store: store, remote: ScriptedRemote().remote) |
| 175 | model.signedIn = true |
| 176 | model.joining = true |
| 177 | model.signOut() |
| 178 | XCTAssertTrue(model.signedIn) |
| 179 | XCTAssertEqual(store.session, kept) |
| 180 | } |
| 181 | |
| 182 | @MainActor func testALinkOnTheSameInstanceOpensWithTheSessionNotTheBrowser() async { |
| 183 | let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) |
| 184 | let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| 185 | model.restore() |
| 186 | await settle { model.signedIn } |
| 187 | model.room = "old123" |
| 188 | model.openMeetingURL( |
| 189 | URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) |
| 190 | XCTAssertFalse(model.authenticating, "no browser sign-in for an instance already signed in to") |
| 191 | XCTAssertTrue(model.signedIn) |
| 192 | await settle { !model.opening } |
| 193 | XCTAssertEqual(server.requests.last, "open \(kept.origin) https://calls.oficina.works/j/invitation") |
| 194 | XCTAssertEqual(model.room, "", "the answer replaces the meeting in hand") |
| 195 | XCTAssertEqual(model.address, "") |
| 196 | } |
| 197 | |
| 198 | @MainActor func testALinkThatLaunchedTheAppWaitsForTheCheckAndThenOpens() async { |
| 199 | let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) |
| 200 | let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) |
| 201 | model.restore() |
| 202 | model.openMeetingURL( |
| 203 | URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fm%2Fabc123")!) |
| 204 | XCTAssertFalse(model.authenticating) |
| 205 | await settle { server.requests.count == 2 && !model.opening } |
| 206 | XCTAssertEqual( |
| 207 | server.requests, |
| 208 | [ |
| 209 | "check \(kept.origin) \(kept.cookies)", |
| 210 | "open \(kept.origin) https://calls.oficina.works/m/abc123", |
| 211 | ]) |
| 212 | } |
| 213 | |
| 214 | @MainActor func testTheInstanceIsComparedWithItsPortAndWithoutItsPath() { |
| 215 | XCTAssertEqual( |
| 216 | CallsModel.instance(of: "https://calls.oficina.works/j/abc"), "https://calls.oficina.works") |
| 217 | XCTAssertEqual(CallsModel.instance(of: "http://127.0.0.1:8080/m/x"), "http://127.0.0.1:8080") |
| 218 | XCTAssertNil(CallsModel.instance(of: "not a meeting")) |
| 219 | } |
| 220 | |
| 221 | /// Not a unit test: the real Keychain, on the machine running it. Opt |
| 222 | /// in with CALLS_KEYCHAIN_TEST=1, because on a Mac a development build |
| 223 | /// falls back to the login keychain and a CI runner may not have one. |
| 224 | func testTheKeychainKeepsAndForgets() throws { |
| 225 | guard ProcessInfo.processInfo.environment["CALLS_KEYCHAIN_TEST"] == "1" else { |
| 226 | throw XCTSkip("Set CALLS_KEYCHAIN_TEST=1 to use this machine's Keychain.") |
| 227 | } |
| 228 | let store = KeychainSessionStore(service: "net.amadan.oficina.calls.test-\(UUID().uuidString)") |
| 229 | defer { store.clear() } |
| 230 | XCTAssertNil(store.load()) |
| 231 | store.save(kept) |
| 232 | XCTAssertEqual(store.load(), kept) |
| 233 | let replacement = StoredSession(origin: "https://other.test", cookies: "[]") |
| 234 | store.save(replacement) |
| 235 | XCTAssertEqual(store.load(), replacement, "one sign-in at a time") |
| 236 | store.clear() |
| 237 | XCTAssertNil(store.load()) |
| 238 | } |
| 239 | } |
| 240 | |