forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.2 KiB
1//! Router-level tests for pinning issues: triage permission, CSRF, the three-pin limit, the
2//! timeline event, the sidebar button and the pinned cards above the issue list.
3
4use forge_core::{Role, Store, Visibility};
5use topcoat::router::request::Request;
6use topcoat::router::{Body, Router, to_bytes};
7
8use crate::auth::SESSION_COOKIE;
9
10const CSRF: &str = "pin-test-csrf";
11
12struct Site {
13 router: Router,
14 store: Store,
15 _dir: tempfile::TempDir,
16}
17
18struct Reply {
19 status: u16,
20 location: Option<String>,
21 body: String,
22}
23
24async fn site() -> Site {
25 let dir = tempfile::tempdir().expect("tempdir");
26 let state = crate::test_support::test_state(dir.path()).await;
27 let store = state.store.clone();
28 let router = crate::test_support::app_router(state);
29 Site {
30 router,
31 store,
32 _dir: dir,
33 }
34}
35
36async fn sign_in(store: &Store, user_id: &str, token: &str) -> String {
37 crate::test_support::create_session(store, user_id, token, CSRF).await;
38 format!("__Host-{SESSION_COOKIE}={token}")
39}
40
41async fn call(
42 site: &Site,
43 method: &str,
44 uri: &str,
45 cookie: Option<&str>,
46 fields: Option<&[(&str, &str)]>,
47) -> Reply {
48 let mut builder = Request::builder().method(method).uri(uri);
49 if let Some(cookie) = cookie {
50 builder = builder.header("cookie", cookie);
51 }
52 let body = match fields {
53 Some(fields) => {
54 builder = builder.header("content-type", "application/x-www-form-urlencoded");
55 Body::from(
56 form_urlencoded::Serializer::new(String::new())
57 .extend_pairs(fields)
58 .finish(),
59 )
60 }
61 None => Body::empty(),
62 };
63 let res = site
64 .router
65 .handle(builder.body(body).expect("request"))
66 .await;
67 let status = res.status().as_u16();
68 let location = res
69 .headers()
70 .get("location")
71 .and_then(|v| v.to_str().ok())
72 .map(str::to_owned);
73 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
74 Reply {
75 status,
76 location,
77 body: String::from_utf8_lossy(&bytes).into_owned(),
78 }
79}
80
81/// The form fields for a pin or unpin, with the test CSRF token.
82fn pin_fields(pinned: &'static str) -> Vec<(&'static str, &'static str)> {
83 vec![("csrf", CSRF), ("pinned", pinned)]
84}
85
86const PIN_PATH: &str = "/alice/forge/issues/";
87
88struct Seed {
89 site: Site,
90 alice_cookie: String,
91 bob_cookie: String,
92 carol_cookie: String,
93 repo_id: String,
94 /// Issues one to four, in number order.
95 issues: Vec<(String, i64)>,
96}
97
98/// Alice owns the public repo `alice/forge` with four issues. Bob is a Read collaborator and
99/// Carol a Triage collaborator.
100async fn seeded() -> Seed {
101 let site = site().await;
102 let store = site.store.clone();
103 let alice = store
104 .create_user("alice", None, "unused", false)
105 .await
106 .expect("alice");
107 let bob = store
108 .create_user("bob", None, "unused", false)
109 .await
110 .expect("bob");
111 let carol = store
112 .create_user("carol", None, "unused", false)
113 .await
114 .expect("carol");
115 let repo = store
116 .create_repo(&alice.id, "forge", "", Visibility::Public, "main")
117 .await
118 .expect("repo");
119 store
120 .set_collaborator(&repo.id, &bob.id, Role::Read)
121 .await
122 .expect("bob role");
123 store
124 .set_collaborator(&repo.id, &carol.id, Role::Triage)
125 .await
126 .expect("carol role");
127 let mut issues = Vec::new();
128 for i in 1..=4 {
129 let issue = store
130 .create_issue(&repo.id, &alice.id, &format!("issue {i}"), "")
131 .await
132 .expect("issue");
133 issues.push((issue.id, issue.number));
134 }
135 let alice_cookie = sign_in(&store, &alice.id, "alice-token").await;
136 let bob_cookie = sign_in(&store, &bob.id, "bob-token").await;
137 let carol_cookie = sign_in(&store, &carol.id, "carol-token").await;
138 Seed {
139 site,
140 alice_cookie,
141 bob_cookie,
142 carol_cookie,
143 repo_id: repo.id,
144 issues,
145 }
146}
147
148fn pin_uri(number: i64) -> String {
149 format!("{PIN_PATH}{number}/pin")
150}
151
152#[tokio::test]
153async fn triage_pins_and_unpins_and_records_the_timeline() {
154 let s = seeded().await;
155 let (issue_id, number) = s.issues[0].clone();
156 let uri = pin_uri(number);
157
158 let reply = call(
159 &s.site,
160 "POST",
161 &uri,
162 Some(s.carol_cookie.as_str()),
163 Some(pin_fields("1").as_slice()),
164 )
165 .await;
166 assert_eq!(reply.status, 303);
167 assert_eq!(
168 reply.location.as_deref(),
169 Some(format!("{PIN_PATH}{number}").as_str())
170 );
171 assert!(
172 s.site
173 .store
174 .is_pinned(&s.repo_id, &issue_id)
175 .await
176 .expect("pinned")
177 );
178 let events = s.site.store.list_events(&issue_id).await.expect("events");
179 assert!(events.iter().any(|e| e.kind == "pinned"));
180
181 let reply = call(
182 &s.site,
183 "POST",
184 &uri,
185 Some(s.carol_cookie.as_str()),
186 Some(pin_fields("0").as_slice()),
187 )
188 .await;
189 assert_eq!(reply.status, 303);
190 assert!(
191 !s.site
192 .store
193 .is_pinned(&s.repo_id, &issue_id)
194 .await
195 .expect("pinned")
196 );
197 let events = s.site.store.list_events(&issue_id).await.expect("events");
198 assert!(events.iter().any(|e| e.kind == "unpinned"));
199}
200
201#[tokio::test]
202async fn read_only_collaborator_and_anonymous_are_forbidden() {
203 let s = seeded().await;
204 let (issue_id, number) = s.issues[0].clone();
205 let uri = pin_uri(number);
206
207 let reply = call(
208 &s.site,
209 "POST",
210 &uri,
211 Some(s.bob_cookie.as_str()),
212 Some(pin_fields("1").as_slice()),
213 )
214 .await;
215 assert_eq!(reply.status, 403);
216 let reply = call(
217 &s.site,
218 "POST",
219 &uri,
220 None,
221 Some(pin_fields("1").as_slice()),
222 )
223 .await;
224 assert!(reply.status == 403 || reply.status == 303);
225 assert!(
226 !s.site
227 .store
228 .is_pinned(&s.repo_id, &issue_id)
229 .await
230 .expect("pinned")
231 );
232}
233
234#[tokio::test]
235async fn missing_csrf_is_forbidden_and_changes_nothing() {
236 let s = seeded().await;
237 let (issue_id, number) = s.issues[0].clone();
238 let no_token: &[(&str, &str)] = &[("pinned", "1")];
239 let reply = call(
240 &s.site,
241 "POST",
242 &pin_uri(number),
243 Some(s.alice_cookie.as_str()),
244 Some(no_token),
245 )
246 .await;
247 assert_eq!(reply.status, 403);
248 assert!(
249 !s.site
250 .store
251 .is_pinned(&s.repo_id, &issue_id)
252 .await
253 .expect("pinned")
254 );
255}
256
257#[tokio::test]
258async fn bad_pinned_value_is_rejected_and_unknown_issue_is_404() {
259 let s = seeded().await;
260 let (_, number) = s.issues[0].clone();
261 let reply = call(
262 &s.site,
263 "POST",
264 &pin_uri(number),
265 Some(s.carol_cookie.as_str()),
266 Some(&[("csrf", CSRF), ("pinned", "yes")][..]),
267 )
268 .await;
269 assert_eq!(reply.status, 400);
270 let reply = call(
271 &s.site,
272 "POST",
273 &pin_uri(999),
274 Some(s.carol_cookie.as_str()),
275 Some(pin_fields("1").as_slice()),
276 )
277 .await;
278 assert_eq!(reply.status, 404);
279}
280
281#[tokio::test]
282async fn fourth_pin_shows_the_limit_and_pins_nothing() {
283 let s = seeded().await;
284 for (id, _) in &s.issues[..3] {
285 s.site.store.pin_issue(&s.repo_id, id).await.expect("pin");
286 }
287 let (fourth_id, fourth_number) = s.issues[3].clone();
288 let reply = call(
289 &s.site,
290 "POST",
291 &pin_uri(fourth_number),
292 Some(s.carol_cookie.as_str()),
293 Some(pin_fields("1").as_slice()),
294 )
295 .await;
296 assert_eq!(reply.status, 200);
297 assert!(reply.body.contains("already has 3 pinned issues"));
298 assert!(
299 !s.site
300 .store
301 .is_pinned(&s.repo_id, &fourth_id)
302 .await
303 .expect("pinned")
304 );
305}
306
307#[tokio::test]
308async fn issue_list_shows_pinned_cards_above_the_list() {
309 let s = seeded().await;
310 let (_, number) = s.issues[1].clone();
311 s.site
312 .store
313 .pin_issue(&s.repo_id, &s.issues[1].0)
314 .await
315 .expect("pin");
316 let reply = call(&s.site, "GET", "/alice/forge/issues", None, None).await;
317 assert_eq!(reply.status, 200);
318 assert!(reply.body.contains("pinned-card"));
319 assert!(
320 reply
321 .body
322 .contains(&format!("/alice/forge/issues/{number}\""))
323 );
324 let card_at = reply.body.find("pinned-card").expect("card");
325 let list_at = reply.body.find("issue-list").expect("list");
326 assert!(card_at < list_at);
327}
328
329#[tokio::test]
330async fn issue_page_offers_pin_to_triage_only() {
331 let s = seeded().await;
332 let (_, number) = s.issues[0].clone();
333 let page = format!("{PIN_PATH}{number}");
334 let reply = call(&s.site, "GET", &page, Some(s.carol_cookie.as_str()), None).await;
335 assert_eq!(reply.status, 200);
336 assert!(reply.body.contains("Pin issue"));
337 assert!(reply.body.contains(&format!("{page}/pin")));
338
339 let reply = call(&s.site, "GET", &page, Some(s.bob_cookie.as_str()), None).await;
340 assert_eq!(reply.status, 200);
341 assert!(!reply.body.contains("Pin issue"));
342}