import Foundation import Hummingbird import HummingbirdTesting import Logging import NIOCore import Persistence import Testing @testable import WebsiteLibrary /// The ceremonies' signatures need a WebAuthn client: `Tests/Browser/passkeys.py` runs them in Chrome. These cover what does not. @Suite( "PasskeyController controller", .tags(.controller) ) struct PasskeyControllerTests { // MARK: Options tests @Test func `offers whichever passkey the browser holds when given no email`() async throws { try await withApp { client in try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in let body = try Self.object(response) let options = try #require(body["options"] as? [String: Any]) #expect(response.status == .ok) #expect(response.headers[.cacheControl] == "no-store") #expect(body["mode"] as? String == "get") #expect(options["rpId"] as? String == "localhost") #expect((options["challenge"] as? String)?.isEmpty == false) #expect(options["allowCredentials"] == nil) #expect(response.headers[.setCookie]?.hasPrefix("ccn_ceremony=") == true) } } } @Test func `registers a passkey for an unknown email, normalized`() async throws { try await withApp { client in try await client.execute( uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: #"{"email": " Sam@Example.COM "}"#) ) { response in let body = try Self.object(response) let options = try #require(body["options"] as? [String: Any]) let user = try #require(options["user"] as? [String: Any]) let party = try #require(options["rp"] as? [String: Any]) #expect(body["mode"] as? String == "create") #expect(user["name"] as? String == "sam@example.com") #expect(user["displayName"] as? String == "sam") #expect(party["id"] as? String == "localhost") #expect(party["name"] as? String == "CCN") } } } @Test func `issues a fresh challenge every time`() async throws { try await withApp { client in var challenges = Set() for _ in 1...3 { let challenge = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in try #require((try Self.object(response)["options"] as? [String: Any])?["challenge"] as? String) } challenges.insert(challenge) } #expect(challenges.count == 3) } } @Test(arguments: zip( [#"{"email": "nope"}"#, #"{"email": "mara.v@example.com"}"#], [HTTPResponse.Status.unprocessableContent, .conflict] )) func `starts no ceremony for what is not an email, or for an account without a passkey`( body: String, expects status: HTTPResponse.Status ) async throws { try await withApp { client in try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: body)) { response in let error = try Self.object(response)["error"] as? String #expect(response.status == status) #expect(response.headers[.setCookie] == nil) #expect(error?.isEmpty == false) } } } // MARK: Finish tests @Test func `signs nobody in without a ceremony, or on an answer it cannot read — and hears a ceremony out once`() async throws { try await withApp { client in try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in #expect(response.status == .badRequest) #expect(response.headers[.setCookie] == nil) } let cookie = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) } var headers = Self.json headers[.cookie] = cookie try await client.execute( uri: "/sign-in/passkey/finish", method: .post, headers: headers, body: ByteBuffer(string: #"{"credential": {"id": "x"}}"#) ) { response in let error = try Self.object(response)["error"] as? String #expect(response.status == .badRequest) #expect(error == "That answer couldn't be read.") #expect(response.headers[values: .setCookie].allSatisfy { !$0.hasPrefix("ccn_session=") }) } try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: headers, body: ByteBuffer(string: "{}")) { response in let error = try Self.object(response)["error"] as? String #expect(error == "That sign-in ran out. Try again.") } } } } // MARK: - Helpers private extension PasskeyControllerTests { static let json: HTTPFields = [.contentType: "application/json"] /// The JSON object a response carries. static func object( _ response: TestResponse ) throws -> [String: Any] { try #require(try JSONSerialization.jsonObject(with: Data(buffer: response.body)) as? [String: Any]) } /// Runs the body against the passkey routes, over a migrated, seeded in-memory database. func withApp( _ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void ) async throws { let fluent = try Service( driver: .inMemory, logger: Logger(label: "test") )() do { await PrepareDB()(for: fluent) try await fluent.migrate() let router = Router(context: WebsiteRequestContext.self) router.addRoutes(PasskeyController( origin: "http://localhost:8080", accounts: .init(fluent: fluent), sessions: .init() ).routes) try await Application(router: router).test(.router, body) } catch { try? await fluent.shutdown() throw error } try await fluent.shutdown() } }