carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import HTTPTypes
3import Hummingbird
4import HummingbirdTesting
5import Testing
6@testable import TodoApp
7
8let origin = "http://127.0.0.1:8080"
9
10/// Runs `body` against the app's router on a fresh database.
11func withApp(_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void) async throws {
12 try await withStore { store, _ in
13 let options = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "unused"], environment: [:])
14 let app = Application(router: Server.router(store: store, options: options, version: "test-version"))
15 try await app.test(.router, body)
16 }
17}
18
19extension TestResponse {
20 var text: String { body.getString(at: body.readerIndex, length: body.readableBytes) ?? "" }
21 var listCookie: String? {
22 guard let header = headers[.setCookie], header.hasPrefix("todo_list=") else { return nil }
23 return String(header.prefix { $0 != ";" })
24 }
25}
26
27extension TestClientProtocol {
28 /// Visits `/` and returns the cookie for a new, empty list.
29 func newVisitor() async throws -> String {
30 try await execute(uri: "/", method: .get) { try #require($0.listCookie) }
31 }
32
33 func post(_ path: String, _ form: String, cookie: String?, headers: HTTPFields = [:]) async throws -> TestResponse {
34 var fields = headers
35 fields[.contentType] = "application/x-www-form-urlencoded"
36 if let cookie { fields[.cookie] = cookie }
37 return try await execute(uri: path, method: .post, headers: fields, body: ByteBuffer(string: form))
38 }
39
40 func get(_ path: String, cookie: String) async throws -> TestResponse {
41 try await execute(uri: path, method: .get, headers: [.cookie: cookie])
42 }
43}
44
45func ids(_ html: String) -> [String] {
46 html.split(separator: "data-id=\"").dropFirst().map { String($0.prefix { $0 != "\"" }) }
47}
48
49@Suite struct RouteTests {
50 @Test func healthAndVersion() async throws {
51 try await withApp { client in
52 try await client.execute(uri: "/healthz", method: .get) { #expect($0.status == .ok) }
53 try await client.execute(uri: "/api/version", method: .get) {
54 #expect($0.status == .ok)
55 #expect($0.text == "test-version")
56 }
57 }
58 }
59
60 @Test func firstVisitGetsAnHttpOnlyLaxCookie() async throws {
61 try await withApp { client in
62 try await client.execute(uri: "/", method: .get) { response in
63 #expect(response.status == .ok)
64 let header = try #require(response.headers[.setCookie])
65 let value = header.prefix { $0 != ";" }.dropFirst("todo_list=".count)
66 #expect(Visitors.isWellFormed(String(value)))
67 #expect(header.contains("HttpOnly"))
68 #expect(header.contains("SameSite=Lax"))
69 #expect(header.contains("Path=/"))
70 #expect(!header.contains("Secure"))
71 #expect(!response.text.contains("class=\"main\""))
72 #expect(response.text.contains("· Swift</p>"))
73 }
74 }
75 }
76
77 @Test(arguments: ["todo_list=%%%", "todo_list=0123456789abcdef0123456789abcdef", "todo_list="])
78 func unknownCookieGetsAFreshList(_ cookie: String) async throws {
79 try await withApp { client in
80 let response = try await client.get("/", cookie: cookie)
81 #expect(response.status == .ok)
82 #expect(response.listCookie != nil)
83 #expect(response.listCookie != cookie)
84 }
85 }
86
87 @Test func addRedirectsToTheFilterAndTrims() async throws {
88 try await withApp { client in
89 let me = try await client.newVisitor()
90 for (filter, location) in [("all", "/"), ("active", "/active"), ("completed", "/completed"), ("bogus", "/"), ("", "/")] {
91 let response = try await client.post("/todos", "title=++x++&filter=\(filter)", cookie: me)
92 #expect(response.status == .seeOther)
93 #expect(response.headers[.location] == location)
94 }
95 _ = try await client.post("/todos", "title=+++", cookie: me)
96 let page = try await client.get("/", cookie: me).text
97 #expect(page.components(separatedBy: "<label>x</label>").count - 1 == 5)
98 }
99 }
100
101 @Test func titlesAreEscapedAndKeptExactly() async throws {
102 try await withApp { client in
103 let me = try await client.newVisitor()
104 let title = "<img src=x onerror=alert(1)> & \"q\" naïve 🚀 日本語"
105 let encoded = title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
106 _ = try await client.post("/todos", "title=\(encoded)", cookie: me)
107 let response = try await client.get("/", cookie: me)
108 #expect(response.text.contains("<label>&lt;img src=x onerror=alert(1)&gt; &amp; &quot;q&quot; naïve 🚀 日本語</label>"))
109 #expect(!response.text.contains("<img"))
110 #expect(response.headers[.contentLength] == String(response.body.readableBytes))
111 }
112 }
113
114 @Test func longTitlesWork() async throws {
115 try await withApp { client in
116 let me = try await client.newVisitor()
117 let long = String(repeating: "é", count: 10_000)
118 _ = try await client.post("/todos", "title=\(long.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", cookie: me)
119 #expect(try await client.get("/", cookie: me).text.contains("<label>\(long)</label>"))
120 }
121 }
122
123 @Test func filtersShowTheirItems() async throws {
124 try await withApp { client in
125 let me = try await client.newVisitor()
126 for t in ["a", "b", "c"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
127 let all = ids(try await client.get("/", cookie: me).text)
128 _ = try await client.post("/todos/\(all[1])/toggle", "filter=all", cookie: me)
129
130 let active = try await client.get("/active", cookie: me).text
131 #expect(ids(active) == [all[0], all[2]])
132 #expect(active.contains("<a class=\"selected\" href=\"/active\">"))
133 #expect(active.contains("<strong>2</strong> items left"))
134 #expect(active.contains("button class=\"clear-completed\""))
135 #expect(ids(try await client.get("/completed", cookie: me).text) == [all[1]])
136 }
137 }
138
139 @Test func editViewAndEdits() async throws {
140 try await withApp { client in
141 let me = try await client.newVisitor()
142 for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
143 let all = ids(try await client.get("/", cookie: me).text)
144
145 let edit = try await client.get("/active?edit=\(all[1])", cookie: me).text
146 #expect(edit.contains("<li class=\"editing\" data-id=\"\(all[1])\">"))
147 #expect(edit.contains("<form method=\"post\" action=\"/todos/\(all[1])/edit\"><input type=\"hidden\" name=\"filter\" value=\"active\"><input class=\"edit\" name=\"title\" value=\"b\" autocomplete=\"off\" autofocus>"))
148 #expect(edit.contains("href=\"/active?edit=\(all[0])\""))
149
150 let renamed = try await client.post("/todos/\(all[1])/edit", "title=++bee++&filter=active", cookie: me)
151 #expect(renamed.status == .seeOther)
152 #expect(renamed.headers[.location] == "/active")
153 #expect(try await client.get("/", cookie: me).text.contains("<label>bee</label>"))
154
155 _ = try await client.post("/todos/\(all[1])/edit", "title=+&filter=all", cookie: me)
156 #expect(ids(try await client.get("/", cookie: me).text) == [all[0]])
157 }
158 }
159
160 @Test func toggleAllAndClearCompleted() async throws {
161 try await withApp { client in
162 let me = try await client.newVisitor()
163 for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) }
164 _ = try await client.post("/todos/toggle-all", "filter=all", cookie: me)
165 let page = try await client.get("/", cookie: me).text
166 #expect(page.contains("class=\"toggle-all\" type=\"checkbox\" checked"))
167 #expect(page.contains("<strong>0</strong> items left"))
168 _ = try await client.post("/todos/clear-completed", "filter=all", cookie: me)
169 #expect(ids(try await client.get("/", cookie: me).text).isEmpty)
170 }
171 }
172
173 @Test func anotherListsItemIs404AndUnchanged() async throws {
174 try await withApp { client in
175 let me = try await client.newVisitor()
176 let them = try await client.newVisitor()
177 _ = try await client.post("/todos", "title=mine", cookie: me)
178 let id = ids(try await client.get("/", cookie: me).text)[0]
179 for action in ["toggle", "edit", "destroy"] {
180 #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: them).status == .notFound)
181 #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: nil).status == .notFound)
182 }
183 #expect(try await client.post("/todos/99999/toggle", "filter=all", cookie: me).status == .notFound)
184 #expect(try await client.get("/", cookie: me).text.contains("<li data-id=\"\(id)\">"))
185 #expect(try await client.get("/", cookie: me).text.contains("<label>mine</label>"))
186 }
187 }
188
189 @Test func crossOriginPostsAre403() async throws {
190 try await withApp { client in
191 let me = try await client.newVisitor()
192 for bad in ["https://evil.example", "null", "http://127.0.0.1:8081", "https://127.0.0.1:8080"] {
193 let response = try await client.post("/todos", "title=evil", cookie: me, headers: [.origin: bad])
194 #expect(response.status == .forbidden)
195 }
196 #expect(try await client.post("/todos", "title=ok", cookie: me, headers: [.origin: origin]).status == .seeOther)
197 #expect(try await client.post("/todos", "title=ok2", cookie: me).status == .seeOther)
198 let page = try await client.get("/", cookie: me).text
199 #expect(!page.contains("evil"))
200 #expect(ids(page).count == 2)
201 }
202 }
203
204 @Test func partialHeaderAnswersWithTheNewListInstead() async throws {
205 try await withApp { client in
206 let me = try await client.newVisitor()
207 let response = try await client.post("/todos", "title=a&filter=active", cookie: me, headers: [partialHeader: "1"])
208 #expect(response.status == .ok)
209 #expect(response.text.hasPrefix("<section class=\"main\">"))
210 #expect(response.text.contains("<a class=\"selected\" href=\"/active\">"))
211 #expect(!response.text.contains("<html"))
212 }
213 }
214
215 @Test func staticFilesAreServedVerbatim() async throws {
216 try await withApp { client in
217 try await client.execute(uri: "/static/index.css", method: .get) { response in
218 #expect(response.status == .ok)
219 #expect(response.headers[.contentType] == "text/css; charset=utf-8")
220 #expect(response.text == Assets.indexCSS)
221 }
222 try await client.execute(uri: "/static/base.css", method: .get) { #expect($0.text == Assets.baseCSS) }
223 }
224 }
225}
226