import XCTest import WebKit final class WorkspaceTests: XCTestCase { func testServerValidationAndOriginBoundary() throws { let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban ")) XCTAssertEqual(url.absoluteString, "https://example.com/kanban/") let server = WorkspaceServer(url: url, profile: UUID()) for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] { XCTAssertNil(WorkspaceServer.normalize(value), value) } for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] { XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value) } for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] { XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value) } } func testDashboardAddressPreservesTheInstallationPath() throws { let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/")) XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString, "https://example.com:8443/kanban/dashboard") } @MainActor func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws { let name = "KAN69-startup-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID()) let data = try JSONEncoder().encode(saved) defaults.set(data, forKey: "ios.activeServer") let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }) let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView) XCTAssertTrue(launcher.isHidden) XCTAssertTrue(loading.isAnimating) XCTAssertEqual(controller.server, saved) let webview = try XCTUnwrap(controller.workspace) XCTAssertFalse(webview.canGoBack) XCTAssertTrue(webview.backForwardList.backList.isEmpty) webview.stopLoading() let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "")) controller.webView(webview, didStartProvisionalNavigation: navigation) XCTAssertTrue(launcher.isHidden) controller.webView(webview, didFailProvisionalNavigation: navigation, withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)) XCTAssertFalse(launcher.isHidden) XCTAssertFalse(loading.isAnimating) XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved) controller.webView(webview, didFinish: navigation) XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure") controller.open(saved.url) XCTAssertTrue(launcher.isHidden) XCTAssertTrue(loading.isAnimating) XCTAssertEqual(controller.server?.profile, saved.profile) } @MainActor func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws { let name = "KAN69-setup-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] { defaults.set(data, forKey: "ios.activeServer") let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() XCTAssertNil(controller.workspace) let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }) XCTAssertFalse(launcher.isHidden) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() controller.open(URL(string: "https://first.invalid/")!) controller.open(URL(string: "https://second.invalid/app/")!) let restored = WorkspaceViewController(defaults: defaults) restored.loadViewIfNeeded() XCTAssertEqual(restored.server, controller.server) XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard") XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true) } func testExternalLinksStayOutsideTheSelectedServer() throws { let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID()) for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] { let url = try XCTUnwrap(URL(string: value)) XCTAssertTrue(WorkspaceServer.allowsExternal(url)) XCTAssertFalse(server.allows(url)) } for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] { XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value) } } @MainActor func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws { let name = "KAN71-history-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) let url = URL(string: "https://example.invalid/dashboard")! controller.open(URL(string: "https://example.invalid/")!) let webview = try XCTUnwrap(controller.workspace) webview.stopLoading() webview.frame = controller.view.bounds webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
Workspace
""") try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'") XCTAssertFalse(webview.canGoBack) XCTAssertNil(webview.goBack()) XCTAssertNil(webview.goForward()) try await waitFor(webview, "location.pathname === '/dashboard'") XCTAssertTrue(webview.allowsBackForwardNavigationGestures) XCTAssertFalse(webview.scrollView.bounces) XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:"))) let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation") XCTAssertEqual(customNavigation as? String, "undefined") let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") XCTAssertEqual(draft as? String, "unsaved") let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" }) XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" }) let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY") XCTAssertEqual(scroll as? String, "none") let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX") XCTAssertEqual(horizontal as? String, "auto") controller.open(URL(string: "https://other.invalid/")!) XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures, "Changing workspaces must retain native gesture navigation") } func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws { for body in [ #"{"version":2,"id":1,"action":"getState","payload":{}}"#, #"{"version":true,"id":1,"action":"getState","payload":{}}"#, #"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#, #"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#, #"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#, #"{"version":1,"id":1,"action":"shell","payload":{}}"#, ] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) } XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193))) XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2) } @MainActor func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws { let name = "KAN70-settings-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) let url = URL(string: "https://example.invalid/")! controller.open(url) let originalProfile = controller.server!.profile let webview = try XCTUnwrap(controller.workspace) webview.stopLoading() webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "Login") try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'") let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any] XCTAssertEqual(state?["platform"] as? String, "ios") XCTAssertEqual(state?["workspace"] as? String, url.absoluteString) XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"]) XCTAssertNil(state?["updates"]) let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery") let rejected = try await callScript(webview, """ try { await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}})); return false; } catch { return true; } """) XCTAssertEqual(rejected as? Bool, true) XCTAssertFalse(toolbar.isHidden) _ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')") controller.view.layoutIfNeeded() XCTAssertTrue(toolbar.isHidden) XCTAssertEqual(toolbar.frame.height, 0) XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1) let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }") XCTAssertEqual(invalid as? Bool, true) XCTAssertEqual(controller.server?.url, url) _ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')") XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden) _ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})") for _ in 0..<50 { if controller.workspace !== webview { break } try await Task.sleep(for: .milliseconds(20)) } XCTAssertFalse(controller.workspace === webview) XCTAssertNotEqual(controller.server?.profile, originalProfile) let restored = WorkspaceViewController(defaults: defaults) restored.loadViewIfNeeded() XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/") let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }") XCTAssertEqual(stale as? Bool, true) XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/") } @MainActor private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any { try await withCheckedThrowingContinuation { continuation in webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in continuation.resume(with: result) } } } @MainActor private func waitFor(_ webview: WKWebView, _ expression: String) async throws { for _ in 0..<100 { if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return } try await Task.sleep(for: .milliseconds(50)) } XCTFail("WebView condition timed out: \(expression)") } func testServerChangesNeverReusePreviousProfile() throws { let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/")) let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/")) let first = WorkspaceServer.selecting(firstURL, current: nil) XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first) let second = WorkspaceServer.selecting(otherURL, current: first) XCTAssertNotEqual(second.profile, first.profile) XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile) XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first) } func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() { var state = WorkspaceConnection() let first = state.begin() let second = state.begin() XCTAssertFalse(state.fail(first)) state.finish() XCTAssertFalse(state.fail(second)) let third = state.begin() XCTAssertTrue(state.fail(third)) XCTAssertTrue(state.ready) XCTAssertFalse(state.fail(third)) } @MainActor func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws { let profile = UUID() let first = WKWebViewConfiguration() first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile) let cookie = try XCTUnwrap(HTTPCookie(properties: [ .domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session", .secure: "TRUE", .expires: Date().addingTimeInterval(3600), ])) await first.websiteDataStore.httpCookieStore.setCookie(cookie) let restored = WKWebViewConfiguration() restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile) let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies() XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" }) let other = WKWebsiteDataStore(forIdentifier: UUID()) let otherCookies = await other.httpCookieStore.allCookies() XCTAssertTrue(otherCookies.isEmpty) await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie) let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies() XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" }) } @MainActor func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws { let name = "KAN54-tests-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) controller.open(url) let server = try XCTUnwrap(controller.server) XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile) XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2) let restored = WorkspaceViewController(defaults: defaults) restored.loadViewIfNeeded() XCTAssertEqual(restored.server, server) let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/")) restored.open(other) XCTAssertNotEqual(restored.server?.profile, server.profile) } @MainActor func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws { let name = "KAN54-form-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) controller.open(url) let webview = try XCTUnwrap(controller.workspace) webview.stopLoading() let navigation = try XCTUnwrap(webview.loadHTMLString("", baseURL: url)) var loaded = false for _ in 0..<50 { if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), value as? String == "unsaved ticket" { loaded = true; break } try await Task.sleep(for: .milliseconds(100)) } XCTAssertTrue(loaded) controller.webView(webview, didFinish: navigation) controller.webView(webview, didStartProvisionalNavigation: navigation) controller.webView(webview, didFailProvisionalNavigation: navigation, withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)) XCTAssertTrue(webview.isHidden) controller.open(url) XCTAssertTrue(controller.workspace === webview) XCTAssertFalse(webview.isHidden) let value = try await webview.evaluateJavaScript("document.getElementById('draft').value") XCTAssertEqual(value as? String, "unsaved ticket") let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__") XCTAssertEqual(bridge as? String, "undefined") } }