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(sessions: sessions).routes) router.addRoutes(CatalogueController(repository: catalogue, sessions: sessions).routes) router.addRoutes(SessionController( 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() } }