import Foundation import HTTPTypes import Hummingbird import HummingbirdTesting import Testing @testable import TodoApp let origin = "http://127.0.0.1:8080" /// Runs `body` against the app's router on a fresh database. func withApp(_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void) async throws { try await withStore { store, _ in let options = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "unused"], environment: [:]) let app = Application(router: Server.router(store: store, options: options, version: "test-version")) try await app.test(.router, body) } } extension TestResponse { var text: String { body.getString(at: body.readerIndex, length: body.readableBytes) ?? "" } var listCookie: String? { guard let header = headers[.setCookie], header.hasPrefix("todo_list=") else { return nil } return String(header.prefix { $0 != ";" }) } } extension TestClientProtocol { /// Visits `/` and returns the cookie for a new, empty list. func newVisitor() async throws -> String { try await execute(uri: "/", method: .get) { try #require($0.listCookie) } } func post(_ path: String, _ form: String, cookie: String?, headers: HTTPFields = [:]) async throws -> TestResponse { var fields = headers fields[.contentType] = "application/x-www-form-urlencoded" if let cookie { fields[.cookie] = cookie } return try await execute(uri: path, method: .post, headers: fields, body: ByteBuffer(string: form)) } func get(_ path: String, cookie: String) async throws -> TestResponse { try await execute(uri: path, method: .get, headers: [.cookie: cookie]) } } func ids(_ html: String) -> [String] { html.split(separator: "data-id=\"").dropFirst().map { String($0.prefix { $0 != "\"" }) } } @Suite struct RouteTests { @Test func healthAndVersion() async throws { try await withApp { client in try await client.execute(uri: "/healthz", method: .get) { #expect($0.status == .ok) } try await client.execute(uri: "/api/version", method: .get) { #expect($0.status == .ok) #expect($0.text == "test-version") } } } @Test func firstVisitGetsAnHttpOnlyLaxCookie() async throws { try await withApp { client in try await client.execute(uri: "/", method: .get) { response in #expect(response.status == .ok) let header = try #require(response.headers[.setCookie]) let value = header.prefix { $0 != ";" }.dropFirst("todo_list=".count) #expect(Visitors.isWellFormed(String(value))) #expect(header.contains("HttpOnly")) #expect(header.contains("SameSite=Lax")) #expect(header.contains("Path=/")) #expect(!header.contains("Secure")) #expect(!response.text.contains("class=\"main\"")) #expect(response.text.contains("· Swift

")) } } } @Test(arguments: ["todo_list=%%%", "todo_list=0123456789abcdef0123456789abcdef", "todo_list="]) func unknownCookieGetsAFreshList(_ cookie: String) async throws { try await withApp { client in let response = try await client.get("/", cookie: cookie) #expect(response.status == .ok) #expect(response.listCookie != nil) #expect(response.listCookie != cookie) } } @Test func addRedirectsToTheFilterAndTrims() async throws { try await withApp { client in let me = try await client.newVisitor() for (filter, location) in [("all", "/"), ("active", "/active"), ("completed", "/completed"), ("bogus", "/"), ("", "/")] { let response = try await client.post("/todos", "title=++x++&filter=\(filter)", cookie: me) #expect(response.status == .seeOther) #expect(response.headers[.location] == location) } _ = try await client.post("/todos", "title=+++", cookie: me) let page = try await client.get("/", cookie: me).text #expect(page.components(separatedBy: "").count - 1 == 5) } } @Test func titlesAreEscapedAndKeptExactly() async throws { try await withApp { client in let me = try await client.newVisitor() let title = " & \"q\" naïve 🚀 日本語" let encoded = title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! _ = try await client.post("/todos", "title=\(encoded)", cookie: me) let response = try await client.get("/", cookie: me) #expect(response.text.contains("")) #expect(!response.text.contains("\(long)")) } } @Test func filtersShowTheirItems() async throws { try await withApp { client in let me = try await client.newVisitor() for t in ["a", "b", "c"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } let all = ids(try await client.get("/", cookie: me).text) _ = try await client.post("/todos/\(all[1])/toggle", "filter=all", cookie: me) let active = try await client.get("/active", cookie: me).text #expect(ids(active) == [all[0], all[2]]) #expect(active.contains("")) #expect(active.contains("2 items left")) #expect(active.contains("button class=\"clear-completed\"")) #expect(ids(try await client.get("/completed", cookie: me).text) == [all[1]]) } } @Test func editViewAndEdits() async throws { try await withApp { client in let me = try await client.newVisitor() for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } let all = ids(try await client.get("/", cookie: me).text) let edit = try await client.get("/active?edit=\(all[1])", cookie: me).text #expect(edit.contains("
  • ")) #expect(edit.contains("
    ")) #expect(edit.contains("href=\"/active?edit=\(all[0])\"")) let renamed = try await client.post("/todos/\(all[1])/edit", "title=++bee++&filter=active", cookie: me) #expect(renamed.status == .seeOther) #expect(renamed.headers[.location] == "/active") #expect(try await client.get("/", cookie: me).text.contains("")) _ = try await client.post("/todos/\(all[1])/edit", "title=+&filter=all", cookie: me) #expect(ids(try await client.get("/", cookie: me).text) == [all[0]]) } } @Test func toggleAllAndClearCompleted() async throws { try await withApp { client in let me = try await client.newVisitor() for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } _ = try await client.post("/todos/toggle-all", "filter=all", cookie: me) let page = try await client.get("/", cookie: me).text #expect(page.contains("class=\"toggle-all\" type=\"checkbox\" checked")) #expect(page.contains("0 items left")) _ = try await client.post("/todos/clear-completed", "filter=all", cookie: me) #expect(ids(try await client.get("/", cookie: me).text).isEmpty) } } @Test func anotherListsItemIs404AndUnchanged() async throws { try await withApp { client in let me = try await client.newVisitor() let them = try await client.newVisitor() _ = try await client.post("/todos", "title=mine", cookie: me) let id = ids(try await client.get("/", cookie: me).text)[0] for action in ["toggle", "edit", "destroy"] { #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: them).status == .notFound) #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: nil).status == .notFound) } #expect(try await client.post("/todos/99999/toggle", "filter=all", cookie: me).status == .notFound) #expect(try await client.get("/", cookie: me).text.contains("
  • ")) #expect(try await client.get("/", cookie: me).text.contains("")) } } @Test func crossOriginPostsAre403() async throws { try await withApp { client in let me = try await client.newVisitor() for bad in ["https://evil.example", "null", "http://127.0.0.1:8081", "https://127.0.0.1:8080"] { let response = try await client.post("/todos", "title=evil", cookie: me, headers: [.origin: bad]) #expect(response.status == .forbidden) } #expect(try await client.post("/todos", "title=ok", cookie: me, headers: [.origin: origin]).status == .seeOther) #expect(try await client.post("/todos", "title=ok2", cookie: me).status == .seeOther) let page = try await client.get("/", cookie: me).text #expect(!page.contains("evil")) #expect(ids(page).count == 2) } } @Test func partialHeaderAnswersWithTheNewListInstead() async throws { try await withApp { client in let me = try await client.newVisitor() let response = try await client.post("/todos", "title=a&filter=active", cookie: me, headers: [partialHeader: "1"]) #expect(response.status == .ok) #expect(response.text.hasPrefix("
    ")) #expect(response.text.contains("")) #expect(!response.text.contains("