steid

@jamesgill /

steid/tests/http.rs
Rust12.6 KBCode·Blame·History·Raw
1//! The first tests that speak HTTP.
2//!
3//! Everything below is a fact about a *response* — a header, a status, a body — and none
4//! of it was assertable until [`app::build`](steid::infrastructure::app::build) came out
5//! of `main.rs`. Both organisation reviews asked for this; §6 of the second one checked
6//! that `topcoat::serve_until` takes any `Listener` and that `tokio::net::TcpListener`
7//! is one, which is the whole of the mechanism here.
8//!
9//! **`tests/` rather than a `#[cfg(test)]` module inside `web/`**, for two reasons. It
10//! reaches the application exactly the way `main` does — through the public API, with no
11//! access to a private helper that could quietly make a test pass — and it keeps a
12//! server boot, a migration and a pool out of the unit-test binary, which runs 1,300
13//! tests in seven seconds and should keep doing so.
14//!
15//! **It does not replace `web/csrf_scan.rs`,** and could not: this exercises one refused
16//! POST, and the scan's guarantee is about the thirty-odd handlers it has never called.
17//! What it does do is close the scan's own stated gap — that it "has never executed the
18//! 403 page it protects" — so the two are complements. See the note in the CSRF test.
19
20use std::net::SocketAddr;
21
22use steid::{
23 application::AppConfig,
24 domain::SetupToken,
25 infrastructure::{app, database},
26};
27use tempfile::TempDir;
28use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle};
29use topcoat::asset::AssetBundle;
30
31/// A Steid serving on a port the operating system picked, against a database and a data
32/// directory that vanish with it.
33struct Instance {
34 base: String,
35 /// The claim token, live until the instance is claimed.
36 setup_token: String,
37 /// Held so the temporary directory outlives the server.
38 _dir: TempDir,
39 stop: Option<oneshot::Sender<()>>,
40 serving: Option<JoinHandle<()>>,
41}
42
43impl Instance {
44 /// A URL on this instance.
45 fn url(&self, path: &str) -> String {
46 format!("{}{path}", self.base)
47 }
48}
49
50impl Drop for Instance {
51 fn drop(&mut self) {
52 // The server holds the pool, which holds an open file in the temp directory the
53 // next line removes. Signalling here rather than in each test keeps every test
54 // to its assertions.
55 if let Some(stop) = self.stop.take() {
56 let _ = stop.send(());
57 }
58 if let Some(serving) = self.serving.take() {
59 serving.abort();
60 }
61 }
62}
63
64/// Boots one, unclaimed, and returns once it is accepting connections.
65async fn boot() -> Instance {
66 let dir = tempfile::tempdir().expect("a temp dir");
67 let data_dir = dir.path().join("data");
68 std::fs::create_dir_all(&data_dir).expect("mkdir");
69
70 let config = AppConfig {
71 database_url: format!("sqlite:{}?mode=rwc", dir.path().join("steid.db").display()),
72 data_dir,
73 // The default, and the one under test: the session cookie is `__Host-` prefixed
74 // and `Secure`. A test that flipped this would be testing the development
75 // fallback rather than what is deployed.
76 insecure_cookies: false,
77 allow_private_webhooks: false,
78 trusted_proxy: false,
79 // Absent, so the operator surface does not exist. `/metrics` answering 404 is the
80 // fact this makes assertable.
81 operator_token: None,
82 setup_token: None,
83 backup_marker: None,
84 smtp_url: None,
85 mail_from: None,
86 public_url: None,
87 };
88
89 let pool = database::connect(&config.database_url)
90 .await
91 .expect("the database opens");
92
93 let token = SetupToken::generate();
94 let setup_token = token.reveal().to_owned();
95
96 let router = app::build(config, pool, assets(), Some(token))
97 .await
98 .build();
99
100 // Port zero: the kernel picks, so tests never collide with each other or with a dev
101 // server on 3000.
102 let listener = TcpListener::bind("127.0.0.1:0").await.expect("a port");
103 let addr: SocketAddr = listener.local_addr().expect("the address it got");
104
105 let (stop, stopped) = oneshot::channel();
106 let serving = tokio::spawn(async move {
107 let _ = topcoat::serve_until(listener, router, async {
108 let _ = stopped.await;
109 })
110 .await;
111 });
112
113 Instance {
114 base: format!("http://{addr}"),
115 setup_token,
116 _dir: dir,
117 stop: Some(stop),
118 serving: Some(serving),
119 }
120}
121
122/// The asset bundle these tests serve.
123///
124/// Empty, deliberately: nothing asserted here renders a `view!`, so no page reaches
125/// `Asset`'s renderer and the catalog is never consulted. Loading the real bundle would
126/// tie `cargo test` to `topcoat asset bundle` having been run — a build step that is not
127/// part of the test gate — and the failure would be a `NotFound` at boot rather than
128/// anything about the assertion. The day a test here does render a page, this is where
129/// the real bundle goes.
130fn assets() -> AssetBundle {
131 // Leaked rather than held: `load_dir` reads the manifest and keeps only the path,
132 // and the directory has to outlive every instance in the process.
133 let dir = Box::leak(Box::new(tempfile::tempdir().expect("a temp dir")));
134 std::fs::write(
135 dir.path().join("manifest.toml"),
136 "version = 1\nassets = []\n",
137 )
138 .expect("the manifest is written");
139
140 AssetBundle::load_dir(dir.path()).expect("an empty bundle loads")
141}
142
143/// One response, reduced to what these tests ask about.
144struct Answer {
145 status: u16,
146 headers: Vec<(String, String)>,
147 body: String,
148}
149
150impl Answer {
151 /// The first value of a header, case-insensitively.
152 fn header(&self, name: &str) -> Option<&str> {
153 self.headers
154 .iter()
155 .find(|(key, _)| key.eq_ignore_ascii_case(name))
156 .map(|(_, value)| value.as_str())
157 }
158}
159
160/// Makes a request from a blocking thread.
161///
162/// A `body` makes it a POST and a `None` a GET, which is the whole of the verb handling
163/// these tests need.
164///
165/// `ureq` is the client already in the tree, and it is blocking — so it runs on a
166/// blocking thread rather than stalling the runtime worker the server is being polled
167/// on. Without `spawn_blocking` a single-threaded runtime deadlocks here.
168async fn request(url: String, body: Option<(&str, String)>) -> Answer {
169 let body = body.map(|(content_type, body)| (content_type.to_owned(), body));
170
171 tokio::task::spawn_blocking(move || {
172 let agent = ureq::Agent::config_builder()
173 // Every status these tests are about is a 4xx. The default collapses them
174 // into an error variant that carries no headers, which is exactly what is
175 // being asserted.
176 .http_status_as_error(false)
177 .max_redirects(0)
178 .build()
179 .new_agent();
180
181 let mut response = match body {
182 Some((content_type, body)) => agent
183 .post(&url)
184 .header("content-type", &content_type)
185 .send(&body)
186 .expect("the request completes"),
187 None => agent.get(&url).call().expect("the request completes"),
188 };
189
190 let status = response.status().as_u16();
191 let headers = response
192 .headers()
193 .iter()
194 .map(|(name, value)| {
195 (
196 name.as_str().to_owned(),
197 String::from_utf8_lossy(value.as_bytes()).into_owned(),
198 )
199 })
200 .collect();
201 let body = response.body_mut().read_to_string().unwrap_or_default();
202
203 Answer {
204 status,
205 headers,
206 body,
207 }
208 })
209 .await
210 .expect("the blocking thread finishes")
211}
212
213async fn get(instance: &Instance, path: &str) -> Answer {
214 request(instance.url(path), None).await
215}
216
217async fn post_form(instance: &Instance, path: &str, body: &str) -> Answer {
218 request(
219 instance.url(path),
220 Some(("application/x-www-form-urlencoded", body.to_owned())),
221 )
222 .await
223}
224
225async fn post_json(instance: &Instance, path: &str, body: String) -> Answer {
226 request(instance.url(path), Some(("application/json", body))).await
227}
228
229/// Claims the instance headlessly, as `install.sh` does.
230///
231/// Asserted rather than ignored: if this ever stops answering 201 the tests below are
232/// running against an unclaimed instance and would still pass, which is the worst kind
233/// of green.
234async fn claim(instance: &Instance) {
235 let body = format!(
236 r#"{{"setup_token":"{}","email":"ada@example.com","handle":"ada","password":"a-long-enough-password"}}"#,
237 instance.setup_token
238 );
239
240 let answer = post_json(instance, "/api/setup", body).await;
241
242 assert_eq!(answer.status, 201, "the claim was refused: {}", answer.body);
243 assert!(
244 answer.body.contains("\"handle\":\"ada\""),
245 "{}",
246 answer.body
247 );
248}
249
250#[tokio::test]
251async fn an_instance_boots_and_can_be_claimed_without_a_browser() {
252 let instance = boot().await;
253
254 let before = get(&instance, "/api/instance").await;
255 assert_eq!(before.status, 200);
256 assert_eq!(before.body.trim(), r#"{"claimed":false}"#);
257
258 claim(&instance).await;
259
260 let after = get(&instance, "/api/instance").await;
261 assert_eq!(after.status, 200);
262 assert_eq!(after.body.trim(), r#"{"claimed":true}"#);
263}
264
265#[tokio::test]
266async fn every_response_carries_the_security_headers_including_a_404() {
267 let instance = boot().await;
268
269 // A 404 specifically, because until the layer learned to build the response itself an
270 // error never passed through it: a 404 went out with no CSP, no `X-Frame-Options` and
271 // no `Referrer-Policy` at all. That regression is invisible to every other test.
272 for path in ["/api/instance", "/nothing-is-here"] {
273 let answer = get(&instance, path).await;
274
275 assert!(
276 answer.status == 200 || answer.status == 404,
277 "{path} answered {}",
278 answer.status
279 );
280
281 let policy = answer
282 .header("content-security-policy")
283 .unwrap_or_else(|| panic!("{path} carries no CSP"));
284 assert!(
285 policy.contains("default-src 'none'"),
286 "{path}'s CSP is {policy}"
287 );
288 assert!(
289 policy.contains("frame-ancestors 'none'"),
290 "{path}'s CSP is {policy}"
291 );
292
293 assert_eq!(
294 answer.header("x-content-type-options"),
295 Some("nosniff"),
296 "{path}"
297 );
298 assert_eq!(answer.header("x-frame-options"), Some("DENY"), "{path}");
299 // `no-referrer`, not the usual `strict-origin-when-cross-origin`: a private
300 // repository's URL contains its name, and a README may link anywhere.
301 assert_eq!(
302 answer.header("referrer-policy"),
303 Some("no-referrer"),
304 "{path}"
305 );
306 assert_eq!(
307 answer.header("cache-control"),
308 Some("private, no-cache"),
309 "{path}"
310 );
311 }
312}
313
314#[tokio::test]
315async fn a_post_without_a_csrf_token_is_refused_with_a_403() {
316 let instance = boot().await;
317
318 // `/auth/logout` because it is a guarded POST that needs no session to reach: the
319 // check runs before anything else it does. This is the first time the refusal has
320 // been *executed* — `csrf_scan` asserts that the line is written, which is a
321 // different guarantee and still the one that covers the other thirty handlers.
322 let empty = post_form(&instance, "/auth/logout", "").await;
323 assert_eq!(empty.status, 403, "{}", empty.body);
324 assert!(
325 empty.body.contains("submitted from somewhere else"),
326 "{}",
327 empty.body
328 );
329
330 let wrong = post_form(&instance, "/auth/logout", "csrf_token=not-a-token").await;
331 assert_eq!(wrong.status, 403, "{}", wrong.body);
332
333 // And the refusal is not an exception to the headers: it is built by hand rather than
334 // by the router, so it is exactly the shape of response that could miss them.
335 assert_eq!(empty.header("x-frame-options"), Some("DENY"));
336}
337
338#[tokio::test]
339async fn metrics_without_the_bearer_does_not_exist() {
340 let instance = boot().await;
341
342 // No `operator_token` is configured, so the surface is off. 404 rather than 401, and
343 // byte for byte what an unrouted path answers — a 401 would confirm there is a token
344 // to guess.
345 let metrics = get(&instance, "/metrics").await;
346 let unrouted = get(&instance, "/definitely-not-a-route").await;
347
348 assert_eq!(metrics.status, 404);
349 assert_eq!(metrics.status, unrouted.status);
350 assert_eq!(metrics.body, unrouted.body);
351
352 assert!(
353 !metrics.body.contains("steid_http_requests_total"),
354 "the counters leaked into the refusal: {}",
355 metrics.body
356 );
357}