//! The Alloy hotfix RPM publish endpoint: who may mint a presigned PUT, and //! which object paths it will sign. //! //! The endpoint records nothing and owns no table, so what is worth testing is //! entirely the gate and the validator. Both are the security surface: the gate //! is the only thing between a SyncKit account and write access to a bucket the //! world reads, and the validator is the only thing stopping a signed PUT from //! landing an object somewhere the Caddy block would serve it as something else. use crate::harness::{BuildOptions, TestHarness, storage::InMemoryStorage}; use makenotwork::db::UserId; use serde::Deserialize; use serde_json::json; use std::sync::Arc; #[derive(Deserialize)] struct AuthResponse { token: String, } #[derive(Deserialize)] struct PresignResponse { upload_url: String, object_key: String, public_url: Option, content_type: String, } /// Build a harness whose configured admin is the account the returned token /// authenticates, so the happy path is reachable. /// /// The admin row `insert_admin_user` writes is the one `with_admin` configures, /// so authenticating as `admin@test.com` is what makes `SyncUser::user_id` and /// `config.admin_user_id` the same value. That equality IS the gate. async fn harness_as_admin(artifact_base_url: Option) -> TestHarness { let test_db = crate::harness::db::TestDb::new().await; let pool = test_db.pool.clone(); let admin_id = insert_admin(&pool).await; // The harness points `artifact_s3` at the same in-memory backend as `s3`, so a // presign is only mintable when storage is configured at all. let mut h = TestHarness::build(BuildOptions { storage: Some(Arc::new(InMemoryStorage::new())), admin_user_id: Some(admin_id), existing_db: Some(test_db), artifact_base_url, ..Default::default() }) .await; let api_key = create_sync_app(&h.db, admin_id).await; authenticate(&mut h, "admin@test.com", &api_key).await; h } async fn insert_admin(pool: &sqlx::PgPool) -> UserId { let password_hash = makenotwork::auth::hash_password("password123").unwrap(); sqlx::query_scalar( "INSERT INTO users (username, email, password_hash, email_verified) VALUES ('admin', 'admin@test.com', $1, true) RETURNING id", ) .bind(&password_hash) .fetch_one(pool) .await .expect("insert admin user") } async fn create_sync_app(pool: &sqlx::PgPool, owner: UserId) -> String { let api_key = format!("test-rpm-key-{}", uuid::Uuid::new_v4()); let key_hash = crate::harness::hash_api_key(&api_key); let key_prefix = &api_key[..8]; sqlx::query( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'RPM Publisher', $2, $3)", ) .bind(owner) .bind(&key_hash) .bind(key_prefix) .execute(pool) .await .expect("insert sync app"); api_key } async fn authenticate(h: &mut TestHarness, email: &str, api_key: &str) { let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": email, "password": "password123", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); } async fn presign( h: &mut TestHarness, path: &str, size: i64, ) -> crate::harness::client::TestResponse { h.client .post_json( "/api/v1/admin/artifacts/uploads", &json!({ "path": path, "size": size }).to_string(), ) .await } // ── The gate ── #[tokio::test] async fn the_configured_admin_can_mint_a_presign() { let mut h = harness_as_admin(Some("https://rpm.example.test".to_string())).await; let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await; assert_eq!(resp.status, 201, "presign failed: {}", resp.text); let body: PresignResponse = resp.json(); assert_eq!(body.object_key, "alloy/f43/x86_64/repodata/repomd.xml"); assert_eq!(body.content_type, "application/xml"); assert_eq!( body.public_url.as_deref(), Some("https://rpm.example.test/alloy/f43/x86_64/repodata/repomd.xml"), "the public URL is the base plus the key, with no rewriting in between" ); assert!( body.upload_url.contains("repomd.xml"), "presigned URL should address the key: {}", body.upload_url ); } #[tokio::test] async fn a_non_admin_gets_404_rather_than_403() { // A separate signed-up account with a valid SyncKit token. It is // authenticated, just not the admin — the case the gate exists for. let mut h = TestHarness::new().await; let user_id = h .signup("someone", "someone@example.com", "Password1!") .await; let api_key = create_sync_app(&h.db, user_id).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "someone@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await; assert_eq!( resp.status, 404, "a non-admin must not learn the endpoint exists: {}", resp.text ); } #[tokio::test] async fn an_unauthenticated_caller_is_refused() { let mut h = TestHarness::new().await; let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await; assert_eq!(resp.status, 401, "expected 401, got {}", resp.text); } #[tokio::test] async fn no_configured_admin_refuses_everyone() { // `admin_user_id` unset is the dev default. The gate must close, not open: // an unset admin is not "anyone may". let mut h = TestHarness::new().await; let user_id = h .signup("someone", "someone@example.com", "Password1!") .await; let api_key = create_sync_app(&h.db, user_id).await; authenticate_with_password(&mut h, "someone@example.com", "Password1!", &api_key).await; let resp = presign(&mut h, "a/repodata/repomd.xml", 512).await; assert_eq!(resp.status, 404, "expected 404, got {}", resp.text); } async fn authenticate_with_password( h: &mut TestHarness, email: &str, password: &str, api_key: &str, ) { let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": email, "password": password, "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); } // ── The path validator ── #[tokio::test] async fn traversal_and_absolute_paths_are_refused() { let mut h = harness_as_admin(None).await; for path in [ "../etc/passwd.rpm", "alloy/../../x.rpm", "/alloy/f43/x.rpm", "alloy//f43/x.rpm", "alloy/f43/x.rpm/", "alloy/./x.rpm", ] { let resp = presign(&mut h, path, 512).await; assert_eq!( resp.status, 400, "path {path:?} should have been refused, got {} {}", resp.status, resp.text ); } } #[tokio::test] async fn only_content_the_store_serves_is_signed() { let mut h = harness_as_admin(None).await; for path in [ "alloy/hotfix/f43/x86_64/alloy-1.0.0-1.fc43.x86_64.rpm", "alloy/hotfix/f43/x86_64/repodata/abc123-primary.xml.zst", "alloy/hotfix/f43/x86_64/repodata/repomd.xml.asc", // The mirror's half of the store: archives, pinned by digest, with an // extension like everything else here. "base/fedora-bootc-43-amd64.tar", "base/fedora-bootc-43-arm64.tar", ] { let resp = presign(&mut h, path, 512).await; assert_eq!( resp.status, 201, "path {path:?} should be signed: {}", resp.text ); } for path in [ "alloy/f43/index.html", "alloy/f43/payload.sh", "alloy/f43/noextension", // Registry shape. The mirror ships archives, so none of this is store // content and the key alphabet stays narrow: no colons, no // extensionless digest names. "v2/alloy/base/manifests/43", "v2/alloy/base/blobs/sha256:3f786850e387550fdab836ed7e6dc881de23001b", "base/oci-layout", ] { let resp = presign(&mut h, path, 512).await; assert_eq!( resp.status, 400, "path {path:?} is not repository content and should be refused: {}", resp.text ); } } #[tokio::test] async fn a_package_and_the_index_get_different_content_types() { let mut h = harness_as_admin(None).await; let pkg: PresignResponse = presign(&mut h, "a/alloy-1.0.0.rpm", 512).await.json(); assert_eq!(pkg.content_type, "application/x-rpm"); let meta: PresignResponse = presign(&mut h, "a/repodata/abc-primary.xml.zst", 512) .await .json(); assert_eq!(meta.content_type, "application/zstd"); let archive: PresignResponse = presign(&mut h, "base/fedora-bootc-43-amd64.tar", 512) .await .json(); assert_eq!(archive.content_type, "application/x-tar"); } #[tokio::test] async fn an_index_is_revalidated_and_content_is_cached_forever() { // The whole reason cache-control is not one value: an index that an edge // holds is a published fix that never arrives. let mut h = harness_as_admin(None).await; for path in ["alloy/hotfix/f43/repodata/repomd.xml"] { let resp = presign(&mut h, path, 512).await; assert_eq!(resp.status, 201, "{path}: {}", resp.text); } } #[tokio::test] async fn a_size_outside_the_ceiling_is_refused_before_anything_is_signed() { let mut h = harness_as_admin(None).await; for size in [0, -1, makenotwork::constants::ARTIFACT_MAX_OBJECT_BYTES + 1] { let resp = presign(&mut h, "a/alloy-1.0.0.rpm", size).await; assert_eq!( resp.status, 400, "size {size} should have been refused: {}", resp.text ); } } #[tokio::test] async fn an_unset_artifact_base_url_answers_with_a_null_public_url() { // Not a degraded mode worth failing on: the endpoint's job is the presign, // and where the object is served from is a separate piece of config the // publisher only prints. let mut h = harness_as_admin(None).await; let body: PresignResponse = presign(&mut h, "a/alloy-1.0.0.rpm", 512).await.json(); assert!(body.public_url.is_none()); }