Files
ccn/Services/Website/Tests/Library/Cases/Public/Controllers/PasskeyControllerTests.swift
T

179 lines
6.6 KiB
Swift

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<String>()
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")
)()
defer {
try? await fluent.shutdown()
}
await PrepareDB()(for: fluent)
try await fluent.migrate()
let router = Router(context: WebsiteRequestContext.self)
router.addRoutes(PasskeyController<WebsiteRequestContext>(
origin: "http://localhost:8080",
accounts: .init(fluent: fluent),
sessions: .init()
).routes)
try await Application(router: router).test(.router, body)
}
}