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

234 lines
7.9 KiB
Swift

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()
}
}