Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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")
|
||||
)()
|
||||
|
||||
do {
|
||||
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)
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user