import Foundation import XCTest @testable import CallsKit private let other = "https://calls.example.org" private let kept = StoredSession( origin: CallsServer.defaultOrigin, cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) /// A defaults suite of its own per test, so nothing here reads or writes /// the setting of whoever runs the tests. private func freshDefaults() -> UserDefaults { let name = "CallsKitTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defaults.removePersistentDomain(forName: name) return defaults } @MainActor private func settle(_ done: @MainActor () -> Bool) async { let deadline = Date().addingTimeInterval(5) while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } } /// What a browser sign-in hands back, in the bridge's shape. private func signInResult(origin: String, room: String = "") -> String { #"{"origin":"\#(origin)","room":"\#(room)","cookies":[{"name":"__Host-rastrillo_session","value":"fresh"}]}"# } final class DefaultServerTests: XCTestCase { // MARK: The setting @MainActor func testTheServerIsCallsOficinaWorksUntilChosen() { let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) XCTAssertEqual(CallsServer.defaultOrigin, "https://calls.oficina.works") XCTAssertEqual(model.server, "https://calls.oficina.works") XCTAssertEqual(model.serverHost, "calls.oficina.works") } func testServerAddressesAreValidatedAndWrittenAsAnOrigin() { let accepted: [(String, String)] = [ ("https://calls.oficina.works", "https://calls.oficina.works"), (" https://calls.example.org/ ", "https://calls.example.org"), ("https://calls.example.org//", "https://calls.example.org"), ("HTTPS://Calls.Example.org", "https://calls.example.org"), ("calls.example.org", "https://calls.example.org"), ("https://calls.example.org:8443", "https://calls.example.org:8443"), ("http://localhost:8080", "http://localhost:8080"), ("http://127.0.0.1:8080/", "http://127.0.0.1:8080"), ] for (text, origin) in accepted { XCTAssertEqual(try? CallsServer.validate(text).get(), origin, text) } let refused: [(String, CallsServer.Problem)] = [ ("", .empty), (" ", .empty), ("http://calls.example.org", .insecure), ("ftp://calls.example.org", .notAnAddress), ("https://", .notAnAddress), ("not an address", .notAnAddress), ("https://calls.example.org/m/abc123", .notJustTheServer), ("https://calls.example.org/?next=1", .notJustTheServer), ("https://calls.example.org#top", .notJustTheServer), ("https://paul:secret@calls.example.org", .credentials), ] for (text, problem) in refused { XCTAssertEqual(CallsServer.validate(text), .failure(problem), text) XCTAssertFalse(problem.message.isEmpty) } } func testTheSavedServerIsReadBackAndTheDefaultIsSavedAsNothing() { let defaults = freshDefaults() CallsServer.save(other, to: defaults) XCTAssertEqual(CallsServer.load(from: defaults), other) CallsServer.save(CallsServer.defaultOrigin, to: defaults) XCTAssertNil(defaults.object(forKey: CallsServer.key)) defaults.set("http://calls.example.org", forKey: CallsServer.key) XCTAssertEqual( CallsServer.load(from: defaults), CallsServer.defaultOrigin, "an invalid saved value falls back to the default") } // MARK: The signed-out home @MainActor func testSignedOutHomeIsTheServerAndNothingToType() async { let server = ScriptedRemote() let model = CallsModel( store: MemorySessionStore(), remote: server.remote, defaults: freshDefaults()) model.restore() await settle { !model.restoring } XCTAssertFalse(model.signedIn) XCTAssertFalse(model.authenticating) XCTAssertEqual(model.pendingLink, "") XCTAssertEqual(model.address, "") XCTAssertEqual(model.server, CallsServer.defaultOrigin) XCTAssertTrue(server.requests.isEmpty) } @MainActor func testALinkOnTheServerIsASignInForTheServer() { let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) model.openMeetingURL( URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) XCTAssertEqual(model.pendingLink, "https://calls.oficina.works/j/invitation") XCTAssertTrue(model.pendingOnServer) XCTAssertEqual(model.pendingHost, "calls.oficina.works") XCTAssertFalse(model.authenticating, "the person sees where it is before a browser opens") } // MARK: Changing the server @MainActor func testChangingTheServerSignsOutOfTheOldOne() async { let defaults = freshDefaults() let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: defaults) model.restore() await settle { model.signedIn } XCTAssertTrue(model.changingServerSignsOut, "the settings ask first") XCTAssertNil(model.changeServer(to: " calls.example.org/ ")) XCTAssertEqual(model.server, other) XCTAssertEqual(defaults.string(forKey: CallsServer.key), other) XCTAssertFalse(model.signedIn) XCTAssertEqual(model.origin, "") await settle { store.session == nil && server.requests.count == 2 } XCTAssertNil(store.session, "the Keychain copy is forgotten") XCTAssertEqual( server.requests.last, "signout \(kept.origin) \(kept.cookies)", "and the old server is asked to revoke it") // A new model — the next launch — reads the new server. XCTAssertEqual(CallsModel(store: store, defaults: defaults).server, other) } @MainActor func testAnInvalidServerChangesNothing() async { let defaults = freshDefaults() let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: defaults) model.restore() await settle { model.signedIn } XCTAssertEqual(model.changeServer(to: "http://calls.example.org"), .insecure) XCTAssertEqual(model.server, CallsServer.defaultOrigin) XCTAssertTrue(model.signedIn) XCTAssertEqual(store.session, kept) XCTAssertNil(defaults.object(forKey: CallsServer.key)) } @MainActor func testTheServerIsNotChangedDuringACall() { let defaults = freshDefaults() let store = MemorySessionStore(kept) let model = CallsModel(store: store, remote: ScriptedRemote().remote, defaults: defaults) model.signedIn = true model.joining = true XCTAssertNil(model.changeServer(to: other)) XCTAssertEqual(model.server, CallsServer.defaultOrigin) XCTAssertTrue(model.signedIn) XCTAssertEqual(store.session, kept) XCTAssertFalse(model.error.isEmpty) } @MainActor func testResettingToTheDefaultRemovesTheSetting() { let defaults = freshDefaults() CallsServer.save(other, to: defaults) let model = CallsModel(store: MemorySessionStore(), defaults: defaults) XCTAssertEqual(model.server, other) XCTAssertFalse(model.changingServerSignsOut) XCTAssertNil(model.changeServer(to: CallsServer.defaultOrigin)) XCTAssertEqual(model.server, CallsServer.defaultOrigin) XCTAssertNil(defaults.object(forKey: CallsServer.key)) } @MainActor func testASessionKeptForAnotherServerIsSignedOutAtLaunch() async { // The setting was changed outside the app, leaving the Keychain // holding the old server's session. let defaults = freshDefaults() CallsServer.save(other, to: defaults) let store = MemorySessionStore(kept) let server = ScriptedRemote() let model = CallsModel(store: store, remote: server.remote, defaults: defaults) model.restore() await settle { !model.restoring && store.session == nil && !server.requests.isEmpty } XCTAssertFalse(model.signedIn) XCTAssertNil(store.session) XCTAssertEqual(server.requests, ["signout \(kept.origin) \(kept.cookies)"]) } // MARK: Guests and other servers func testInviteLinksAreValidatedInPlainWords() { for text in [ "https://calls.oficina.works/j/abcdef", "https://calls.example.org/m/abc123", " https://calls.example.org/m/abc123/ ", "calls.example.org/j/token", "http://127.0.0.1:8080/j/token", ] { XCTAssertNotNil(try? MeetingLink.validate(text).get(), text) } let refused: [(String, MeetingLink.Problem)] = [ ("", .empty), ("not a meeting", .notALink), ("file:///j/token", .notALink), ("http://calls.example.org/j/token", .insecure), ("https://paul:secret@calls.example.org/j/token", .credentials), ("https://calls.example.org", .notACall), ("https://calls.example.org/settings", .notACall), ("https://calls.example.org/j/token?x=1", .notACall), ("https://calls.example.org/x/token", .notACall), ] for (text, problem) in refused { XCTAssertEqual(MeetingLink.validate(text), .failure(problem), text) XCTAssertFalse(problem.message.isEmpty) } XCTAssertEqual( MeetingLink.validate("http://127.0.0.1:8080/j/token", allowLoopback: false), .failure(.insecure), "a link from outside the app is https only") } @MainActor func testAnInvalidGuestLinkStartsNothing() { let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) XCTAssertEqual(model.joinAsGuest("https://calls.example.org/settings"), .notACall) XCTAssertFalse(model.authenticating) XCTAssertEqual(model.server, CallsServer.defaultOrigin) } @MainActor func testAGuestSessionIsNeverKeptEvenAMembersOnTheServer() async throws { let store = MemorySessionStore() let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) try model.adopt(signInResult(origin: CallsServer.defaultOrigin, room: "abc123"), visit: true) XCTAssertTrue(model.signedIn) XCTAssertTrue(model.visiting) XCTAssertFalse(model.canStart, "a guest is here for one meeting") XCTAssertEqual(model.room, "abc123") try? await Task.sleep(for: .milliseconds(100)) XCTAssertNil(store.session) XCTAssertTrue(server.requests.isEmpty, "not even asked whether it is worth keeping") } @MainActor func testASignInOnAnotherServerIsAVisitAndChangesNoSetting() async throws { let defaults = freshDefaults() let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: defaults) try model.adopt(signInResult(origin: other, room: "abc123"), visit: false) XCTAssertTrue(model.visiting, "another server is a visit however it began") try? await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(store.session, kept, "the server's own sign-in is not replaced") XCTAssertEqual(model.server, CallsServer.defaultOrigin) XCTAssertNil(defaults.object(forKey: CallsServer.key)) } @MainActor func testTheServersOwnSignInIsKept() async throws { let store = MemorySessionStore() let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) try model.adopt(signInResult(origin: CallsServer.defaultOrigin), visit: false) XCTAssertFalse(model.visiting) XCTAssertTrue(model.canStart) await settle { store.session != nil } XCTAssertEqual(store.session?.origin, CallsServer.defaultOrigin) } @MainActor func testEndingAVisitComesBackToTheMemberHome() async throws { let store = MemorySessionStore(kept) let server = ScriptedRemote(checks: [.success("member")]) let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) model.signOut() await settle { model.signedIn && !model.restoring } XCTAssertFalse(model.visiting) XCTAssertEqual(model.origin, CallsServer.defaultOrigin, "the kept sign-in is back") XCTAssertEqual(store.session, kept) XCTAssertTrue(server.requests.contains { $0.hasPrefix("signout \(other) ") }) XCTAssertFalse(server.requests.contains { $0.hasPrefix("signout \(kept.origin)") }) } @MainActor func testEndingAVisitWithNothingKeptIsTheSignedOutHome() async throws { let store = MemorySessionStore() let server = ScriptedRemote() let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) model.leave() await settle { !model.signedIn && !model.restoring } XCTAssertFalse(model.signedIn) XCTAssertFalse(model.visiting) XCTAssertEqual(model.room, "") XCTAssertNil(store.session) } }