import Foundation import XCTest @testable import CallsKit /// The Keychain's stand-in: the same three calls, held in memory, and /// safe to reach from the detached tasks the model uses for the real one. final class MemorySessionStore: SessionStore, @unchecked Sendable { private let lock = NSLock() private var kept: StoredSession? init(_ kept: StoredSession? = nil) { self.kept = kept } var session: StoredSession? { lock.withLock { kept } } func load() -> StoredSession? { lock.withLock { kept } } func save(_ session: StoredSession) { lock.withLock { kept = session } } func clear() { lock.withLock { kept = nil } } } /// What the server said, scripted, and what the model asked it. final class ScriptedRemote: @unchecked Sendable { private let lock = NSLock() private var checks: [Result] private var opens: [String] private(set) var asked: [String] = [] init(checks: [Result] = [], opens: [String] = []) { self.checks = checks self.opens = opens } var requests: [String] { lock.withLock { asked } } var remote: SessionRemote { SessionRemote( check: { origin, cookies in try self.lock.withLock { self.asked.append("check \(origin) \(cookies)") return try self.checks.removeFirst().get() } }, open: { origin, _, address in self.lock.withLock { self.asked.append("open \(origin) \(address)") return self.opens.removeFirst() } }, signOut: { origin, cookies in self.lock.withLock { self.asked.append("signout \(origin) \(cookies)") } }) } } private let kept = StoredSession( origin: "https://calls.oficina.works", cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) private struct Unreachable: LocalizedError { var errorDescription: String? { "cannot reach Calls" } } /// The model's work here happens in tasks it starts itself; this waits /// for the state they leave rather than for the tasks, which are not /// the model's to hand out. @MainActor private func settle(_ done: @MainActor () -> Bool) async { let deadline = Date().addingTimeInterval(5) while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } } final class KeptSignInTests: XCTestCase { @MainActor func testNothingKeptIsTheSignInScreenAndAsksNobody() async { let server = ScriptedRemote() let model = CallsModel(store: MemorySessionStore(), remote: server.remote) model.restore() await settle { !model.restoring } XCTAssertFalse(model.signedIn) XCTAssertTrue(server.requests.isEmpty) } @MainActor func testAKeptMemberSessionLandsHomeWithStartOffered() async { let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) model.restore() XCTAssertTrue(model.restoring, "nothing is offered until the instance answers") await settle { !model.restoring } XCTAssertTrue(model.signedIn) XCTAssertTrue(model.canStart) XCTAssertFalse(model.unchecked) XCTAssertEqual(model.room, "") XCTAssertEqual(model.origin, kept.origin) XCTAssertEqual(server.requests, ["check \(kept.origin) \(kept.cookies)"]) } @MainActor func testAnEndedSessionIsForgottenAndPointsAtSignIn() async { let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("signed-out")]) let model = CallsModel(store: store, remote: server.remote) model.restore() await settle { !model.restoring && store.session == nil } XCTAssertFalse(model.signedIn) XCTAssertNil(store.session) XCTAssertEqual(model.pendingLink, "", "signing in again is the home's one button") XCTAssertTrue(model.error.contains("calls.oficina.works")) XCTAssertTrue(model.error.contains("Choose Sign in")) } @MainActor func testABadConnectionKeepsTheSessionAndOffersToCheckAgain() async { let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.failure(Unreachable()), .success("member")]) let model = CallsModel(store: store, remote: server.remote) model.restore() await settle { !model.restoring } XCTAssertEqual(store.session, kept, "no network says nothing about the session") XCTAssertTrue(model.signedIn) XCTAssertTrue(model.unchecked) XCTAssertTrue(model.error.contains("cannot reach Calls")) model.recheck() await settle { !model.restoring } XCTAssertFalse(model.unchecked) XCTAssertTrue(model.error.isEmpty) XCTAssertTrue(model.signedIn) } @MainActor func testASessionThatIsNoLongerAMembersIsNotKept() async { let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("participant")]) let model = CallsModel(store: store, remote: server.remote) model.restore() await settle { !model.restoring && store.session == nil } XCTAssertFalse(model.signedIn) XCTAssertNil(store.session) } @MainActor func testOnlyAFreshMembersSessionIsKept() async { for (answer, keptAfter, canStart) in [ (Result.success("member"), true, true), (.success("participant"), false, false), (.failure(Unreachable()), false, true), ] { let store = MemorySessionStore() let server = ScriptedRemote(checks: [answer]) let model = CallsModel(store: store, remote: server.remote) model.origin = kept.origin model.cookies = kept.cookies model.signedIn = true model.canStart = true model.keep(kept) await settle { !server.requests.isEmpty && (store.session != nil) == keptAfter } try? await Task.sleep(for: .milliseconds(50)) XCTAssertEqual(store.session != nil, keptAfter, "\(answer)") XCTAssertEqual(model.canStart, canStart, "\(answer)") } } @MainActor func testSigningOutForgetsAtOnceAndTellsTheInstance() async { let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote) model.restore() await settle { model.signedIn } model.room = "abc123" model.signOut() XCTAssertFalse(model.signedIn) XCTAssertFalse(model.canStart) XCTAssertEqual(model.room, "") XCTAssertEqual(model.origin, "") await settle { store.session == nil && server.requests.count == 2 } XCTAssertNil(store.session) XCTAssertEqual(server.requests.last, "signout \(kept.origin) \(kept.cookies)") } @MainActor func testSigningOutIsRefusedDuringACall() { let store = MemorySessionStore(kept) let model = CallsModel(store: store, remote: ScriptedRemote().remote) model.signedIn = true model.joining = true model.signOut() XCTAssertTrue(model.signedIn) XCTAssertEqual(store.session, kept) } @MainActor func testALinkOnTheSameInstanceOpensWithTheSessionNotTheBrowser() async { let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) model.restore() await settle { model.signedIn } model.room = "old123" model.openMeetingURL( URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) XCTAssertFalse(model.authenticating, "no browser sign-in for an instance already signed in to") XCTAssertTrue(model.signedIn) await settle { !model.opening } XCTAssertEqual(server.requests.last, "open \(kept.origin) https://calls.oficina.works/j/invitation") XCTAssertEqual(model.room, "", "the answer replaces the meeting in hand") XCTAssertEqual(model.address, "") } @MainActor func testALinkThatLaunchedTheAppWaitsForTheCheckAndThenOpens() async { let server = ScriptedRemote(checks: [.success("member")], opens: [#"{"room":""}"#]) let model = CallsModel(store: MemorySessionStore(kept), remote: server.remote) model.restore() model.openMeetingURL( URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fm%2Fabc123")!) XCTAssertFalse(model.authenticating) await settle { server.requests.count == 2 && !model.opening } XCTAssertEqual( server.requests, [ "check \(kept.origin) \(kept.cookies)", "open \(kept.origin) https://calls.oficina.works/m/abc123", ]) } @MainActor func testTheInstanceIsComparedWithItsPortAndWithoutItsPath() { XCTAssertEqual( CallsModel.instance(of: "https://calls.oficina.works/j/abc"), "https://calls.oficina.works") XCTAssertEqual(CallsModel.instance(of: "http://127.0.0.1:8080/m/x"), "http://127.0.0.1:8080") XCTAssertNil(CallsModel.instance(of: "not a meeting")) } /// Not a unit test: the real Keychain, on the machine running it. Opt /// in with CALLS_KEYCHAIN_TEST=1, because on a Mac a development build /// falls back to the login keychain and a CI runner may not have one. func testTheKeychainKeepsAndForgets() throws { guard ProcessInfo.processInfo.environment["CALLS_KEYCHAIN_TEST"] == "1" else { throw XCTSkip("Set CALLS_KEYCHAIN_TEST=1 to use this machine's Keychain.") } let store = KeychainSessionStore(service: "net.amadan.oficina.calls.test-\(UUID().uuidString)") defer { store.clear() } XCTAssertNil(store.load()) store.save(kept) XCTAssertEqual(store.load(), kept) let replacement = StoredSession(origin: "https://other.test", cookies: "[]") store.save(replacement) XCTAssertEqual(store.load(), replacement, "one sign-in at a time") store.clear() XCTAssertNil(store.load()) } }