//! Repo and SSH key management integration tests. //! //! Tests repo CRUD, SSH key delete-by-fingerprint, and issue counts after repo //! operations, at the DB layer the management commands sit on. Those commands //! moved from the sshd path (`git_ssh.rs`) to mnw-cli on 2026-07-31; this layer //! is shared by both and did not move. use crate::harness::TestHarness; use makenotwork::db; // ── Repo CRUD ── #[tokio::test] async fn ssh_repo_list_and_info() { let mut h = TestHarness::new().await; h.signup("alice", "alice@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("alice".into())) .await .unwrap() .unwrap(); // No repos initially let repos = db::git_repos::get_repos_by_user(&h.db, user.id) .await .unwrap(); assert!(repos.is_empty()); let repo = db::git_repos::create_repo(&h.db, user.id, "myproject") .await .unwrap(); assert_eq!(repo.name, "myproject"); // Private by default since migration 182: creating a repo and publishing it // are two acts, and push-create only does the first. assert_eq!(repo.visibility, db::Visibility::Private); // List should show 1 repo let repos = db::git_repos::get_repos_by_user(&h.db, user.id) .await .unwrap(); assert_eq!(repos.len(), 1); // Info: issue counts should be zero let (open, closed) = db::issues::get_issue_counts(&h.db, repo.id).await.unwrap(); assert_eq!(open, 0); assert_eq!(closed, 0); } #[tokio::test] async fn ssh_repo_set_visibility() { let mut h = TestHarness::new().await; h.signup("bob", "bob@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("bob".into())) .await .unwrap() .unwrap(); let repo = db::git_repos::create_repo(&h.db, user.id, "secret") .await .unwrap(); assert_eq!(repo.visibility, db::Visibility::Private); // The interesting direction now: private is where a repo starts, so // publishing is the transition worth asserting. db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Public) .await .unwrap(); let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .unwrap(); assert_eq!(updated.visibility, db::Visibility::Public); // Also test unlisted db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Unlisted) .await .unwrap(); let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .unwrap(); assert_eq!(updated.visibility, db::Visibility::Unlisted); } #[tokio::test] async fn ssh_repo_set_description() { let mut h = TestHarness::new().await; h.signup("carol", "carol@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into())) .await .unwrap() .unwrap(); let repo = db::git_repos::create_repo(&h.db, user.id, "docengine") .await .unwrap(); assert!(repo.description.is_empty()); db::git_repos::update_repo_settings( &h.db, repo.id, "Markdown rendering engine", repo.visibility, ) .await .unwrap(); let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .unwrap(); assert_eq!(updated.description, "Markdown rendering engine"); } #[tokio::test] async fn ssh_repo_delete_cascades_issues() { let tmp = tempfile::TempDir::new().unwrap(); let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; h.signup("dave", "dave@example.com", "password123").await; h.login("dave", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into())) .await .unwrap() .unwrap(); // Create a bare repo on disk + DB entry, with a commit so the repo page works { use crate::harness::gitfixture::{blob, commit, init_bare, tree}; use gix::objs::tree::EntryKind; let repo = init_bare(tmp.path(), "dave", "deleteme"); let readme = blob(&repo, b"# Delete Me\n"); let root = tree(&repo, &[("README.md", readme, EntryKind::Blob)]); commit(&repo, "init", root, Vec::new()); } // Visit to auto-register let resp = h.client.get("/git/dave/deleteme").await; assert_eq!(resp.status, 200, "{}", resp.text); let repo = db::git_repos::get_repo_by_user_and_name(&h.db, user.id, "deleteme") .await .unwrap() .unwrap(); // Create an issue via direct DB insert (issues are email-only, no web write path) sqlx::query( "INSERT INTO issues (repo_id, number, author_user_id, title, body_markdown, body_html) VALUES ($1, 1, $2, 'TestIssue', 'body', '

body

