Skip to main content

max / makenotwork

14.0 KB · 424 lines History Blame Raw
1 //! Repo and SSH key management integration tests.
2 //!
3 //! Tests repo CRUD, SSH key delete-by-fingerprint, and issue counts after repo
4 //! operations, at the DB layer the management commands sit on. Those commands
5 //! live in mnw-cli rather than on the sshd path (`git_ssh.rs`); this layer is
6 //! shared by both.
7
8 use crate::harness::TestHarness;
9 use makenotwork::db;
10
11 // ── Repo CRUD ──
12
13 #[tokio::test]
14 async fn ssh_repo_list_and_info() {
15 let mut h = TestHarness::new().await;
16 h.signup("alice", "alice@example.com", "password123").await;
17 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("alice".into()))
18 .await
19 .unwrap()
20 .unwrap();
21
22 // No repos initially
23 let repos = db::git_repos::get_repos_by_user(&h.db, user.id)
24 .await
25 .unwrap();
26 assert!(repos.is_empty());
27
28 let repo = db::git_repos::create_repo(&h.db, user.id, "myproject")
29 .await
30 .unwrap();
31 assert_eq!(repo.name, "myproject");
32 // Private by default since migration 182: creating a repo and publishing it
33 // are two acts, and push-create only does the first.
34 assert_eq!(repo.visibility, db::Visibility::Private);
35
36 // List should show 1 repo
37 let repos = db::git_repos::get_repos_by_user(&h.db, user.id)
38 .await
39 .unwrap();
40 assert_eq!(repos.len(), 1);
41
42 // Info: issue counts should be zero
43 let (open, closed) = db::issues::get_issue_counts(&h.db, repo.id).await.unwrap();
44 assert_eq!(open, 0);
45 assert_eq!(closed, 0);
46 }
47
48 #[tokio::test]
49 async fn ssh_repo_set_visibility() {
50 let mut h = TestHarness::new().await;
51 h.signup("bob", "bob@example.com", "password123").await;
52 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("bob".into()))
53 .await
54 .unwrap()
55 .unwrap();
56
57 let repo = db::git_repos::create_repo(&h.db, user.id, "secret")
58 .await
59 .unwrap();
60 assert_eq!(repo.visibility, db::Visibility::Private);
61
62 // The interesting direction now: private is where a repo starts, so
63 // publishing is the transition worth asserting.
64 db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Public)
65 .await
66 .unwrap();
67
68 let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
69 .await
70 .unwrap()
71 .unwrap();
72 assert_eq!(updated.visibility, db::Visibility::Public);
73
74 // Also test unlisted
75 db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Unlisted)
76 .await
77 .unwrap();
78 let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
79 .await
80 .unwrap()
81 .unwrap();
82 assert_eq!(updated.visibility, db::Visibility::Unlisted);
83 }
84
85 #[tokio::test]
86 async fn ssh_repo_set_description() {
87 let mut h = TestHarness::new().await;
88 h.signup("carol", "carol@example.com", "password123").await;
89 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into()))
90 .await
91 .unwrap()
92 .unwrap();
93
94 let repo = db::git_repos::create_repo(&h.db, user.id, "docengine")
95 .await
96 .unwrap();
97 assert!(repo.description.is_empty());
98
99 db::git_repos::update_repo_settings(
100 &h.db,
101 repo.id,
102 "Markdown rendering engine",
103 repo.visibility,
104 )
105 .await
106 .unwrap();
107
108 let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
109 .await
110 .unwrap()
111 .unwrap();
112 assert_eq!(updated.description, "Markdown rendering engine");
113 }
114
115 #[tokio::test]
116 async fn ssh_repo_delete_cascades_issues() {
117 let tmp = tempfile::TempDir::new().unwrap();
118 let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
119 h.signup("dave", "dave@example.com", "password123").await;
120 h.login("dave", "password123").await;
121
122 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into()))
123 .await
124 .unwrap()
125 .unwrap();
126
127 // Create a bare repo on disk + DB entry, with a commit so the repo page works
128 {
129 use crate::harness::gitfixture::{blob, commit, init_bare, tree};
130 use gix::objs::tree::EntryKind;
131
132 let repo = init_bare(tmp.path(), "dave", "deleteme");
133 let readme = blob(&repo, b"# Delete Me\n");
134 let root = tree(&repo, &[("README.md", readme, EntryKind::Blob)]);
135 commit(&repo, "init", root, Vec::new());
136 }
137
138 // Visit to auto-register
139 let resp = h.client.get("/git/dave/deleteme").await;
140 assert_eq!(resp.status, 200, "{}", resp.text);
141
142 let repo = db::git_repos::get_repo_by_user_and_name(&h.db, user.id, "deleteme")
143 .await
144 .unwrap()
145 .unwrap();
146
147 // Create an issue via direct DB insert (issues are email-only, no web write path)
148 sqlx::query(
149 "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'TestIssue', 'body', '<p>body</p>')"
150 )
151 .bind(repo.id)
152 .bind(user.id)
153 .execute(&h.db)
154 .await
155 .unwrap();
156
157 let (open, _) = db::issues::get_issue_counts(&h.db, repo.id).await.unwrap();
158 assert_eq!(open, 1);
159
160 // Delete the repo, issues should cascade
161 db::git_repos::delete_repo(&h.db, repo.id).await.unwrap();
162
163 // Verify repo is gone
164 let gone = db::git_repos::get_repo_by_id(&h.db, repo.id).await.unwrap();
165 assert!(gone.is_none());
166 }
167
168 // ── SSH key delete by fingerprint ──
169
170 #[tokio::test]
171 async fn ssh_key_delete_by_fingerprint() {
172 let mut h = TestHarness::new().await;
173 h.signup("eve", "eve@example.com", "password123").await;
174 h.login("eve", "password123").await;
175
176 // Add a key via the API
177 let test_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq test@example.com";
178 let body = format!("public_key={}&label=laptop", urlencoding::encode(test_key));
179 let resp = h.client.post_form("/api/users/me/ssh-keys", &body).await;
180 assert_eq!(resp.status, 200, "Add key failed: {}", resp.text);
181 let json: serde_json::Value = resp.json();
182 let fingerprint = json["fingerprint"].as_str().unwrap().to_string();
183
184 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("eve".into()))
185 .await
186 .unwrap()
187 .unwrap();
188
189 let deleted = db::ssh_keys::delete_key_by_fingerprint(&h.db, user.id, &fingerprint)
190 .await
191 .unwrap();
192 assert!(deleted);
193
194 // Should not be found again
195 let deleted_again = db::ssh_keys::delete_key_by_fingerprint(&h.db, user.id, &fingerprint)
196 .await
197 .unwrap();
198 assert!(!deleted_again);
199
200 // Verify key list is empty
201 let keys = db::ssh_keys::list_keys_by_user(&h.db, user.id)
202 .await
203 .unwrap();
204 assert!(keys.is_empty());
205 }
206
207 #[tokio::test]
208 async fn ssh_key_delete_by_fingerprint_wrong_user() {
209 let mut h = TestHarness::new().await;
210 h.signup("frank", "frank@example.com", "password123").await;
211 h.login("frank", "password123").await;
212
213 let test_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq test@example.com";
214 let body = format!("public_key={}&label=mykey", urlencoding::encode(test_key));
215 let resp = h.client.post_form("/api/users/me/ssh-keys", &body).await;
216 assert_eq!(resp.status, 200, "{}", resp.text);
217 let json: serde_json::Value = resp.json();
218 let fingerprint = json["fingerprint"].as_str().unwrap().to_string();
219
220 // Create another user
221 h.client.post_form("/logout", "").await;
222 h.signup("grace", "grace@example.com", "password123").await;
223 let other_user =
224 db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into()))
225 .await
226 .unwrap()
227 .unwrap();
228
229 // Other user can't delete Frank's key
230 let deleted = db::ssh_keys::delete_key_by_fingerprint(&h.db, other_user.id, &fingerprint)
231 .await
232 .unwrap();
233 assert!(!deleted);
234 }
235
236 // ── The CLI's management endpoints ──
237 //
238 // The verbs above are exercised at the DB layer. These cover the HTTP surface
239 // mnw-cli actually calls, which is the half that was missing: the old
240 // implementation was reachable only through an sshd path the live front door
241 // does not use, so `repo set-visibility` worked and could not be run.
242
243 /// A harness with the internal API configured, as mnw-cli talks to it.
244 ///
245 /// `TestHarness::new` leaves `cli_service_token` unset, and without it every
246 /// internal route answers 503 "Internal API not configured" rather than
247 /// exercising the handler.
248 async fn cli_harness() -> TestHarness {
249 TestHarness::build(crate::harness::BuildOptions {
250 cli_service_token: Some("test-cli-token".to_string()),
251 git_repos_path: Some(
252 tempfile::TempDir::new()
253 .unwrap()
254 .keep()
255 .to_string_lossy()
256 .into_owned(),
257 ),
258 ..Default::default()
259 })
260 .await
261 }
262
263 /// Authenticate the test client as `user_id` for the internal API.
264 fn as_cli_actor(h: &mut TestHarness, user_id: db::UserId) {
265 h.client.set_bearer_token("test-cli-token");
266 let actor = makenotwork::crypto::mint_internal_actor_token(
267 user_id,
268 chrono::Utc::now().timestamp() + 3600,
269 "test-signing-secret-for-integration-tests",
270 );
271 h.client.set_actor_token(&actor);
272 }
273
274 #[tokio::test]
275 async fn cli_repo_list_and_info_over_http() {
276 let mut h = cli_harness().await;
277 h.signup("carol", "carol@example.com", "password123").await;
278 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into()))
279 .await
280 .unwrap()
281 .unwrap();
282 db::git_repos::create_repo(&h.db, user.id, "myproject")
283 .await
284 .unwrap();
285
286 as_cli_actor(&mut h, user.id);
287
288 let resp = h.client.get("/api/internal/creator/repos").await;
289 assert_eq!(resp.status, 200, "repo list failed: {}", resp.text);
290 assert!(resp.text.contains("myproject"));
291 // Private is what push-create now produces, so the CLI must report it.
292 assert!(resp.text.contains("private"));
293
294 let resp = h.client.get("/api/internal/creator/repos/myproject").await;
295 assert_eq!(resp.status, 200, "repo info failed: {}", resp.text);
296 assert!(resp.text.contains("open_issues"));
297 }
298
299 #[tokio::test]
300 async fn cli_repo_set_visibility_over_http() {
301 let mut h = cli_harness().await;
302 h.signup("dave", "dave@example.com", "password123").await;
303 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into()))
304 .await
305 .unwrap()
306 .unwrap();
307 let repo = db::git_repos::create_repo(&h.db, user.id, "toolate")
308 .await
309 .unwrap();
310
311 as_cli_actor(&mut h, user.id);
312
313 // The whole point of the port: publishing, and unpublishing again, without
314 // touching the web UI.
315 let resp = h
316 .client
317 .put_json(
318 "/api/internal/creator/repos/toolate/visibility",
319 r#"{"visibility":"public"}"#,
320 )
321 .await;
322 assert_eq!(resp.status, 200, "set public failed: {}", resp.text);
323 let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
324 .await
325 .unwrap()
326 .unwrap();
327 assert_eq!(updated.visibility, db::Visibility::Public);
328
329 let resp = h
330 .client
331 .put_json(
332 "/api/internal/creator/repos/toolate/visibility",
333 r#"{"visibility":"private"}"#,
334 )
335 .await;
336 assert_eq!(resp.status, 200, "set private failed: {}", resp.text);
337 let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
338 .await
339 .unwrap()
340 .unwrap();
341 assert_eq!(updated.visibility, db::Visibility::Private);
342 }
343
344 #[tokio::test]
345 async fn cli_repo_endpoints_do_not_reach_another_users_repo() {
346 let mut h = cli_harness().await;
347 h.signup("erin", "erin@example.com", "password123").await;
348 let owner = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("erin".into()))
349 .await
350 .unwrap()
351 .unwrap();
352 let repo = db::git_repos::create_repo(&h.db, owner.id, "private-thing")
353 .await
354 .unwrap();
355
356 h.client.post_form("/logout", "").await;
357 h.signup("frank", "frank@example.com", "password123").await;
358 let other = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("frank".into()))
359 .await
360 .unwrap()
361 .unwrap();
362
363 // Every verb keys on (actor, name), so another user's repo is simply not
364 // found rather than found-and-refused. The delete case is the one that
365 // matters: it removes a directory tree.
366 as_cli_actor(&mut h, other.id);
367
368 let resp = h
369 .client
370 .get("/api/internal/creator/repos/private-thing")
371 .await;
372 assert_eq!(resp.status, 404, "info leaked another user's repo");
373
374 let resp = h
375 .client
376 .put_json(
377 "/api/internal/creator/repos/private-thing/visibility",
378 r#"{"visibility":"public"}"#,
379 )
380 .await;
381 assert_eq!(
382 resp.status, 404,
383 "set-visibility reached another user's repo"
384 );
385
386 let resp = h
387 .client
388 .delete("/api/internal/creator/repos/private-thing")
389 .await;
390 assert_eq!(resp.status, 404, "delete reached another user's repo");
391
392 assert!(
393 db::git_repos::get_repo_by_id(&h.db, repo.id)
394 .await
395 .unwrap()
396 .is_some(),
397 "the repo survived none of that"
398 );
399 }
400
401 #[tokio::test]
402 async fn cli_repo_name_traversal_is_rejected() {
403 let mut h = cli_harness().await;
404 h.signup("grace", "grace@example.com", "password123").await;
405 let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into()))
406 .await
407 .unwrap()
408 .unwrap();
409
410 as_cli_actor(&mut h, user.id);
411
412 // Delete builds a filesystem path from this string, so the validation runs
413 // before the lookup rather than after.
414 let resp = h
415 .client
416 .delete("/api/internal/creator/repos/..%2F..%2Fetc")
417 .await;
418 assert!(
419 resp.status == 404 || resp.status == 400,
420 "traversal answered {}",
421 resp.status
422 );
423 }
424