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

This commit is contained in:
javier committed 2026-09-21 11:44:58 +02:00
1 parent 09c7b8a1a7
commit 43dcc03da6
28 files changed
+1468 -65

No files matched your search

+30
View File
@@ -97,6 +97,36 @@ struct AppTests {
}
}
@Test
func `sign-in to start a session the other pages recognize`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
let cookie = try await client.execute(
uri: "/sign-in",
method: .post,
headers: [.contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "return=/listing/nightward-1-cgc-9-8")
) { response in
#expect(response.status == .seeOther)
#expect(response.headers[.location] == "/listing/nightward-1-cgc-9-8")
return String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
}
try await client.execute(
uri: "/listing/nightward-1-cgc-9-8",
method: .get,
headers: [.cookie: cookie]
) { response in
let body = String(buffer: response.body)
#expect(body.contains(#"aria-label="Account of Mara Visser""#))
#expect(body.contains(#"href="/listing/nightward-1-cgc-9-8/offer""#))
}
}
}
@Test
func `error page to be served for an unknown listing`() async throws {
try await app(
@@ -33,6 +33,8 @@ struct SiteHeaderTests {
#expect(html.contains(#"aria-label="Messages, 3 unread""#))
#expect(html.contains(#"href="/sell""#))
#expect(html.contains(">MV<"))
#expect(html.contains(">Mara<"))
#expect(html.contains(#"action="/sign-out" method="post""#))
#expect(!html.contains(#"href="/sign-in""#))
}
@@ -74,6 +74,30 @@ struct ListingDisplayTests {
#expect(cents.euros == price)
}
@Test(arguments: zip(
["/listing/a?x=1", "/", "//evil.example", "https://evil.example", "/\\evil.example", "listing/a", "", "/a\nb"],
["/listing/a?x=1", "/", "/", "/", "/", "/", "/", "/"]
))
func `follows a return path only within the site`(
_ path: String,
expects safe: String
) {
#expect(String.Route.safeReturn(path) == safe)
}
@Test
func `returns home without a return path`() {
#expect(String.Route.safeReturn(nil) == "/")
}
@Test
func `leads a gated action to itself signed in, and to the sign-in signed out`() {
let offer = String.Route.offer("a")
#expect(String.Route.gated(offer, from: "/listing/a", viewer: .init(displayName: "Mara")) == "/listing/a/offer")
#expect(String.Route.gated(offer, from: "/listing/a", viewer: nil) == "/sign-in?return=/listing/a")
}
@Test
func `returns to the gated action's path after sign-in`() {
#expect(String.Route.signIn(returningTo: "/shop/a?keys=1&page=2") == "/sign-in?return=/shop/a%3Fkeys%3D1%26page%3D2")
@@ -121,8 +121,71 @@ struct CataloguePagesTests {
#expect(html.contains("<dt>Condition</dt>"))
}
@Test
func `listing page opens its actions to a signed-in viewer`() {
let html = ListingPage(
listing: .stub(),
related: [],
viewer: .init(displayName: "Mara Visser")
).render()
#expect(html.contains(#"href="/listing/nightward-1/buy""#))
#expect(html.contains(#"href="/listing/nightward-1/offer""#))
#expect(html.contains(#"href="/messages/new?listing=nightward-1""#))
#expect(html.contains(#"aria-label="Account of Mara Visser""#))
#expect(!html.contains("gate-note"))
#expect(html.contains(#"disabled title="Not part of the prototype">Save"#))
}
// MARK: ShopPage tests
@Test
func `shop page opens its actions to a signed-in viewer`() {
let html = ShopPage(
shop: .stub(),
inventory: [],
matches: [],
parameters: .init(),
viewer: .init(displayName: "Mara Visser")
).render()
#expect(html.contains(#"href="/messages/new?shop=kestrel-row-comics""#))
#expect(!html.contains("Both need a passkey sign-in"))
#expect(!html.contains("/sign-in?return="))
}
// MARK: SignInPage tests
@Test
func `sign-in page is bare, states the intent, and carries the return path`() {
let html = SignInPage(
returnPath: "/listing/nightward-1",
intent: "To buy Nightward #1"
).render()
#expect(html.contains("To buy Nightward #1"))
#expect(html.contains(#"action="/sign-in" method="post""#))
#expect(html.contains(#"name="return" value="/listing/nightward-1""#))
#expect(html.contains(#"href="/listing/nightward-1" aria-label="Close and go back""#))
#expect(html.contains(#"content="noindex""#))
#expect(html.contains("/css/sign-in.css"))
#expect(!html.contains("site-header"))
#expect(!html.contains("site-footer"))
#expect(!html.contains("aria-invalid"))
#expect(!html.contains("style="))
}
@Test
func `sign-in page ties an error to the email field`() {
let html = SignInPage(
returnPath: "/",
error: "That doesn't look like an email address."
).render()
#expect(html.contains(#"aria-invalid="true" aria-describedby="email-error""#))
#expect(html.contains(#"id="email-error">That doesn't look like an email address.</p>"#))
}
@Test
func `shop page counts its tabs from the whole inventory and lists the matches`() {
let inventory = [
@@ -108,6 +108,7 @@ private extension StaticFileTests {
(.search, ["css/search.css"]),
(.shared, ["css/shared.css", "js/shared.js"]),
(.shop, ["css/shop.css"]),
(.signIn, ["css/sign-in.css"]),
(.site, ["site.webmanifest"]),
(.sitemap, ["sitemap.xml"]),
]
@@ -1,3 +1,4 @@
import Persistence
import Testing
@testable import WebsiteLibrary
@@ -8,6 +9,17 @@ import Testing
)
struct ViewerTests {
// MARK: Initializers tests
@Test
func `signs in as the account's display name`() {
let viewer = Viewer(User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"))
#expect(viewer.displayName == "Mara Visser")
#expect(viewer.newWantMatches == 0)
#expect(viewer.unreadMessages == 0)
}
// MARK: Computed tests
@Test(arguments: zip(
@@ -21,4 +33,15 @@ struct ViewerTests {
#expect(Viewer(displayName: displayName).initials == initials)
}
@Test(arguments: zip(
["Mara Visser", "Mara", ""],
["Mara", "Mara", ""]
))
func `first name`(
of displayName: String,
expects firstName: String
) {
#expect(Viewer(displayName: displayName).firstName == firstName)
}
}
@@ -0,0 +1,233 @@
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"SessionController controller",
.tags(.controller)
)
struct SessionControllerTests {
// MARK: Functional tests
@Test
func `serves the sign-in screen, never stored, stating what the visitor was about to do`() async throws {
try await withApp { client in
try await client.execute(
uri: "/sign-in?return=/listing/nightward-1-cgc-9-8",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.cacheControl] == "no-store")
#expect(body.contains("To buy, offer on or ask about Nightward #1"))
#expect(body.contains(#"name="return" value="/listing/nightward-1-cgc-9-8""#))
}
}
}
@Test
func `signs in the demo account without an email and returns to the intent, signed in`() async throws {
try await withApp { client in
let cookie = try await signIn(client, body: "return=/shop/kestrel-row-comics") { response in
#expect(response.status == .seeOther)
#expect(response.headers[.location] == "/shop/kestrel-row-comics")
}
try await client.execute(
uri: "/shop/kestrel-row-comics",
method: .get,
headers: [.cookie: cookie]
) { response in
let body = String(buffer: response.body)
#expect(response.headers[.cacheControl] == "private, no-cache")
#expect(body.contains(#"aria-label="Account of Mara Visser""#))
#expect(!body.contains("Both need a passkey sign-in"))
}
}
}
@Test
func `joins an unknown email, normalized, in the same step`() async throws {
try await withApp { client in
let cookie = try await signIn(client, body: "email=+Sam%40Example.COM+&return=/")
try await client.execute(
uri: "/search",
method: .get,
headers: [.cookie: cookie]
) { response in
#expect(String(buffer: response.body).contains(#"aria-label="Account of sam""#))
}
}
}
@Test
func `turns down what is not an email, and starts no session`() async throws {
try await withApp { client in
try await client.execute(
uri: "/sign-in",
method: .post,
headers: [.contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "email=nope&return=/search")
) { response in
let body = String(buffer: response.body)
#expect(response.status == .unprocessableContent)
#expect(response.headers[.setCookie] == nil)
#expect(body.contains(#"aria-invalid="true""#))
#expect(body.contains(#"name="return" value="/search""#))
}
}
}
@Test(arguments: ["//evil.example/x", "https://evil.example", "evil"])
func `returns home rather than off the site`(
returnPath: String
) async throws {
try await withApp { client in
_ = try await signIn(client, body: "return=\(returnPath)") { response in
#expect(response.headers[.location] == "/")
}
}
}
@Test
func `sends a signed-in visitor straight back from the sign-in screen`() async throws {
try await withApp { client in
let cookie = try await signIn(client, body: "return=/")
try await client.execute(
uri: "/sign-in?return=/search",
method: .get,
headers: [.cookie: cookie]
) { response in
#expect(response.status == .found)
#expect(response.headers[.location] == "/search")
}
}
}
@Test
func `signs out: ends the session, expires the cookie, and returns home`() async throws {
try await withApp { client in
let cookie = try await signIn(client, body: "return=/")
try await client.execute(
uri: "/sign-out",
method: .post,
headers: [.cookie: cookie]
) { response in
#expect(response.status == .seeOther)
#expect(response.headers[.location] == "/")
#expect(response.headers[.setCookie]?.contains("Max-Age=0") == true)
}
// The token itself is dead, not just forgotten by the browser.
try await client.execute(
uri: "/search",
method: .get,
headers: [.cookie: cookie]
) { response in
#expect(!String(buffer: response.body).contains("Account of"))
}
}
}
@Test
func `serves the landing page cached signed out, and rendered around the viewer signed in`() async throws {
try await withApp { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(response.headers[.cacheControl] == "public, no-cache")
#expect(response.headers[.eTag] != nil)
}
let cookie = try await signIn(client, body: "return=/")
try await client.execute(
uri: "/",
method: .get,
headers: [.cookie: cookie]
) { response in
#expect(response.headers[.cacheControl] == "private, no-cache")
#expect(response.headers[.eTag] == nil)
#expect(String(buffer: response.body).contains(#"aria-label="Account of Mara Visser""#))
}
}
}
}
// MARK: - Helpers
private extension SessionControllerTests {
/// Posts the sign-in form and returns the session cookie it sets, as a `Cookie` header value.
func signIn(
_ client: any TestClientProtocol,
body: String,
expecting: @escaping @Sendable (TestResponse) throws -> Void = { _ in }
) async throws -> String {
try await client.execute(
uri: "/sign-in",
method: .post,
headers: [.contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: body)
) { response in
try expecting(response)
let cookie = try #require(response.headers[.setCookie])
#expect(cookie.contains("HttpOnly"))
return String(cookie.prefix { $0 != ";" })
}
}
/// Runs the body against the session, catalogue, and root routes sharing one session store, 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 catalogue = CatalogueRepository(fluent: fluent)
let router = Router(context: WebsiteRequestContext.self)
let sessions = SessionStore()
router.addRoutes(RootController<WebsiteRequestContext>(sessions: sessions).routes)
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
router.addRoutes(SessionController<WebsiteRequestContext>(
sessions: sessions,
accounts: .init(fluent: fluent),
catalogue: catalogue
).routes)
try await Application(router: router).test(.router, body)
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
@@ -0,0 +1,96 @@
import Foundation
import Hummingbird
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"SessionStore type",
.tags(.type)
)
struct SessionStoreTests {
// MARK: Constants
private let user = User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser")
// MARK: Methods tests
@Test
func `starts a session under an unguessable, unique token`() async {
let sessions = SessionStore()
let first = await sessions.start(for: user)
let second = await sessions.start(for: user)
#expect(first.count == 64)
#expect(first.allSatisfy { $0.isHexDigit })
let found = await sessions.user(for: first)
#expect(first != second)
#expect(found == user)
}
@Test
func `forgets a session once ended`() async {
let sessions = SessionStore()
let token = await sessions.start(for: user)
await sessions.end(token)
let ended = await sessions.user(for: token)
let unknown = await sessions.user(for: "unknown")
#expect(ended == nil)
#expect(unknown == nil)
}
@Test
func `reads the viewer from the request's session cookie`() async {
let sessions = SessionStore()
let token = await sessions.start(for: user)
let signedIn = await sessions.viewer(for: request(cookie: "theme=dark; ccn_session=\(token)"))
let forged = await sessions.viewer(for: request(cookie: "ccn_session=forged"))
let signedOut = await sessions.viewer(for: request(cookie: nil))
#expect(signedIn?.displayName == "Mara Visser")
#expect(forged == nil)
#expect(signedOut == nil)
}
@Test
func `hides the cookie from scripts and other sites`() {
let cookie = SessionStore.cookie("abc", isSecure: true).description
#expect(cookie.hasPrefix("ccn_session=abc"))
#expect(cookie.contains("HttpOnly"))
#expect(cookie.contains("SameSite=Lax"))
#expect(cookie.contains("Secure"))
#expect(cookie.contains("Path=/"))
#expect(!SessionStore.cookie("abc", isSecure: false).description.contains("Secure"))
}
@Test
func `expires the cookie when handed no token`() {
#expect(SessionStore.cookie("", isSecure: false).description.contains("Max-Age=0"))
}
}
// MARK: - Helpers
private extension SessionStoreTests {
/// A request carrying the given `Cookie` header, or none.
func request(
cookie: String?
) -> Request {
var head = HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/")
head.headerFields[.cookie] = cookie
return .init(head: head, body: .init(buffer: .init()))
}
}