')" ) .bind(repo.id) .bind(user.id) .execute(&h.db) .await .unwrap(); let (open, _) = db::issues::get_issue_counts(&h.db, repo.id).await.unwrap(); assert_eq!(open, 1); // Delete the repo, issues should cascade db::git_repos::delete_repo(&h.db, repo.id).await.unwrap(); // Verify repo is gone let gone = db::git_repos::get_repo_by_id(&h.db, repo.id).await.unwrap(); assert!(gone.is_none()); } // ── SSH key delete by fingerprint ── #[tokio::test] async fn ssh_key_delete_by_fingerprint() { let mut h = TestHarness::new().await; h.signup("eve", "eve@example.com", "password123").await; h.login("eve", "password123").await; // Add a key via the API let test_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq test@example.com"; let body = format!("public_key={}&label=laptop", urlencoding::encode(test_key)); let resp = h.client.post_form("/api/users/me/ssh-keys", &body).await; assert_eq!(resp.status, 200, "Add key failed: {}", resp.text); let json: serde_json::Value = resp.json(); let fingerprint = json["fingerprint"].as_str().unwrap().to_string(); let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("eve".into())) .await .unwrap() .unwrap(); let deleted = db::ssh_keys::delete_key_by_fingerprint(&h.db, user.id, &fingerprint) .await .unwrap(); assert!(deleted); // Should not be found again let deleted_again = db::ssh_keys::delete_key_by_fingerprint(&h.db, user.id, &fingerprint) .await .unwrap(); assert!(!deleted_again); // Verify key list is empty let keys = db::ssh_keys::list_keys_by_user(&h.db, user.id) .await .unwrap(); assert!(keys.is_empty()); } #[tokio::test] async fn ssh_key_delete_by_fingerprint_wrong_user() { let mut h = TestHarness::new().await; h.signup("frank", "frank@example.com", "password123").await; h.login("frank", "password123").await; let test_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq test@example.com"; let body = format!("public_key={}&label=mykey", urlencoding::encode(test_key)); let resp = h.client.post_form("/api/users/me/ssh-keys", &body).await; assert_eq!(resp.status, 200, "{}", resp.text); let json: serde_json::Value = resp.json(); let fingerprint = json["fingerprint"].as_str().unwrap().to_string(); // Create another user h.client.post_form("/logout", "").await; h.signup("grace", "grace@example.com", "password123").await; let other_user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into())) .await .unwrap() .unwrap(); // Other user can't delete Frank's key let deleted = db::ssh_keys::delete_key_by_fingerprint(&h.db, other_user.id, &fingerprint) .await .unwrap(); assert!(!deleted); } // ── The CLI's management endpoints ── // // The verbs above are exercised at the DB layer. These cover the HTTP surface // mnw-cli actually calls, which is the half that was missing: the old // implementation was reachable only through an sshd path the live front door // does not use, so `repo set-visibility` worked and could not be run. /// A harness with the internal API configured, as mnw-cli talks to it. /// /// `TestHarness::new` leaves `cli_service_token` unset, and without it every /// internal route answers 503 "Internal API not configured" rather than /// exercising the handler. async fn cli_harness() -> TestHarness { TestHarness::build(crate::harness::BuildOptions { cli_service_token: Some("test-cli-token".to_string()), git_repos_path: Some( tempfile::TempDir::new() .unwrap() .keep() .to_string_lossy() .into_owned(), ), ..Default::default() }) .await } /// Authenticate the test client as `user_id` for the internal API. fn as_cli_actor(h: &mut TestHarness, user_id: db::UserId) { h.client.set_bearer_token("test-cli-token"); let actor = makenotwork::crypto::mint_internal_actor_token( user_id, chrono::Utc::now().timestamp() + 3600, "test-signing-secret-for-integration-tests", ); h.client.set_actor_token(&actor); } #[tokio::test] async fn cli_repo_list_and_info_over_http() { let mut h = cli_harness().await; h.signup("carol", "carol@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into())) .await .unwrap() .unwrap(); db::git_repos::create_repo(&h.db, user.id, "myproject") .await .unwrap(); as_cli_actor(&mut h, user.id); let resp = h.client.get("/api/internal/creator/repos").await; assert_eq!(resp.status, 200, "repo list failed: {}", resp.text); assert!(resp.text.contains("myproject")); // Private is what push-create now produces, so the CLI must report it. assert!(resp.text.contains("private")); let resp = h.client.get("/api/internal/creator/repos/myproject").await; assert_eq!(resp.status, 200, "repo info failed: {}", resp.text); assert!(resp.text.contains("open_issues")); } #[tokio::test] async fn cli_repo_set_visibility_over_http() { let mut h = cli_harness().await; h.signup("dave", "dave@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into())) .await .unwrap() .unwrap(); let repo = db::git_repos::create_repo(&h.db, user.id, "toolate") .await .unwrap(); as_cli_actor(&mut h, user.id); // The whole point of the port: publishing, and unpublishing again, without // touching the web UI. let resp = h .client .put_json( "/api/internal/creator/repos/toolate/visibility", r#"{"visibility":"public"}"#, ) .await; assert_eq!(resp.status, 200, "set public failed: {}", resp.text); let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .unwrap(); assert_eq!(updated.visibility, db::Visibility::Public); let resp = h .client .put_json( "/api/internal/creator/repos/toolate/visibility", r#"{"visibility":"private"}"#, ) .await; assert_eq!(resp.status, 200, "set private failed: {}", resp.text); let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .unwrap(); assert_eq!(updated.visibility, db::Visibility::Private); } #[tokio::test] async fn cli_repo_endpoints_do_not_reach_another_users_repo() { let mut h = cli_harness().await; h.signup("erin", "erin@example.com", "password123").await; let owner = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("erin".into())) .await .unwrap() .unwrap(); let repo = db::git_repos::create_repo(&h.db, owner.id, "private-thing") .await .unwrap(); h.client.post_form("/logout", "").await; h.signup("frank", "frank@example.com", "password123").await; let other = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("frank".into())) .await .unwrap() .unwrap(); // Every verb keys on (actor, name), so another user's repo is simply not // found rather than found-and-refused. The delete case is the one that // matters: it removes a directory tree. as_cli_actor(&mut h, other.id); let resp = h .client .get("/api/internal/creator/repos/private-thing") .await; assert_eq!(resp.status, 404, "info leaked another user's repo"); let resp = h .client .put_json( "/api/internal/creator/repos/private-thing/visibility", r#"{"visibility":"public"}"#, ) .await; assert_eq!( resp.status, 404, "set-visibility reached another user's repo" ); let resp = h .client .delete("/api/internal/creator/repos/private-thing") .await; assert_eq!(resp.status, 404, "delete reached another user's repo"); assert!( db::git_repos::get_repo_by_id(&h.db, repo.id) .await .unwrap() .is_some(), "the repo survived none of that" ); } #[tokio::test] async fn cli_repo_name_traversal_is_rejected() { let mut h = cli_harness().await; h.signup("grace", "grace@example.com", "password123").await; let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into())) .await .unwrap() .unwrap(); as_cli_actor(&mut h, user.id); // Delete builds a filesystem path from this string, so the validation runs // before the lookup rather than after. let resp = h .client .delete("/api/internal/creator/repos/..%2F..%2Fetc") .await; assert!( resp.status == 404 || resp.status == 400, "traversal answered {}", resp.status ); }