Implemented phase 2 for the POC prototype in the Website service.
This commit is contained in:
1 parent
09c7b8a1a7
commit
43dcc03da6
28 files changed
+1468
-65
No files matched your search
@@ -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()))
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user