Implemented phase 4 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:55:35 +02:00
1 parent 95e0a455e8
commit 9403931109
42 files changed
+3407 -36

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
"""Drives headless Chrome over the DevTools protocol with a stdlib-only WebSocket client, to run real WebAuthn ceremonies."""
import base64, json, os, socket, struct, sys, time, urllib.request
class CDP:
def __init__(self, url):
host, rest = url[len("ws://"):].split("/", 1)
name, port = host.split(":")
self.sock = socket.create_connection((name, int(port)))
key = base64.b64encode(os.urandom(16)).decode()
self.sock.sendall((f"GET /{rest} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
response = b""
while b"\r\n\r\n" not in response:
response += self.sock.recv(4096)
assert b" 101 " in response.split(b"\r\n")[0], response
self.buffer = response.split(b"\r\n\r\n", 1)[1]
self.next_id = 0
def _read(self, count):
while len(self.buffer) < count:
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("closed")
self.buffer += chunk
data, self.buffer = self.buffer[:count], self.buffer[count:]
return data
def _frame(self):
first, second = self._read(2)
length = second & 0x7F
if length == 126:
length = struct.unpack(">H", self._read(2))[0]
elif length == 127:
length = struct.unpack(">Q", self._read(8))[0]
return first & 0x0F, first & 0x80, self._read(length)
def _message(self):
payload = b""
while True:
opcode, final, data = self._frame()
if opcode in (0x0, 0x1, 0x2):
payload += data
if final:
return json.loads(payload)
def send(self, method, **params):
self.next_id += 1
data = json.dumps({"id": self.next_id, "method": method, "params": params}).encode()
mask = os.urandom(4)
header = bytes([0x81])
if len(data) < 126:
header += bytes([0x80 | len(data)])
elif len(data) < 65536:
header += bytes([0x80 | 126]) + struct.pack(">H", len(data))
else:
header += bytes([0x80 | 127]) + struct.pack(">Q", len(data))
self.sock.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
while True:
message = self._message()
if message.get("id") == self.next_id:
if "error" in message:
raise RuntimeError(f"{method}: {message['error']}")
return message.get("result", {})
def evaluate(self, expression):
result = self.send("Runtime.evaluate", expression=expression, awaitPromise=True, returnByValue=True)
if "exceptionDetails" in result:
raise RuntimeError(result["exceptionDetails"].get("exception", {}).get("description", str(result["exceptionDetails"])))
return result["result"].get("value")
def wait_for(self, expression, seconds=10):
deadline = time.time() + seconds
while time.time() < deadline:
try:
if self.evaluate(expression):
return True
except RuntimeError:
pass
time.sleep(0.2)
return False
def connect(port):
for _ in range(50):
try:
targets = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
page = next(t for t in targets if t["type"] == "page")
return CDP(page["webSocketDebuggerUrl"])
except Exception:
time.sleep(0.2)
sys.exit("Chrome's DevTools endpoint never answered")
@@ -0,0 +1,76 @@
"""Runs real passkey ceremonies against a locally running Website, in headless Chrome with a virtual authenticator.
swift run Website & # serves http://localhost:8080 over the in-memory database
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --remote-debugging-port=9333 \\
--user-data-dir="$(mktemp -d)" about:blank &
python3 Tests/Browser/passkeys.py
Needs a fresh server: it joins accounts that must not exist yet. Stdlib only.
"""
import os, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import connect
BASE = "http://localhost:8080"
chrome = connect(9333)
chrome.send("Page.enable")
chrome.send("Runtime.enable")
chrome.send("WebAuthn.enable")
authenticator = chrome.send("WebAuthn.addVirtualAuthenticator", options={
"protocol": "ctap2", "transport": "internal", "hasResidentKey": True,
"hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True,
})["authenticatorId"]
def open_sign_in(return_path):
chrome.send("Page.navigate", url=f"{BASE}/sign-in?return={return_path}")
assert chrome.wait_for("document.querySelector('[data-passkey]') && !document.querySelector('[data-passkey]').hidden"), \
"the passkey button never appeared"
def press(email):
chrome.evaluate(f"document.querySelector('#email').value = {email!r}; document.querySelector('[data-passkey]').click(); true")
def status():
return chrome.evaluate("document.querySelector('[data-passkey-status]')?.textContent ?? ''")
def credentials():
return chrome.send("WebAuthn.getCredentials", authenticatorId=authenticator)["credentials"]
results = []
def check(name, ok, detail=""):
results.append(ok)
print(f"{'PASS' if ok else 'FAIL'} {name}{' · ' + str(detail) if detail else ''}")
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
open_sign_in("/search")
press("sam.okafor@example.com")
landed = chrome.wait_for("location.pathname === '/search'")
check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname"))
check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
held = credentials()
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
# 2. Signed out, the same passkey signs in with no email at all.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/grading-guide")
press("")
landed = chrome.wait_for("location.pathname === '/grading-guide'")
check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "")
check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"])
# 3. By email, the ceremony names the account's credential.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/")
press("Sam.Okafor@Example.com")
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
# 4. A demo account has no passkey, and none can be added to it.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/")
press("mara.v@example.com")
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status())
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
sys.exit(0 if all(results) else 1)
@@ -175,6 +175,17 @@ struct CataloguePagesTests {
#expect(!html.contains("style="))
}
@Test
func `sign-in page hides the passkey button until its script finds WebAuthn, and keeps the demo sign-in working without it`() {
let html = SignInPage(returnPath: "/").render()
#expect(html.contains(#"type="button" hidden data-passkey"#))
#expect(html.contains("/js/passkey.js"))
#expect(html.contains(#"role="alert" data-passkey-status"#))
#expect(html.contains(#"type="submit">Demo sign-in, without a passkey</button>"#))
#expect(html.contains("joost@kestrelrow.example"))
}
@Test
func `sign-in page ties an error to the email field`() {
let html = SignInPage(
@@ -0,0 +1,118 @@
import Elementary
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Selling pages",
.tags(.page)
)
struct SellingPagesTests {
// MARK: GradingGuidePage tests
@Test
func `grading guide prints the whole scale and defines every defect`() {
let html = GradingGuidePage().render()
for step in Grading.scale {
#expect(html.contains("<strong>\(step.grade.gradeLabel)</strong><span>\(step.code)</span>"))
}
for defect in Grading.Defect.allCases {
#expect(html.contains(defect.label))
#expect(html.contains(defect.definition))
}
#expect(html.contains("Not at this grade"))
#expect(html.contains("/css/guide.css"))
#expect(!html.contains("noindex"))
#expect(!html.contains("style="))
}
// MARK: NewListingPage tests
@Test
func `new-listing form posts as multipart and offers the guide's vocabulary`() {
let html = NewListingPage(shop: .stub()).render()
#expect(html.contains(#"action="/sell/new" method="post" enctype="multipart/form-data""#))
#expect(html.contains(#"type="file" name="photos" accept="image/jpeg,image/png,image/webp" multiple"#))
#expect(html.contains(#"name="format" value="single-issues" checked"#))
#expect(html.contains(#"name="grading" value="slabbed" checked"#))
#expect(html.contains(#"<option value="9.4">9.4 · NM</option>"#))
#expect(html.contains(#"name="defects" value="crease""#))
#expect(html.contains(#"data-scope="collected""#))
#expect(html.contains(#"href="/grading-guide""#))
#expect(html.contains("Ships from Amsterdam, NL"))
#expect(!html.contains(#"role="alert""#))
#expect(!html.contains("style="))
}
@Test
func `new-listing form gives back what was typed, with each problem tied to its field`() {
let html = NewListingPage(
shop: .stub(),
form: ListingFormTests.form(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"], "notes": ["Light <wear>"]]),
problems: [.defects: "A colour-breaking crease caps a raw book at 7.0.", .price: "Enter the asking price in whole euros."]
).render()
#expect(html.contains("2 things to fix before publishing."))
#expect(html.contains("Photos have to be picked again"))
#expect(html.contains(#"name="series" value="Nightward""#))
#expect(html.contains(#"<option value="9.4" selected>"#))
#expect(html.contains(#"name="grading" value="raw" checked"#))
#expect(html.contains(#"name="defects" value="crease" checked"#))
#expect(html.contains(#"id="price-error">Enter the asking price in whole euros."#))
#expect(html.contains(#"aria-invalid="true" aria-describedby="price-error""#))
#expect(html.contains("Light &lt;wear&gt;"))
}
// MARK: Photo tests
@Test
func `listing shows its first photo in place of the placeholder cover`() {
let bare = ListingCard(.stub()).render()
let shot = ListingCard(.stub(photos: ["/uploads/a.jpg", "/uploads/b.jpg"])).render()
let page = ListingPage(listing: .stub(defects: ["Spine stress / ticks"], photos: ["/uploads/a.jpg", "/uploads/b.jpg"]), related: []).render()
#expect(bare.contains("cover--navy"))
#expect(!bare.contains("<img"))
#expect(shot.contains(#"<img class="photo" src="/uploads/a.jpg" alt="Photo of Nightward #1, front""#))
#expect(!shot.contains("cover--navy"))
#expect(page.contains(#"src="/uploads/b.jpg""#))
#expect(page.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
}
// MARK: MultipartForm tests
@Test
func `multipart form tells text from files, and drops what was left blank`() throws {
let body = [
"--x", #"Content-Disposition: form-data; name="series""#, "", " Nightward ",
"--x", #"Content-Disposition: form-data; name="defects""#, "", "crease",
"--x", #"Content-Disposition: form-data; name="defects""#, "", "foxing",
"--x", #"Content-Disposition: form-data; name="issue""#, "", "",
"--x", #"Content-Disposition: form-data; name="photos"; filename="front.jpg""#, "Content-Type: image/jpeg", "", "JPEGDATA",
"--x", #"Content-Disposition: form-data; name="photos"; filename="""#, "Content-Type: application/octet-stream", "", "",
"--x--", "",
].joined(separator: "\r\n")
let form = try #require(MultipartForm(body: .init(string: body), contentType: #"multipart/form-data; boundary="x""#))
#expect(form.value("series") == "Nightward")
#expect(form.values("defects") == ["crease", "foxing"])
#expect(form.value("issue") == nil)
#expect(form.values("photos").isEmpty)
#expect(form.files("photos").map { String(buffer: $0) } == ["JPEGDATA"])
#expect(ListingForm(form).has(.defects, value: "foxing"))
}
@Test
func `multipart form is nothing without a multipart content type`() {
#expect(MultipartForm(body: .init(string: "a=b"), contentType: "application/x-www-form-urlencoded") == nil)
#expect(MultipartForm(body: .init(), contentType: nil) == nil)
}
}
@@ -0,0 +1,119 @@
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"ListingForm type",
.tags(.type)
)
struct ListingFormTests {
// MARK: Methods tests
@Test
func `describes a slabbed single issue`() throws {
let draft = try Self.form().validated(photos: ["/uploads/a.jpg"], currentYear: 2026).get()
#expect(draft.series == "Nightward")
#expect(draft.issue == "1")
#expect(draft.grader == "CGC")
#expect(draft.grade == 9.8)
#expect(draft.condition == "NM/M")
#expect(draft.certNumber == "4312087-002")
#expect(draft.pageQuality == "White")
#expect(draft.priceCents == 24_000)
#expect(draft.shippingCents == 600)
#expect(draft.acceptsOffers)
#expect(draft.photos == ["/uploads/a.jpg"])
}
@Test
func `describes a raw single issue by the scale's code, dropping what only a slab states`() throws {
let draft = try Self.form(["grading": ["raw"], "grade": ["8.0"], "defects": ["spine-stress", "corner-wear", "bogus"]])
.validated(photos: [], currentYear: 2026)
.get()
#expect(draft.grader == nil)
#expect(draft.certNumber == nil)
#expect(draft.pageQuality == nil)
#expect(draft.condition == "VF")
#expect(draft.defects == ["Spine stress / ticks", "Corner wear / blunting"])
}
@Test
func `describes a collected edition by its condition, ungraded`() throws {
let draft = try Self.form(["format": ["hardcovers"], "issue": [], "condition": ["Sealed"], "offers": ["0"]])
.validated(photos: [], currentYear: 2026)
.get()
#expect(draft.issue.isEmpty)
#expect(draft.grade == nil)
#expect(draft.grader == nil)
#expect(draft.condition == "Sealed")
#expect(draft.edition.isEmpty)
#expect(!draft.acceptsOffers)
}
@Test
func `applies the grading guide to a raw book`() {
#expect(Self.problems(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"]]) == [.defects])
#expect(Self.problems(["grading": ["raw"], "grade": ["7.0"], "defects": ["crease"]]).isEmpty)
#expect(Self.problems(["grading": ["raw"], "defects": ["case-scuffs"]]) == [.defects])
// A slab's crease is the grader's business, and its case can be scuffed.
#expect(Self.problems(["defects": ["crease", "case-scuffs"]]).isEmpty)
}
@Test
func `names every field that is missing or out of range`() {
#expect(Self.problems([
"series": [], "publisher": [], "year": ["1850"], "issue": [], "grade": ["9.7"], "grader": ["ACME"],
"cert": [], "price": ["0"], "shipping": ["-1"],
]) == [.series, .publisher, .year, .issue, .grade, .grader, .cert, .price, .shipping])
#expect(Self.problems(["format": ["vinyl"]]) == [.format])
#expect(Self.problems(["format": ["manga"], "condition": ["Mint"]]) == [.condition])
#expect(Self.problems(["year": ["2028"]]) == [.year])
#expect(Self.problems(["price": ["12.50"]]) == [.price])
#expect(Self.problems(["notes": [String(repeating: "a", count: 1_001)]]) == [.notes])
}
@Test
func `starts out with the common choices made`() {
let form = ListingForm()
#expect(form.format == .singleIssues)
#expect(form.isSlabbed)
#expect(form.has(.offers, value: "1"))
#expect(form.value(.series) == nil)
}
}
// MARK: - Helpers
extension ListingFormTests {
/// A valid form for a slabbed Nightward #1, with the given fields replaced.
static func form(
_ overrides: [String: [String]] = [:]
) -> ListingForm {
.init(values: [
"format": ["single-issues"], "series": ["Nightward"], "issue": ["1"], "publisher": ["Meridian Comics"],
"year": ["2019"], "printing": ["1"], "edition": ["Direct"], "grading": ["slabbed"], "grade": ["9.8"],
"grader": ["CGC"], "page-quality": ["White"], "cert": ["4312087-002"], "price": ["240"],
"shipping": ["6"], "offers": ["1"],
].merging(overrides) { $1 })
}
/// The fields a form with the given overrides has problems with.
static func problems(
_ overrides: [String: [String]]
) -> Set<ListingForm.Field> {
guard case .failure(let problems) = form(overrides).validated(photos: [], currentYear: 2026) else {
return []
}
return Set(problems.fields.keys)
}
}
@@ -98,12 +98,14 @@ private extension StaticFileTests {
(.fontAnton, ["font/anton-400.woff2"]),
(.fontArchivo, ["font/archivo-variable.woff2"]),
(.fontOswald, ["font/oswald-variable.woff2"]),
(.guide, ["css/guide.css"]),
(.icon, ["icon.svg"]),
(.icon192, ["icon-192.png"]),
(.icon512, ["icon-512.png"]),
(.index, ["css/index.css", "js/index.js"]),
(.listing, ["css/listing.css"]),
(.notFound, ["css/not-found.css", "js/not-found.js"]),
(.passkey, ["js/passkey.js"]),
(.robots, ["robots.txt"]),
(.search, ["css/search.css"]),
(.shared, ["css/shared.css", "js/shared.js"]),
@@ -0,0 +1,239 @@
import Foundation
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"ListingFormController controller",
.tags(.controller)
)
struct ListingFormControllerTests {
// MARK: Constants
private let seller = "joost@kestrelrow.example"
/// The fields of a valid raw Nightward #3.
private let fields = [
("format", "single-issues"), ("series", "Nightward"), ("issue", "3"), ("publisher", "Meridian Comics"),
("year", "2019"), ("printing", "1"), ("grading", "raw"), ("grade", "9.4"), ("defects", "spine-stress"),
("notes", "Two light ticks."), ("price", "30"), ("shipping", "5"), ("offers", "1"),
]
// MARK: Functional tests
@Test
func `keeps the form to a shop's owner`() async throws {
try await withApp { client, _ in
try await client.execute(uri: "/sell/new", method: .get) { response in
#expect(response.status == .found)
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
}
let mara = try await signIn(client)
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: mara]) { response in
#expect(response.status == .forbidden)
}
let joost = try await signIn(client, email: seller)
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: joost]) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body).contains("Kestrel Row Comics · seller tools"))
}
}
}
@Test
func `publishes a listing with its photos, into the shop and the search`() async throws {
try await withApp { client, folder in
let joost = try await signIn(client, email: seller)
let location = try await post(client, fields, photos: [PhotoStoreTests.jpeg, PhotoStoreTests.png], as: joost) { response in
#expect(response.status == .seeOther)
return try #require(response.headers[.location])
}
#expect(location == "/listing/nightward-3-raw-nm")
try await client.execute(uri: location, method: .get) { response in
let body = String(buffer: response.body)
let photos = body.matches(of: /\/uploads\/[0-9a-f-]+\.(jpg|png)/).map { String($0.output.0) }
#expect(body.contains("Nightward #3"))
#expect(body.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
#expect(Set(photos).count == 2)
for photo in photos {
#expect(FileManager.default.fileExists(atPath: folder + photo))
}
}
try await client.execute(uri: "/search?q=nightward+3", method: .get) { response in
#expect(String(buffer: response.body).contains(#"href="/listing/nightward-3-raw-nm""#))
}
}
}
@Test
func `gives the form back with a 422, storing no photo, when a field is wrong`() async throws {
try await withApp { client, folder in
let joost = try await signIn(client, email: seller)
let wrong = fields.map { $0.0 == "grade" ? ("grade", "9.4") : $0 } + [("defects", "crease")]
try await post(client, wrong, photos: [PhotoStoreTests.jpeg], as: joost) { response in
let body = String(buffer: response.body)
#expect(response.status == .unprocessableContent)
#expect(body.contains("caps a raw book at 7.0"))
#expect(body.contains(#"name="series" value="Nightward""#))
}
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
@Test
func `turns down a file that is not an image, publishing nothing`() async throws {
try await withApp { client, _ in
let joost = try await signIn(client, email: seller)
try await post(client, fields, photos: [ByteBuffer(string: "<script>alert(1)</script>")], as: joost) { response in
#expect(response.status == .unprocessableContent)
#expect(String(buffer: response.body).contains("Photos have to be JPEG, PNG or WebP images."))
}
try await client.execute(uri: "/listing/nightward-3-raw-nm", method: .get) { response in
#expect(response.status == .notFound)
}
}
}
@Test
func `takes no listing from a signed-out visitor, a buyer, or a body that is not multipart`() async throws {
try await withApp { client, _ in
try await post(client, fields, photos: [], as: nil) { response in
#expect(response.status == .seeOther)
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
}
let mara = try await signIn(client)
try await post(client, fields, photos: [], as: mara) { response in
#expect(response.status == .forbidden)
}
let joost = try await signIn(client, email: seller)
try await client.execute(
uri: "/sell/new",
method: .post,
headers: [.cookie: joost, .contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "series=Nightward")
) { response in
#expect(response.status == .badRequest)
}
}
}
}
// MARK: - Helpers
private extension ListingFormControllerTests {
/// Posts the form as multipart — the fields, then the photos — as an account, or signed out.
func post<T>(
_ client: any TestClientProtocol,
_ fields: [(String, String)],
photos: [ByteBuffer],
as cookie: String?,
_ check: @escaping @Sendable (TestResponse) throws -> T
) async throws -> T {
var body = ByteBuffer()
for (name, value) in fields {
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
}
for (index, photo) in photos.enumerated() {
var photo = photo
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"p\(index)\"\r\nContent-Type: image/jpeg\r\n\r\n")
body.writeBuffer(&photo)
body.writeString("\r\n")
}
body.writeString("--bnd--\r\n")
var headers: HTTPFields = [.contentType: "multipart/form-data; boundary=bnd"]
headers[.cookie] = cookie
return try await client.execute(uri: "/sell/new", method: .post, headers: headers, body: body, testCallback: check)
}
/// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value.
func signIn(
_ client: any TestClientProtocol,
email: String = ""
) async throws -> String {
try await client.execute(
uri: "/sign-in",
method: .post,
headers: [.contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "email=\(email)&return=/")
) { response in
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
}
}
/// Runs the body against the session, catalogue, and listing form routes, over a seeded in-memory database and a temporary photo folder.
func withApp(
_ body: @escaping @Sendable (any TestClientProtocol, String) async throws -> Void
) async throws {
try await PhotoStoreTests.withFolder { folder in
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
do {
await PrepareDB()(for: fluent)
try await fluent.migrate()
let catalogue = CatalogueRepository(fluent: fluent)
let trade = TradeRepository(fluent: fluent)
let sessions = SessionStore(trade: trade)
let router = Router(context: WebsiteRequestContext.self)
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
router.addRoutes(ListingFormController<WebsiteRequestContext>(
catalogue: catalogue,
trade: trade,
sessions: sessions,
photos: .init(staticFilesPath: folder)
).routes)
try await Application(router: router).test(.router) { client in
try await body(client, folder)
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
}
@@ -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()
}
}
@@ -0,0 +1,87 @@
import Foundation
import Testing
@testable import WebsiteLibrary
@Suite(
"PasskeyCeremonies type",
.tags(.type)
)
struct PasskeyCeremoniesTests {
// MARK: Methods tests
@Test
func `answers a ceremony once`() async {
let ceremonies = PasskeyCeremonies()
let token = await ceremonies.start(.registration(email: "sam@example.com"), challenge: [1, 2, 3])
let first = await ceremonies.take(token)
let second = await ceremonies.take(token)
let unknown = await ceremonies.take("unknown")
#expect(first?.kind == .registration(email: "sam@example.com"))
#expect(first?.challenge == [1, 2, 3])
#expect(second == nil)
#expect(unknown == nil)
}
@Test
func `lets a ceremony run out after five minutes`() async {
let clock = Clock()
let ceremonies = PasskeyCeremonies(now: clock.now)
let early = await ceremonies.start(.authentication, challenge: [1])
let late = await ceremonies.start(.authentication, challenge: [2])
clock.advance(by: 299)
let inTime = await ceremonies.take(early)
clock.advance(by: 1)
let tooLate = await ceremonies.take(late)
#expect(inTime != nil)
#expect(tooLate == nil)
}
@Test
func `hands out unguessable tokens, in a cookie kept to the sign-in path`() async {
let ceremonies = PasskeyCeremonies()
let first = await ceremonies.start(.authentication, challenge: [])
let second = await ceremonies.start(.authentication, challenge: [])
let cookie = PasskeyCeremonies.cookie(first, isSecure: true).description
#expect(first.count == 64)
#expect(first != second)
#expect(cookie.contains("Path=/sign-in"))
#expect(cookie.contains("HttpOnly"))
#expect(cookie.contains("SameSite=Strict"))
#expect(cookie.contains("Secure"))
#expect(cookie.contains("Max-Age=300"))
#expect(PasskeyCeremonies.cookie("", isSecure: false).description.contains("Max-Age=0"))
}
}
// MARK: - Helpers
/// A clock the tests move by hand.
final class Clock: @unchecked Sendable {
private let lock = NSLock()
private var date = Date(timeIntervalSince1970: 1_790_000_000)
/// The clock's reading.
@Sendable
func now() -> Date {
lock.withLock { date }
}
/// Moves the clock forward.
func advance(
by interval: TimeInterval
) {
lock.withLock { date.addTimeInterval(interval) }
}
}
@@ -0,0 +1,114 @@
import Foundation
import NIOCore
import Testing
@testable import WebsiteLibrary
@Suite(
"PhotoStore type",
.tags(.type)
)
struct PhotoStoreTests {
// MARK: Constants
static let jpeg = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF, 0xE0] + Array(repeating: UInt8(0), count: 32))
static let png = ByteBuffer(bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + Array(repeating: UInt8(0), count: 32))
static let webp = ByteBuffer(bytes: Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBPVP8 ".utf8))
// MARK: Methods tests
@Test
func `stores photos under names of its own, by the type their bytes tell`() throws {
try Self.withFolder { folder in
let paths = try PhotoStore(staticFilesPath: folder).save([Self.jpeg, Self.png, Self.webp])
#expect(paths.map { $0.split(separator: ".").last } == ["jpg", "png", "webp"])
#expect(paths.allSatisfy { $0.hasPrefix("/uploads/") })
#expect(Set(paths).count == 3)
for path in paths {
#expect(FileManager.default.fileExists(atPath: folder + path))
}
}
}
@Test(arguments: [
ByteBuffer(string: "<script>alert(1)</script>"),
ByteBuffer(string: "GIF89a"),
ByteBuffer(bytes: [0xFF, 0xD8]),
ByteBuffer(string: "RIFF0000WAVE"),
])
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
file: ByteBuffer
) throws {
try Self.withFolder { folder in
#expect(throws: PhotoStore.Refusal.notAnImage) {
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
}
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
@Test
func `turns down too many photos, and one too large`() throws {
try Self.withFolder { folder in
let store = PhotoStore(staticFilesPath: folder)
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
#expect(throws: PhotoStore.Refusal.tooMany) {
try store.save(Array(repeating: Self.jpeg, count: PhotoStore.maxCount + 1))
}
#expect(throws: PhotoStore.Refusal.tooLarge) {
try store.save([large])
}
}
}
@Test
func `stores nothing, and makes no folder, without photos`() throws {
try Self.withFolder { folder in
let paths = try PhotoStore(staticFilesPath: folder).save([])
#expect(paths.isEmpty)
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
}
// MARK: - Helpers
extension PhotoStoreTests {
/// Runs the body against an empty temporary folder, removed after.
static func withFolder<T>(
_ body: (String) async throws -> T
) async rethrows -> T {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: folder)
}
return try await body(folder.path)
}
/// Runs the body against an empty temporary folder, removed after.
static func withFolder<T>(
_ body: (String) throws -> T
) rethrows -> T {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: folder)
}
return try body(folder.path)
}
}
@@ -14,7 +14,9 @@ extension Listing {
keyNotes: String? = nil,
priceCents: Int = 24_000,
acceptsOffers: Bool = true,
soldAt: Date? = nil
soldAt: Date? = nil,
defects: [String] = [],
photos: [String] = []
) -> Self {
.init(
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
@@ -25,7 +27,9 @@ extension Listing {
acceptsOffers: acceptsOffers,
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
listedAt: .init(timeIntervalSince1970: 0),
soldAt: soldAt
soldAt: soldAt,
defects: defects,
photos: photos
)
}
}