179 lines
6.6 KiB
Swift
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)
|
|
}
|
|
|
|
}
|