forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.3 KiB
1//! Router-level tests for reactions on issues, pull requests and comments: CSRF, permissions,
2//! toggling, target validation and the archived read-only rule.
3
4use forge_core::{Reaction, ReactionTarget, Store, Visibility};
5use topcoat::router::request::Request;
6use topcoat::router::{Body, Router, to_bytes};
7
8use crate::auth::SESSION_COOKIE;
9
10const CSRF: &str = "reactions-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}
22
23async fn site() -> Site {
24 let dir = tempfile::tempdir().expect("tempdir");
25 let state = crate::test_support::test_state(dir.path()).await;
26 let store = state.store.clone();
27 let router = crate::test_support::app_router(state);
28 Site {
29 router,
30 store,
31 _dir: dir,
32 }
33}
34
35async fn sign_in(store: &Store, user_id: &str, token: &str) -> String {
36 crate::test_support::create_session(store, user_id, token, CSRF).await;
37 format!("__Host-{SESSION_COOKIE}={token}")
38}
39
40async fn post(site: &Site, uri: &str, cookie: Option<&str>, fields: &[(&str, &str)]) -> Reply {
41 let mut builder = Request::builder().method("POST").uri(uri);
42 if let Some(cookie) = cookie {
43 builder = builder.header("cookie", cookie);
44 }
45 let body = form_urlencoded::Serializer::new(String::new())
46 .extend_pairs(fields)
47 .finish();
48 builder = builder.header("content-type", "application/x-www-form-urlencoded");
49 let res = site
50 .router
51 .handle(builder.body(Body::from(body)).expect("request"))
52 .await;
53 let status = res.status().as_u16();
54 let location = res
55 .headers()
56 .get("location")
57 .and_then(|v| v.to_str().ok())
58 .map(str::to_owned);
59 let _ = to_bytes(res.into_body(), usize::MAX).await.expect("body");
60 Reply { status, location }
61}
62
63/// The form fields for a reaction on `target_kind`/`target_id`, with the test CSRF token.
64fn fields<'a>(
65 target_kind: &'a str,
66 target_id: &'a str,
67 reaction: &'a str,
68) -> Vec<(&'a str, &'a str)> {
69 vec![
70 ("csrf", CSRF),
71 ("target_kind", target_kind),
72 ("target_id", target_id),
73 ("reaction", reaction),
74 ]
75}
76
77struct Seed {
78 site: Site,
79 alice: String,
80 bob_cookie: String,
81 alice_cookie: String,
82 repo_id: String,
83 issue_id: String,
84 issue_number: i64,
85 comment_id: String,
86 other_issue_id: String,
87}
88
89/// Alice owns the public repo `alice/forge` with an issue and a comment, and a second issue in
90/// the same repo. Bob is a signed-in user with no role. Alice owns `alice/secret` (private).
91async fn seeded() -> Seed {
92 let site = site().await;
93 let store = site.store.clone();
94 let alice = store
95 .create_user("alice", None, "unused", false)
96 .await
97 .expect("alice");
98 let bob = store
99 .create_user("bob", None, "unused", false)
100 .await
101 .expect("bob");
102 let repo = store
103 .create_repo(&alice.id, "forge", "", Visibility::Public, "main")
104 .await
105 .expect("repo");
106 store
107 .create_repo(&alice.id, "secret", "", Visibility::Private, "main")
108 .await
109 .expect("secret repo");
110 let issue = store
111 .create_issue(&repo.id, &alice.id, "first", "body")
112 .await
113 .expect("issue");
114 let other = store
115 .create_issue(&repo.id, &alice.id, "second", "")
116 .await
117 .expect("other issue");
118 let comment = store
119 .add_comment(&issue.id, &bob.id, "hello")
120 .await
121 .expect("comment");
122 let alice_cookie = sign_in(&store, &alice.id, "alice-token").await;
123 let bob_cookie = sign_in(&store, &bob.id, "bob-token").await;
124 Seed {
125 site,
126 alice: alice.id.clone(),
127 bob_cookie,
128 alice_cookie,
129 repo_id: repo.id,
130 issue_id: issue.id,
131 issue_number: issue.number,
132 comment_id: comment.id,
133 other_issue_id: other.id,
134 }
135}
136
137fn issue_uri(number: i64) -> String {
138 format!("/alice/forge/issues/{number}/reactions")
139}
140
141async fn on_issue(s: &Seed) -> Vec<forge_core::ReactionSummary> {
142 s.site
143 .store
144 .reactions_for(
145 ReactionTarget::Issue,
146 std::slice::from_ref(&s.issue_id),
147 None,
148 )
149 .await
150 .expect("reactions")
151 .remove(&s.issue_id)
152 .unwrap_or_default()
153}
154
155#[tokio::test]
156async fn signed_in_reaction_toggles_on_and_off_and_redirects_to_anchor() {
157 let s = seeded().await;
158 let uri = issue_uri(s.issue_number);
159 let body = fields("issue", &s.issue_id, "heart");
160
161 let on = post(&s.site, &uri, Some(&s.bob_cookie), &body).await;
162 assert_eq!(on.status, 303);
163 assert_eq!(
164 on.location.as_deref(),
165 Some(
166 format!(
167 "/alice/forge/issues/{}#issue-{}",
168 s.issue_number, s.issue_number
169 )
170 .as_str()
171 )
172 );
173 let summaries = on_issue(&s).await;
174 assert_eq!(summaries.len(), 1);
175 assert_eq!(summaries[0].reaction, Reaction::Heart);
176 assert_eq!(summaries[0].count, 1);
177 assert_eq!(summaries[0].users, vec!["bob"]);
178
179 let off = post(&s.site, &uri, Some(&s.bob_cookie), &body).await;
180 assert_eq!(off.status, 303);
181 assert!(on_issue(&s).await.is_empty());
182}
183
184#[tokio::test]
185async fn comment_reaction_anchors_on_the_comment() {
186 let s = seeded().await;
187 let uri = issue_uri(s.issue_number);
188 let reply = post(
189 &s.site,
190 &uri,
191 Some(&s.alice_cookie),
192 &fields("comment", &s.comment_id, "eyes"),
193 )
194 .await;
195 assert_eq!(reply.status, 303);
196 assert_eq!(
197 reply.location.as_deref(),
198 Some(
199 format!(
200 "/alice/forge/issues/{}#comment-{}",
201 s.issue_number, s.comment_id
202 )
203 .as_str()
204 )
205 );
206 let map = s
207 .site
208 .store
209 .reactions_for(
210 ReactionTarget::Comment,
211 std::slice::from_ref(&s.comment_id),
212 Some(&s.alice),
213 )
214 .await
215 .expect("reactions");
216 assert!(map[&s.comment_id][0].reacted_by_viewer);
217}
218
219#[tokio::test]
220async fn missing_or_wrong_csrf_is_forbidden_and_changes_nothing() {
221 let s = seeded().await;
222 let uri = issue_uri(s.issue_number);
223 let no_token = vec![
224 ("target_kind", "issue"),
225 ("target_id", s.issue_id.as_str()),
226 ("reaction", "rocket"),
227 ];
228 assert_eq!(
229 post(&s.site, &uri, Some(&s.bob_cookie), &no_token)
230 .await
231 .status,
232 403
233 );
234 let wrong = vec![
235 ("csrf", "not-the-token"),
236 ("target_kind", "issue"),
237 ("target_id", s.issue_id.as_str()),
238 ("reaction", "rocket"),
239 ];
240 assert_eq!(
241 post(&s.site, &uri, Some(&s.bob_cookie), &wrong)
242 .await
243 .status,
244 403
245 );
246 assert!(on_issue(&s).await.is_empty());
247}
248
249#[tokio::test]
250async fn signed_out_viewer_is_sent_to_login() {
251 let s = seeded().await;
252 let uri = issue_uri(s.issue_number);
253 let reply = post(&s.site, &uri, None, &fields("issue", &s.issue_id, "+1")).await;
254 assert_eq!(reply.status, 303);
255 assert!(reply.location.unwrap_or_default().starts_with("/login"));
256 assert!(on_issue(&s).await.is_empty());
257}
258
259#[tokio::test]
260async fn hidden_repo_answers_not_found_for_a_viewer_without_access() {
261 let s = seeded().await;
262 let reply = post(
263 &s.site,
264 "/alice/secret/issues/1/reactions",
265 Some(&s.bob_cookie),
266 &fields("issue", "anything", "+1"),
267 )
268 .await;
269 assert_eq!(reply.status, 404);
270}
271
272#[tokio::test]
273async fn target_must_belong_to_this_issue() {
274 let s = seeded().await;
275 let uri = issue_uri(s.issue_number);
276 // An issue id from another item in the same repo is not this item's body.
277 let wrong_issue = post(
278 &s.site,
279 &uri,
280 Some(&s.bob_cookie),
281 &fields("issue", &s.other_issue_id, "+1"),
282 )
283 .await;
284 assert_eq!(wrong_issue.status, 404);
285 // A comment of the other issue (the comment belongs to the first issue) is rejected on the
286 // second issue's page.
287 let other_uri = issue_uri(s.issue_number + 1);
288 let wrong_comment = post(
289 &s.site,
290 &other_uri,
291 Some(&s.bob_cookie),
292 &fields("comment", &s.comment_id, "+1"),
293 )
294 .await;
295 assert_eq!(wrong_comment.status, 404);
296 assert!(on_issue(&s).await.is_empty());
297}
298
299#[tokio::test]
300async fn unknown_reaction_and_target_kind_are_bad_requests() {
301 let s = seeded().await;
302 let uri = issue_uri(s.issue_number);
303 assert_eq!(
304 post(
305 &s.site,
306 &uri,
307 Some(&s.bob_cookie),
308 &fields("issue", &s.issue_id, "party")
309 )
310 .await
311 .status,
312 400
313 );
314 assert_eq!(
315 post(
316 &s.site,
317 &uri,
318 Some(&s.bob_cookie),
319 &fields("pull", &s.issue_id, "+1")
320 )
321 .await
322 .status,
323 400
324 );
325}
326
327#[tokio::test]
328async fn archived_repo_refuses_reactions() {
329 let s = seeded().await;
330 s.site
331 .store
332 .set_repo_archived(&s.repo_id, true)
333 .await
334 .expect("archive");
335 let reply = post(
336 &s.site,
337 &issue_uri(s.issue_number),
338 Some(&s.bob_cookie),
339 &fields("issue", &s.issue_id, "+1"),
340 )
341 .await;
342 assert_eq!(reply.status, 403);
343 assert!(on_issue(&s).await.is_empty());
344}