steid

@jamesgill /

Rust7.7 KBCode·Blame·History·Raw
1//! A test that reads the web layer's own source and fails when a form handler forgets.
2//!
3//! Everything the CSRF check protects depends on being called, and it is called by hand
4//! in thirty-odd handlers. Nothing in the type system says a new `#[page(POST …)]` has
5//! to call it — the next form would compile, work, and be unprotected, and no test
6//! would notice because a test written for that form would not think to check.
7//!
8//! So this scans `src/infrastructure/web/*.rs` for POST routes and asserts that each
9//! one's handler body either calls `csrf_verified` or is named in
10//! [`EXEMPT`](super::csrf::EXEMPT). A textual check, deliberately: the thing being
11//! guaranteed is textual — that somebody wrote the line — and a check that needed the
12//! handler to run would need every handler's fixtures.
13//!
14//! **`tests/http.rs` now executes the refusal**, which this never could: it POSTs to
15//! `/auth/logout` with no token and asserts the 403, its sentence, and that the response
16//! still carries the security headers. That closes the one gap this file's own review
17//! named — that it "has never executed the 403 page it protects" — and it replaces
18//! nothing here. A real request covers one handler; the scan covers the thirty-odd it
19//! will never have fixtures for, and a handler added without the check would still
20//! compile, work, and be caught only by the scan.
21//!
22//! It carries a second rule for the same reason. A form POST must end in a **303**, and
23//! `redirect()` is a 307 that makes the browser re-POST — the mistake CLAUDE.md warns
24//! about, which reached `/auth/setup` and `/auth/login` in turn. A POST handler that
25//! names `redirect(` fails here.
26
27use std::{
28 fs,
29 path::{Path, PathBuf},
30};
31
32use super::csrf::EXEMPT;
33
34/// The directory this scans: the web layer, and nothing below it.
35fn web_dir() -> PathBuf {
36 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/infrastructure/web")
37}
38
39/// Every `.rs` file under `dir`, at any depth.
40///
41/// Recursive on purpose: the day a page module becomes a directory (`web/pulls/`,
42/// `web/api/`), its handlers must not silently leave the scan — and the floor guard
43/// below would not notice a handful going missing.
44fn rust_files_under(dir: &Path, out: &mut Vec<PathBuf>) {
45 for entry in fs::read_dir(dir).expect("the directory is readable") {
46 let path = entry.expect("a directory entry").path();
47 if path.is_dir() {
48 rust_files_under(&path, out);
49 } else if path.extension().is_some_and(|ext| ext == "rs") {
50 out.push(path);
51 }
52 }
53}
54
55/// One POST route: where it is, what it answers on, and the text of its handler.
56struct Handler {
57 file: String,
58 route: String,
59 body: String,
60}
61
62/// Every `#[page(POST …)]` and `#[route(POST …)]` in the web layer.
63///
64/// A handler's body is taken as everything from the attribute to the first line that is
65/// a bare `}` in column zero — which is what `cargo fmt` guarantees the end of a
66/// top-level item looks like, and this is a formatted codebase.
67fn handlers() -> Vec<Handler> {
68 let mut found = Vec::new();
69
70 let root = web_dir();
71 let mut files = Vec::new();
72 rust_files_under(&root, &mut files);
73 files.sort();
74
75 for path in files {
76 // Relative to the web directory, so a handler in `pulls/writes.rs` is named as
77 // such rather than colliding with another module's `writes.rs`.
78 let file = path
79 .strip_prefix(&root)
80 .expect("under the web directory")
81 .to_string_lossy()
82 .into_owned();
83 let source = fs::read_to_string(&path).expect("the file is readable");
84 let lines: Vec<&str> = source.lines().collect();
85
86 for (start, line) in lines.iter().enumerate() {
87 let Some(route) = post_route(line) else {
88 continue;
89 };
90
91 let end = lines[start..]
92 .iter()
93 .position(|line| *line == "}")
94 .map_or(lines.len(), |offset| start + offset);
95
96 found.push(Handler {
97 file: file.clone(),
98 route,
99 body: lines[start..=end.min(lines.len() - 1)].join("\n"),
100 });
101 }
102 }
103
104 found
105}
106
107/// The path out of `#[page(POST "…")]` or `#[route(POST "…")]`, if the line is one.
108fn post_route(line: &str) -> Option<String> {
109 let rest = line
110 .strip_prefix("#[page(POST ")
111 .or_else(|| line.strip_prefix("#[route(POST "))?;
112
113 Some(rest.trim_start_matches('"').split('"').next()?.to_owned())
114}
115
116/// Whether a route is on the exemption list.
117fn exempt(route: &str) -> bool {
118 EXEMPT.iter().any(|(exempt, _)| *exempt == route)
119}
120
121#[test]
122fn every_post_route_is_scanned() {
123 // A guard on the scanner itself: a change to `#[page]`'s spelling that made
124 // `post_route` match nothing would otherwise turn this whole file into a test that
125 // passes by finding nothing to check.
126 let handlers = handlers();
127
128 assert!(
129 handlers.len() >= 30,
130 "found only {} POST routes, which means the scanner stopped matching",
131 handlers.len()
132 );
133}
134
135#[test]
136fn every_post_handler_checks_the_csrf_token() {
137 for handler in handlers() {
138 if exempt(&handler.route) {
139 assert!(
140 !handler.body.contains("csrf_verified"),
141 "{} answers {} and is on the exemption list, yet checks the token — \
142 remove it from `csrf::EXEMPT`",
143 handler.file,
144 handler.route,
145 );
146 continue;
147 }
148
149 assert!(
150 handler.body.contains("csrf_verified"),
151 "{} answers POST {} without calling `csrf_verified` — add the check, and \
152 `csrf_field()` to the form it serves. If it genuinely cannot carry a \
153 token, add it to `csrf::EXEMPT` with the reason.",
154 handler.file,
155 handler.route,
156 );
157 }
158}
159
160#[test]
161fn no_post_handler_redirects_with_a_307() {
162 for handler in handlers() {
163 assert!(
164 !handler.body.contains("redirect("),
165 "{} answers POST {} with `redirect(`, which is a 307 — the browser \
166 re-POSTs the form to the target. Use `see_other` in a `#[route]`, or \
167 `StatusCode::SEE_OTHER` with `context::location` inside `view!`.",
168 handler.file,
169 handler.route,
170 );
171 }
172}
173
174#[test]
175fn every_exempt_route_exists() {
176 // An exemption for a route that has been renamed or removed is an exemption that
177 // silently stops describing anything, and the next route to take that path inherits
178 // it.
179 let routes: Vec<String> = handlers().into_iter().map(|found| found.route).collect();
180
181 for (route, _) in EXEMPT {
182 assert!(
183 routes.iter().any(|found| found == route),
184 "`csrf::EXEMPT` names POST {route}, which no handler answers"
185 );
186 }
187}
188
189#[test]
190fn the_scan_descends_into_subdirectories() {
191 // Splitting a page module into a directory must not drop its handlers from the scan.
192 let dir = tempfile::tempdir().expect("a temp dir");
193 let nested = dir.path().join("pulls");
194 fs::create_dir(&nested).expect("mkdir");
195 fs::write(nested.join("writes.rs"), "// a file\n").expect("write");
196 fs::write(dir.path().join("top.rs"), "// a file\n").expect("write");
197 fs::write(dir.path().join("notes.md"), "not rust\n").expect("write");
198
199 let mut files = Vec::new();
200 rust_files_under(dir.path(), &mut files);
201 files.sort();
202
203 let names: Vec<String> = files
204 .iter()
205 .map(|p| {
206 p.strip_prefix(dir.path())
207 .unwrap()
208 .to_string_lossy()
209 .into_owned()
210 })
211 .collect();
212 assert_eq!(
213 names,
214 vec!["pulls/writes.rs".to_owned(), "top.rs".to_owned()]
215 );
216}