anvilsign in

collin/anvil

1//! Deleting a repository over HTTP: who may, and what has to be typed first.
2//!
3//! The cascade itself is tested in `anvil_core::repos`. What only exists at
4//! this layer is the guard rail — a delete needs a session, write access, a
5//! CSRF token, *and* the repository's name retyped — and the guard rail is the
6//! whole point of the feature, so it is checked against the real router rather
7//! than by reading the handler.
8
9use anvil_core::{
10 App,
11 Config,
12 repos,
13 sessions,
14 storage,
15 users,
16};
17use axum::{
18 Router,
19 body::Body,
20 http::{
21 Request,
22 StatusCode,
23 header,
24 },
25};
26use tower::ServiceExt;
27
28struct Harness {
29 app: App,
30 router: Router,
31 _dir: tempfile::TempDir,
32}
33
34async fn harness() -> Harness {
35 let dir = tempfile::tempdir().unwrap();
36 let config = Config {
37 data_dir: dir.path().to_path_buf(),
38 ..Default::default()
39 };
40 let app = App::bootstrap(config).await.unwrap();
41 Harness {
42 router: anvil_web::router(app.clone()),
43 app,
44 _dir: dir,
45 }
46}
47
48impl Harness {
49 /// Sign a user in, returning `(cookie header, csrf token)`.
50 async fn sign_in(&self, user_id: i64) -> (String, String) {
51 let session = sessions::create(&self.app.db, user_id).await.unwrap();
52 let csrf = self.app.csrf_token(&session.token);
53 (format!("anvil_session={}", session.token), csrf)
54 }
55
56 /// The rendered body of a page, for asserting on markup.
57 async fn get_body(&self, path: &str, cookie: &str) -> String {
58 let response = self
59 .router
60 .clone()
61 .oneshot(
62 Request::get(path)
63 .header(header::COOKIE, cookie)
64 .body(Body::empty())
65 .unwrap(),
66 )
67 .await
68 .unwrap();
69 let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
70 .await
71 .unwrap();
72 String::from_utf8_lossy(&bytes).into_owned()
73 }
74
75 /// POST a form body, returning `(status, Location)`.
76 async fn post(&self, path: &str, cookie: Option<&str>, body: String) -> (StatusCode, String) {
77 let mut req =
78 Request::post(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
79 if let Some(cookie) = cookie {
80 req = req.header(header::COOKIE, cookie);
81 }
82 let response = self
83 .router
84 .clone()
85 .oneshot(req.body(Body::from(body)).unwrap())
86 .await
87 .unwrap();
88 let status = response.status();
89 let location = response
90 .headers()
91 .get(header::LOCATION)
92 .map(|l| l.to_str().unwrap().to_string())
93 .unwrap_or_default();
94 (status, location)
95 }
96}
97
98/// Every way of getting the delete wrong leaves the repository standing, and
99/// only the fully-formed request takes it.
100#[tokio::test]
101async fn delete_needs_the_owner_a_csrf_token_and_the_typed_name() {
102 let h = harness().await;
103 let alice = users::create(&h.app.db, "alice", "", "pw-alice-1", false)
104 .await
105 .unwrap();
106 let bob = users::create(&h.app.db, "bob", "", "pw-bob-1", false)
107 .await
108 .unwrap();
109 let repos_dir = h.app.config.repositories_dir();
110 repos::create(&h.app.db, &repos_dir, &alice, "proj", "", false)
111 .await
112 .unwrap();
113
114 let (alice_cookie, csrf) = h.sign_in(alice.id).await;
115 let (bob_cookie, bob_csrf) = h.sign_in(bob.id).await;
116 let path = "/alice/proj/settings/delete";
117 let still_there = || async {
118 assert!(
119 repos::find(&h.app.db, alice.id, "proj")
120 .await
121 .unwrap()
122 .is_some(),
123 "the repository should have survived"
124 );
125 };
126
127 // The settings page is where the action lives, and it names the repository
128 // the confirmation field expects.
129 let page = h.get_body("/alice/proj/settings", &alice_cookie).await;
130 assert!(page.contains(&format!("action=\"{path}\"")), "{page}");
131 assert!(page.contains("data-expect=\"proj\""), "{page}");
132
133 // Signed out: no session, no delete (a redirect to the login page).
134 let (status, _) = h
135 .post(path, None, format!("confirm=proj&csrf={csrf}"))
136 .await;
137 assert_ne!(status, StatusCode::SEE_OTHER);
138 still_there().await;
139
140 // Someone else's account, holding their own valid CSRF token: it is a
141 // public repository, so the answer is forbidden rather than not-found.
142 let (status, _) = h
143 .post(
144 path,
145 Some(&bob_cookie),
146 format!("confirm=proj&csrf={bob_csrf}"),
147 )
148 .await;
149 assert_eq!(status, StatusCode::FORBIDDEN);
150 still_there().await;
151
152 // The owner, but with no CSRF token — a cross-site POST cannot mint one.
153 let (status, _) = h
154 .post(path, Some(&alice_cookie), "confirm=proj".to_string())
155 .await;
156 assert_eq!(status, StatusCode::FORBIDDEN);
157 still_there().await;
158
159 // The owner, with a token, but the name typed wrong: the page comes back
160 // with an error instead of a redirect.
161 let (status, location) = h
162 .post(
163 path,
164 Some(&alice_cookie),
165 format!("confirm=Proj&csrf={csrf}"),
166 )
167 .await;
168 assert_eq!(
169 status,
170 StatusCode::OK,
171 "re-renders settings, not a redirect"
172 );
173 assert!(location.is_empty());
174 still_there().await;
175
176 // All four together, and it is gone — row and directory both.
177 let (status, location) = h
178 .post(
179 path,
180 Some(&alice_cookie),
181 format!("confirm=proj&csrf={csrf}"),
182 )
183 .await;
184 assert_eq!(status, StatusCode::SEE_OTHER);
185 assert_eq!(location, "/alice");
186 assert!(
187 repos::find(&h.app.db, alice.id, "proj")
188 .await
189 .unwrap()
190 .is_none()
191 );
192 assert!(!storage::repo_path(&repos_dir, "alice", "proj").exists());
193}
194
195/// An admin may delete someone else's repository — the same rule that lets them
196/// change its settings (`access::can_write`).
197#[tokio::test]
198async fn an_admin_may_delete_another_users_repository() {
199 let h = harness().await;
200 let alice = users::create(&h.app.db, "alice", "", "pw-alice-1", false)
201 .await
202 .unwrap();
203 let root = users::create(&h.app.db, "root", "", "pw-root-1", true)
204 .await
205 .unwrap();
206 repos::create(
207 &h.app.db,
208 &h.app.config.repositories_dir(),
209 &alice,
210 "proj",
211 "",
212 true,
213 )
214 .await
215 .unwrap();
216
217 let (cookie, csrf) = h.sign_in(root.id).await;
218 let (status, location) = h
219 .post(
220 "/alice/proj/settings/delete",
221 Some(&cookie),
222 format!("confirm=proj&csrf={csrf}"),
223 )
224 .await;
225 assert_eq!(status, StatusCode::SEE_OTHER);
226 assert_eq!(
227 location, "/alice",
228 "back to the owner's page, not the admin's"
229 );
230 assert!(
231 repos::find(&h.app.db, alice.id, "proj")
232 .await
233 .unwrap()
234 .is_none()
235 );
236}