| 1 | import Foundation |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | private let other = "https://calls.example.org" |
| 7 | private let kept = StoredSession( |
| 8 | origin: CallsServer.defaultOrigin, |
| 9 | cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| 10 | |
| 11 | /// A defaults suite of its own per test, so nothing here reads or writes |
| 12 | /// the setting of whoever runs the tests. |
| 13 | private func freshDefaults() -> UserDefaults { |
| 14 | let name = "CallsKitTests.\(UUID().uuidString)" |
| 15 | let defaults = UserDefaults(suiteName: name)! |
| 16 | defaults.removePersistentDomain(forName: name) |
| 17 | return defaults |
| 18 | } |
| 19 | |
| 20 | @MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| 21 | let deadline = Date().addingTimeInterval(5) |
| 22 | while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| 23 | } |
| 24 | |
| 25 | /// What a browser sign-in hands back, in the bridge's shape. |
| 26 | private func signInResult(origin: String, room: String = "") -> String { |
| 27 | #"{"origin":"\#(origin)","room":"\#(room)","cookies":[{"name":"__Host-rastrillo_session","value":"fresh"}]}"# |
| 28 | } |
| 29 | |
| 30 | final class DefaultServerTests: XCTestCase { |
| 31 | // MARK: The setting |
| 32 | |
| 33 | @MainActor func testTheServerIsCallsOficinaWorksUntilChosen() { |
| 34 | let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| 35 | XCTAssertEqual(CallsServer.defaultOrigin, "https://calls.oficina.works") |
| 36 | XCTAssertEqual(model.server, "https://calls.oficina.works") |
| 37 | XCTAssertEqual(model.serverHost, "calls.oficina.works") |
| 38 | } |
| 39 | |
| 40 | func testServerAddressesAreValidatedAndWrittenAsAnOrigin() { |
| 41 | let accepted: [(String, String)] = [ |
| 42 | ("https://calls.oficina.works", "https://calls.oficina.works"), |
| 43 | (" https://calls.example.org/ ", "https://calls.example.org"), |
| 44 | ("https://calls.example.org//", "https://calls.example.org"), |
| 45 | ("HTTPS://Calls.Example.org", "https://calls.example.org"), |
| 46 | ("calls.example.org", "https://calls.example.org"), |
| 47 | ("https://calls.example.org:8443", "https://calls.example.org:8443"), |
| 48 | ("http://localhost:8080", "http://localhost:8080"), |
| 49 | ("http://127.0.0.1:8080/", "http://127.0.0.1:8080"), |
| 50 | ] |
| 51 | for (text, origin) in accepted { |
| 52 | XCTAssertEqual(try? CallsServer.validate(text).get(), origin, text) |
| 53 | } |
| 54 | let refused: [(String, CallsServer.Problem)] = [ |
| 55 | ("", .empty), |
| 56 | (" ", .empty), |
| 57 | ("http://calls.example.org", .insecure), |
| 58 | ("ftp://calls.example.org", .notAnAddress), |
| 59 | ("https://", .notAnAddress), |
| 60 | ("not an address", .notAnAddress), |
| 61 | ("https://calls.example.org/m/abc123", .notJustTheServer), |
| 62 | ("https://calls.example.org/?next=1", .notJustTheServer), |
| 63 | ("https://calls.example.org#top", .notJustTheServer), |
| 64 | ("https://paul:secret@calls.example.org", .credentials), |
| 65 | ] |
| 66 | for (text, problem) in refused { |
| 67 | XCTAssertEqual(CallsServer.validate(text), .failure(problem), text) |
| 68 | XCTAssertFalse(problem.message.isEmpty) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func testTheSavedServerIsReadBackAndTheDefaultIsSavedAsNothing() { |
| 73 | let defaults = freshDefaults() |
| 74 | CallsServer.save(other, to: defaults) |
| 75 | XCTAssertEqual(CallsServer.load(from: defaults), other) |
| 76 | CallsServer.save(CallsServer.defaultOrigin, to: defaults) |
| 77 | XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| 78 | defaults.set("http://calls.example.org", forKey: CallsServer.key) |
| 79 | XCTAssertEqual( |
| 80 | CallsServer.load(from: defaults), CallsServer.defaultOrigin, |
| 81 | "an invalid saved value falls back to the default") |
| 82 | } |
| 83 | |
| 84 | // MARK: The signed-out home |
| 85 | |
| 86 | @MainActor func testSignedOutHomeIsTheServerAndNothingToType() async { |
| 87 | let server = ScriptedRemote() |
| 88 | let model = CallsModel( |
| 89 | store: MemorySessionStore(), remote: server.remote, defaults: freshDefaults()) |
| 90 | model.restore() |
| 91 | await settle { !model.restoring } |
| 92 | XCTAssertFalse(model.signedIn) |
| 93 | XCTAssertFalse(model.authenticating) |
| 94 | XCTAssertEqual(model.pendingLink, "") |
| 95 | XCTAssertEqual(model.address, "") |
| 96 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 97 | XCTAssertTrue(server.requests.isEmpty) |
| 98 | } |
| 99 | |
| 100 | @MainActor func testALinkOnTheServerIsASignInForTheServer() { |
| 101 | let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| 102 | model.openMeetingURL( |
| 103 | URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) |
| 104 | XCTAssertEqual(model.pendingLink, "https://calls.oficina.works/j/invitation") |
| 105 | XCTAssertTrue(model.pendingOnServer) |
| 106 | XCTAssertEqual(model.pendingHost, "calls.oficina.works") |
| 107 | XCTAssertFalse(model.authenticating, "the person sees where it is before a browser opens") |
| 108 | } |
| 109 | |
| 110 | // MARK: Changing the server |
| 111 | |
| 112 | @MainActor func testChangingTheServerSignsOutOfTheOldOne() async { |
| 113 | let defaults = freshDefaults() |
| 114 | let store = MemorySessionStore(kept) |
| 115 | let server = ScriptedRemote(checks: [.success("member")]) |
| 116 | let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| 117 | model.restore() |
| 118 | await settle { model.signedIn } |
| 119 | XCTAssertTrue(model.changingServerSignsOut, "the settings ask first") |
| 120 | XCTAssertNil(model.changeServer(to: " calls.example.org/ ")) |
| 121 | XCTAssertEqual(model.server, other) |
| 122 | XCTAssertEqual(defaults.string(forKey: CallsServer.key), other) |
| 123 | XCTAssertFalse(model.signedIn) |
| 124 | XCTAssertEqual(model.origin, "") |
| 125 | await settle { store.session == nil && server.requests.count == 2 } |
| 126 | XCTAssertNil(store.session, "the Keychain copy is forgotten") |
| 127 | XCTAssertEqual( |
| 128 | server.requests.last, "signout \(kept.origin) \(kept.cookies)", |
| 129 | "and the old server is asked to revoke it") |
| 130 | // A new model — the next launch — reads the new server. |
| 131 | XCTAssertEqual(CallsModel(store: store, defaults: defaults).server, other) |
| 132 | } |
| 133 | |
| 134 | @MainActor func testAnInvalidServerChangesNothing() async { |
| 135 | let defaults = freshDefaults() |
| 136 | let store = MemorySessionStore(kept) |
| 137 | let server = ScriptedRemote(checks: [.success("member")]) |
| 138 | let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| 139 | model.restore() |
| 140 | await settle { model.signedIn } |
| 141 | XCTAssertEqual(model.changeServer(to: "http://calls.example.org"), .insecure) |
| 142 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 143 | XCTAssertTrue(model.signedIn) |
| 144 | XCTAssertEqual(store.session, kept) |
| 145 | XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| 146 | } |
| 147 | |
| 148 | @MainActor func testTheServerIsNotChangedDuringACall() { |
| 149 | let defaults = freshDefaults() |
| 150 | let store = MemorySessionStore(kept) |
| 151 | let model = CallsModel(store: store, remote: ScriptedRemote().remote, defaults: defaults) |
| 152 | model.signedIn = true |
| 153 | model.joining = true |
| 154 | XCTAssertNil(model.changeServer(to: other)) |
| 155 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 156 | XCTAssertTrue(model.signedIn) |
| 157 | XCTAssertEqual(store.session, kept) |
| 158 | XCTAssertFalse(model.error.isEmpty) |
| 159 | } |
| 160 | |
| 161 | @MainActor func testResettingToTheDefaultRemovesTheSetting() { |
| 162 | let defaults = freshDefaults() |
| 163 | CallsServer.save(other, to: defaults) |
| 164 | let model = CallsModel(store: MemorySessionStore(), defaults: defaults) |
| 165 | XCTAssertEqual(model.server, other) |
| 166 | XCTAssertFalse(model.changingServerSignsOut) |
| 167 | XCTAssertNil(model.changeServer(to: CallsServer.defaultOrigin)) |
| 168 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 169 | XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| 170 | } |
| 171 | |
| 172 | @MainActor func testASessionKeptForAnotherServerIsSignedOutAtLaunch() async { |
| 173 | // The setting was changed outside the app, leaving the Keychain |
| 174 | // holding the old server's session. |
| 175 | let defaults = freshDefaults() |
| 176 | CallsServer.save(other, to: defaults) |
| 177 | let store = MemorySessionStore(kept) |
| 178 | let server = ScriptedRemote() |
| 179 | let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| 180 | model.restore() |
| 181 | await settle { !model.restoring && store.session == nil && !server.requests.isEmpty } |
| 182 | XCTAssertFalse(model.signedIn) |
| 183 | XCTAssertNil(store.session) |
| 184 | XCTAssertEqual(server.requests, ["signout \(kept.origin) \(kept.cookies)"]) |
| 185 | } |
| 186 | |
| 187 | // MARK: Guests and other servers |
| 188 | |
| 189 | func testInviteLinksAreValidatedInPlainWords() { |
| 190 | for text in [ |
| 191 | "https://calls.oficina.works/j/abcdef", "https://calls.example.org/m/abc123", |
| 192 | " https://calls.example.org/m/abc123/ ", "calls.example.org/j/token", |
| 193 | "http://127.0.0.1:8080/j/token", |
| 194 | ] { |
| 195 | XCTAssertNotNil(try? MeetingLink.validate(text).get(), text) |
| 196 | } |
| 197 | let refused: [(String, MeetingLink.Problem)] = [ |
| 198 | ("", .empty), |
| 199 | ("not a meeting", .notALink), |
| 200 | ("file:///j/token", .notALink), |
| 201 | ("http://calls.example.org/j/token", .insecure), |
| 202 | ("https://paul:secret@calls.example.org/j/token", .credentials), |
| 203 | ("https://calls.example.org", .notACall), |
| 204 | ("https://calls.example.org/settings", .notACall), |
| 205 | ("https://calls.example.org/j/token?x=1", .notACall), |
| 206 | ("https://calls.example.org/x/token", .notACall), |
| 207 | ] |
| 208 | for (text, problem) in refused { |
| 209 | XCTAssertEqual(MeetingLink.validate(text), .failure(problem), text) |
| 210 | XCTAssertFalse(problem.message.isEmpty) |
| 211 | } |
| 212 | XCTAssertEqual( |
| 213 | MeetingLink.validate("http://127.0.0.1:8080/j/token", allowLoopback: false), |
| 214 | .failure(.insecure), "a link from outside the app is https only") |
| 215 | } |
| 216 | |
| 217 | @MainActor func testAnInvalidGuestLinkStartsNothing() { |
| 218 | let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| 219 | XCTAssertEqual(model.joinAsGuest("https://calls.example.org/settings"), .notACall) |
| 220 | XCTAssertFalse(model.authenticating) |
| 221 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 222 | } |
| 223 | |
| 224 | @MainActor func testAGuestSessionIsNeverKeptEvenAMembersOnTheServer() async throws { |
| 225 | let store = MemorySessionStore() |
| 226 | let server = ScriptedRemote(checks: [.success("member")]) |
| 227 | let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| 228 | try model.adopt(signInResult(origin: CallsServer.defaultOrigin, room: "abc123"), visit: true) |
| 229 | XCTAssertTrue(model.signedIn) |
| 230 | XCTAssertTrue(model.visiting) |
| 231 | XCTAssertFalse(model.canStart, "a guest is here for one meeting") |
| 232 | XCTAssertEqual(model.room, "abc123") |
| 233 | try? await Task.sleep(for: .milliseconds(100)) |
| 234 | XCTAssertNil(store.session) |
| 235 | XCTAssertTrue(server.requests.isEmpty, "not even asked whether it is worth keeping") |
| 236 | } |
| 237 | |
| 238 | @MainActor func testASignInOnAnotherServerIsAVisitAndChangesNoSetting() async throws { |
| 239 | let defaults = freshDefaults() |
| 240 | let store = MemorySessionStore(kept) |
| 241 | let server = ScriptedRemote(checks: [.success("member")]) |
| 242 | let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| 243 | try model.adopt(signInResult(origin: other, room: "abc123"), visit: false) |
| 244 | XCTAssertTrue(model.visiting, "another server is a visit however it began") |
| 245 | try? await Task.sleep(for: .milliseconds(100)) |
| 246 | XCTAssertEqual(store.session, kept, "the server's own sign-in is not replaced") |
| 247 | XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| 248 | XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| 249 | } |
| 250 | |
| 251 | @MainActor func testTheServersOwnSignInIsKept() async throws { |
| 252 | let store = MemorySessionStore() |
| 253 | let server = ScriptedRemote(checks: [.success("member")]) |
| 254 | let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| 255 | try model.adopt(signInResult(origin: CallsServer.defaultOrigin), visit: false) |
| 256 | XCTAssertFalse(model.visiting) |
| 257 | XCTAssertTrue(model.canStart) |
| 258 | await settle { store.session != nil } |
| 259 | XCTAssertEqual(store.session?.origin, CallsServer.defaultOrigin) |
| 260 | } |
| 261 | |
| 262 | @MainActor func testEndingAVisitComesBackToTheMemberHome() async throws { |
| 263 | let store = MemorySessionStore(kept) |
| 264 | let server = ScriptedRemote(checks: [.success("member")]) |
| 265 | let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| 266 | try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) |
| 267 | model.signOut() |
| 268 | await settle { model.signedIn && !model.restoring } |
| 269 | XCTAssertFalse(model.visiting) |
| 270 | XCTAssertEqual(model.origin, CallsServer.defaultOrigin, "the kept sign-in is back") |
| 271 | XCTAssertEqual(store.session, kept) |
| 272 | XCTAssertTrue(server.requests.contains { $0.hasPrefix("signout \(other) ") }) |
| 273 | XCTAssertFalse(server.requests.contains { $0.hasPrefix("signout \(kept.origin)") }) |
| 274 | } |
| 275 | |
| 276 | @MainActor func testEndingAVisitWithNothingKeptIsTheSignedOutHome() async throws { |
| 277 | let store = MemorySessionStore() |
| 278 | let server = ScriptedRemote() |
| 279 | let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| 280 | try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) |
| 281 | model.leave() |
| 282 | await settle { !model.signedIn && !model.restoring } |
| 283 | XCTAssertFalse(model.signedIn) |
| 284 | XCTAssertFalse(model.visiting) |
| 285 | XCTAssertEqual(model.room, "") |
| 286 | XCTAssertNil(store.session) |
| 287 | } |
| 288 | } |
| 289 | |