//! OTA update integration tests, slug management, releases, artifacts, updater endpoint. use crate::harness::TestHarness; use makenotwork::db::{OtaReleaseId, SyncAppId, UserId}; use serde::Deserialize; use serde_json::json; use sqlx::PgPool; /// The artifact-register route rejects signatures shorter than 40 chars (a real /// base64-encoded minisign signature is well over that), so any test that /// uploads an artifact must supply a plausible-length one. Tests that assert the /// *reject* path use their own short/empty values inline. const TEST_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZQoxYWJjZGVmZ2hpamtsbW5vcA=="; /// A second distinct signature, used to prove each artifact carries its own, /// the whole point of moving the signature onto `ota_artifacts`. const TEST_SIGNATURE_B: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZSBCCjJ6eXh3dnV0c3JxcG9ubQ=="; // ── Response types ── #[derive(Deserialize)] struct AuthResponse { token: String, #[serde(rename = "user_id")] _user_id: UserId, #[serde(rename = "app_id")] _app_id: SyncAppId, } #[derive(Deserialize)] struct ReleaseResponse { id: OtaReleaseId, version: String, } #[derive(Deserialize)] struct UploadArtifactResponse { upload_url: String, } #[derive(Deserialize)] struct TauriUpdaterResponse { version: String, url: String, signature: String, notes: String, } // ── Helpers ── /// Insert a sync app with a slug directly via SQL. async fn create_sync_app_with_slug( pool: &PgPool, user_id: UserId, slug: &str, ) -> (SyncAppId, String) { let api_key = format!("test-ota-key-{slug}"); let key_hash = crate::harness::hash_api_key(&api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, slug) VALUES ($1, $2, $3, $4, $5) RETURNING id", ) .bind(user_id) .bind(format!("OTA App {slug}")) .bind(&key_hash) .bind(key_prefix) .bind(slug) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key) } /// Insert a sync app without a slug. async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = format!("test-ota-key-{}", uuid::Uuid::new_v4()); let key_hash = crate::harness::hash_api_key(&api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'OTA App', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key) } /// Sign up, create an app, get a JWT token. async fn setup_authenticated(h: &mut TestHarness) -> (SyncAppId, String) { let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app(&h.db, user_id).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@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); (app_id, api_key) } /// Build a harness with synckit storage enabled (needed for artifact upload/download). async fn harness_with_synckit_storage() -> TestHarness { TestHarness::with_synckit_storage().await } /// Mark a release's artifacts scanned-clean (simulates a completed scan). New /// artifacts start `pending` and are not advertised/downloadable until the scan /// pipeline clears them; serve-path tests call this to reach the served state. async fn mark_artifacts_clean(pool: &PgPool, release_id: &makenotwork::db::OtaReleaseId) { sqlx::query("UPDATE ota_artifacts SET scan_status = 'clean' WHERE release_id = $1") .bind(release_id) .execute(pool) .await .unwrap(); } // ── Tests ── #[tokio::test] async fn set_app_slug() { let mut h = TestHarness::new().await; let (app_id, _) = setup_authenticated(&mut h).await; // Set slug let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "goingson" }).to_string(), ) .await; assert_eq!(resp.status, 204, "Set slug failed: {}", resp.text); // Verify the slug is set (the updater endpoint should resolve it, returning 204 = no releases) h.client.clear_bearer_token(); let resp = h .client .get("/api/sync/ota/goingson/linux/x86_64/0.0.1") .await; assert_eq!(resp.status, 204, "Slug lookup should work: {}", resp.text); } #[tokio::test] async fn slug_validation() { let mut h = TestHarness::new().await; let (app_id, _) = setup_authenticated(&mut h).await; // Too short (2 chars) let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "ab" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject 2-char slug"); // Uppercase let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "GoingsOn" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject uppercase"); // Special chars let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "my_app!" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject special chars"); // Leading hyphen let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "-myapp" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject leading hyphen"); // Valid slug should work let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_id}/slug"), &json!({ "slug": "my-app" }).to_string(), ) .await; assert_eq!(resp.status, 204, "Valid slug should work: {}", resp.text); } #[tokio::test] async fn slug_uniqueness() { let mut h = TestHarness::new().await; let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app1_id, api_key) = create_sync_app(&h.db, user_id).await; // Authenticate let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); // Set slug on app1 let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app1_id}/slug"), &json!({ "slug": "unique-slug" }).to_string(), ) .await; assert_eq!(resp.status, 204); // Create a second app and try the same slug let api_key2 = "test-ota-key-second"; let key_hash2 = crate::harness::hash_api_key(api_key2); let key_prefix2 = &api_key2[..8]; let app2_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'Second', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash2) .bind(key_prefix2) .fetch_one(&h.db) .await .unwrap(); // Re-authenticate with second app's key let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key2, "key": "test-sdk-key", }) .to_string(), ) .await; let auth2: AuthResponse = resp.json(); h.client.set_bearer_token(&auth2.token); let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app2_id}/slug"), &json!({ "slug": "unique-slug" }).to_string(), ) .await; assert_eq!( resp.status, 500, "Duplicate slug should fail: {}", resp.text ); } #[tokio::test] async fn create_and_list_releases() { let mut h = TestHarness::new().await; let (app_id, _) = setup_authenticated(&mut h).await; // Create a release let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "0.2.1", "notes": "Bug fixes" }) .to_string(), ) .await; assert_eq!(resp.status, 201, "Create release failed: {}", resp.text); let release: ReleaseResponse = resp.json(); assert_eq!(release.version, "0.2.1"); // List releases let resp = h .client .get(&format!("/api/sync/ota/apps/{app_id}/releases")) .await; assert_eq!(resp.status, 200); let releases: Vec = resp.json(); assert_eq!(releases.len(), 1); assert_eq!(releases[0].version, "0.2.1"); } #[tokio::test] async fn version_validation() { let mut h = TestHarness::new().await; let (app_id, _) = setup_authenticated(&mut h).await; // Invalid semver let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "not-semver", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject non-semver"); // Also invalid let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.2", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 400, "Should reject incomplete semver"); } #[tokio::test] async fn duplicate_version() { let mut h = TestHarness::new().await; let (app_id, _) = setup_authenticated(&mut h).await; // First release let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "first" }).to_string(), ) .await; assert_eq!(resp.status, 201); // Duplicate version let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "duplicate" }).to_string(), ) .await; assert_eq!( resp.status, 409, "Duplicate version should return 409 Conflict: {}", resp.text ); } #[tokio::test] async fn upload_artifact() { let mut h = harness_with_synckit_storage().await; let (app_id, _) = setup_authenticated(&mut h).await; // Create release let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "0.3.0", "notes": "New release" }) .to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); // Upload artifact let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 12_345_678, "signature": TEST_SIGNATURE }) .to_string(), ) .await; assert_eq!(resp.status, 201, "Upload artifact failed: {}", resp.text); let upload: UploadArtifactResponse = resp.json(); assert!(!upload.upload_url.is_empty()); } #[tokio::test] async fn updater_check_newer_version() { let mut h = harness_with_synckit_storage().await; let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await; // Authenticate let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); // Create release let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.2.0", "notes": "Big update" }) .to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); // Upload artifact for linux/x86_64 let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 5_000_000, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); // The artifact is scan-gated; mark it clean so the updater advertises it. mark_artifacts_clean(&h.db, &release.id).await; // Check for update with older version (unauthenticated) h.client.clear_bearer_token(); let resp = h .client .get("/api/sync/ota/testapp/linux/x86_64/1.0.0") .await; assert_eq!(resp.status, 200, "Should return update: {}", resp.text); let update: TauriUpdaterResponse = resp.json(); assert_eq!(update.version, "1.2.0"); assert_eq!(update.signature, TEST_SIGNATURE); assert_eq!(update.notes, "Big update"); assert!(update.url.contains("/download/")); } /// A freshly-uploaded artifact is scan-gated: the updater returns 204 until the /// artifact is scanned clean, then advertises it. Regression for the OTA /// scan-bypass (audit 2026-07-01). #[tokio::test] async fn updater_check_gated_until_artifact_clean() { let mut h = harness_with_synckit_storage().await; let user_id = h .signup("otagate", "otagate@example.com", "Password1!") .await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "gateapp").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "otagate@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.5.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); h.client.clear_bearer_token(); // Pending (unscanned) artifact: no update advertised. let resp = h .client .get("/api/sync/ota/gateapp/linux/x86_64/1.0.0") .await; assert_eq!(resp.status, 204, "pending artifact must not be advertised"); // After the scan clears it, the update is advertised. mark_artifacts_clean(&h.db, &release.id).await; let resp = h .client .get("/api/sync/ota/gateapp/linux/x86_64/1.0.0") .await; assert_eq!( resp.status, 200, "clean artifact should be advertised: {}", resp.text ); } #[tokio::test] async fn updater_check_no_update() { let mut h = harness_with_synckit_storage().await; let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await; // Authenticate + create release 1.0.0 let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); // Check with same version, no update h.client.clear_bearer_token(); let resp = h .client .get("/api/sync/ota/testapp/linux/x86_64/1.0.0") .await; assert_eq!(resp.status, 204, "Same version = no update"); // Check with newer version, no update let resp = h .client .get("/api/sync/ota/testapp/linux/x86_64/2.0.0") .await; assert_eq!(resp.status, 204, "Newer version = no update"); } #[tokio::test] async fn updater_check_missing_platform() { let mut h = harness_with_synckit_storage().await; let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); // Create release + linux artifact only let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "2.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); // Check for darwin (no artifact), should be 204 h.client.clear_bearer_token(); let resp = h .client .get("/api/sync/ota/testapp/darwin/aarch64/1.0.0") .await; assert_eq!( resp.status, 204, "Missing platform artifact should return 204" ); } /// The artifact-register route rejects an empty/implausible signature: an /// unsigned artifact can never be installed (Tauri refuses it), so publishing one /// just advertises a dead download. #[tokio::test] async fn artifact_register_rejects_missing_signature() { let mut h = harness_with_synckit_storage().await; let (app_id, _) = setup_authenticated(&mut h).await; let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); // No signature field at all. let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), ) .await; assert_eq!(resp.status, 400, "missing signature must be rejected"); // Present but too short to be a real minisign signature. let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": "short" }) .to_string(), ) .await; assert_eq!(resp.status, 400, "too-short signature must be rejected"); } /// Regression for the per-artifact signature bug (audit 2026-07-21): two /// platforms of one release each carry their OWN minisign signature, and the /// updater serves each platform its own, not a single shared release-level one. /// Before the fix, the second platform was advertised with the first's signature /// and the Tauri updater silently refused it. #[tokio::test] async fn updater_serves_per_artifact_signature() { let mut h = harness_with_synckit_storage().await; let user_id = h .signup("otaperart", "peart@example.com", "Password1!") .await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "perart").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "peart@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "3.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); // Two platforms, two distinct signatures. for (target, arch, sig) in [ ("linux", "x86_64", TEST_SIGNATURE), ("darwin", "aarch64", TEST_SIGNATURE_B), ] { let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": target, "arch": arch, "file_size": 1000, "signature": sig }) .to_string(), ) .await; assert_eq!(resp.status, 201, "upload {target}/{arch}: {}", resp.text); } mark_artifacts_clean(&h.db, &release.id).await; h.client.clear_bearer_token(); // Each platform's updater check returns THAT platform's signature. let resp = h .client .get("/api/sync/ota/perart/linux/x86_64/1.0.0") .await; assert_eq!(resp.status, 200, "linux update: {}", resp.text); let update: TauriUpdaterResponse = resp.json(); assert_eq!( update.signature, TEST_SIGNATURE, "linux gets its own signature" ); let resp = h .client .get("/api/sync/ota/perart/darwin/aarch64/1.0.0") .await; assert_eq!(resp.status, 200, "darwin update: {}", resp.text); let update: TauriUpdaterResponse = resp.json(); assert_eq!( update.signature, TEST_SIGNATURE_B, "darwin gets its own signature, not linux's" ); } #[tokio::test] async fn artifact_download_redirect() { let mut h = harness_with_synckit_storage().await; let user_id = h.signup("otauser", "ota@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "dlapp").await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "ota@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 999, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); // The s3_key the download route would presign. let _s3_key = format!("ota/{app_id}/1.0.0/linux/x86_64/artifact"); // The redirect itself is not exercised: `InMemoryStorage::presign_download` // checks the object exists, the upload endpoint only presigns, and the // harness does not expose `synckit_storage` for a manual injection. This // test covers release and artifact creation only. } #[tokio::test] async fn delete_release_cascades() { let mut h = harness_with_synckit_storage().await; let (app_id, _) = setup_authenticated(&mut h).await; // Create release + artifact let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_id}/releases"), &json!({ "version": "1.0.0", "notes": "" }).to_string(), ) .await; assert_eq!(resp.status, 201); let release: ReleaseResponse = resp.json(); let resp = h .client .post_json( &format!( "/api/sync/ota/apps/{}/releases/{}/artifacts", app_id, release.id ), &json!({ "target": "linux", "arch": "x86_64", "file_size": 500, "signature": TEST_SIGNATURE }).to_string(), ) .await; assert_eq!(resp.status, 201); // Verify release exists let resp = h .client .get(&format!("/api/sync/ota/apps/{app_id}/releases")) .await; let releases: Vec = resp.json(); assert_eq!(releases.len(), 1); // Delete release let resp = h .client .delete(&format!( "/api/sync/ota/apps/{}/releases/{}", app_id, release.id )) .await; assert_eq!(resp.status, 204, "Delete failed: {}", resp.text); // Verify release is gone let resp = h .client .get(&format!("/api/sync/ota/apps/{app_id}/releases")) .await; let releases: Vec = resp.json(); assert_eq!(releases.len(), 0); // Verify artifact cascade (check DB directly) let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ota_artifacts WHERE release_id = $1") .bind(release.id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count.0, 0, "Artifacts should be cascade-deleted"); } #[tokio::test] async fn ownership_check() { let mut h = TestHarness::new().await; // User A creates an app let user_a = h.signup("usera", "a@example.com", "Password1!").await; let (app_a_id, _) = create_sync_app(&h.db, user_a).await; // User B signs up and gets a different app + JWT let user_b = h.signup("userb", "b@example.com", "Password1!").await; let (_, api_key_b) = create_sync_app(&h.db, user_b).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "b@example.com", "password": "Password1!", "api_key": api_key_b, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200); let auth_b: AuthResponse = resp.json(); h.client.set_bearer_token(&auth_b.token); // User B tries to set slug on User A's app let resp = h .client .put_json( &format!("/api/sync/ota/apps/{app_a_id}/slug"), &json!({ "slug": "stolen" }).to_string(), ) .await; assert_eq!(resp.status, 403, "Should deny cross-user access"); // User B tries to create release on User A's app let resp = h .client .post_json( &format!("/api/sync/ota/apps/{app_a_id}/releases"), &json!({ "version": "9.9.9", "notes": "hack" }).to_string(), ) .await; assert_eq!(resp.status, 403, "Should deny cross-user release creation"); } /// Run #10 regression (Storage HIGH): OTA artifacts live in the synckit bucket, /// but `is_s3_key_live`'s synckit branch used to check only `sync_blobs`. With /// OTA keys being deterministic, a delete-then-reupload of the same release /// reclaimed the exact key and the deletion worker wiped the live artifact. The /// synckit branch now also checks `ota_artifacts`. #[tokio::test] async fn is_s3_key_live_covers_ota_artifacts_in_synckit_bucket() { let mut h = TestHarness::new().await; let user_id = h .signup("otaliveuser", "otalive@example.com", "Password1!") .await; let (app_id, _key) = create_sync_app(&h.db, user_id).await; let release_id: OtaReleaseId = sqlx::query_scalar( "INSERT INTO ota_releases (app_id, version, notes, signature) VALUES ($1, '1.0.0', '', 'sig') RETURNING id", ) .bind(app_id) .fetch_one(&h.db) .await .unwrap(); let key = format!("ota/{app_id}/1.0.0/darwin/aarch64/app.tar.gz"); sqlx::query("INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size) VALUES ($1, 'darwin', 'aarch64', $2, 1234)") .bind(release_id) .bind(&key) .execute(&h.db) .await .unwrap(); // A live OTA artifact must be reported live so the deletion worker skips it. assert!( makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "synckit", &key) .await .unwrap(), "live OTA artifact in the synckit bucket must be reported live" ); // A key with no backing row is not live. assert!( !makenotwork::db::pending_s3_deletions::is_s3_key_live( &h.db, "synckit", "ota/ghost/0.0.0/x/y/z" ) .await .unwrap(), "an unreferenced synckit key must not be reported live" ); } /// Registry regression (Storage A+): the `main`-bucket branch of `is_s3_key_live` /// is generated from the same `S3_KEY_REFS` registry as the synckit branch. A /// live `media_files` row must report its key as live so the deletion worker /// skips it (delete-then-reupload race), and an unreferenced key must not. #[tokio::test] async fn is_s3_key_live_covers_main_bucket() { let mut h = TestHarness::new().await; let user_id = h .signup("mainliveuser", "mainlive@example.com", "Password1!") .await; let key = format!("{user_id}/media/cover.png"); sqlx::query( "INSERT INTO media_files (user_id, filename, s3_key, content_type, media_type) \ VALUES ($1, 'cover.png', $2, 'image/png', 'image')", ) .bind(user_id) .bind(&key) .execute(&h.db) .await .unwrap(); assert!( makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", &key) .await .unwrap(), "a live media_files key must be reported live in the main bucket" ); assert!( !makenotwork::db::pending_s3_deletions::is_s3_key_live( &h.db, "main", "nobody/media/ghost.mp3" ) .await .unwrap(), "an unreferenced main-bucket key must not be reported live" ); } #[tokio::test] async fn is_s3_key_live_matches_project_cover_by_bare_key() { // Migration 152: project covers store a bare `cover_s3_key` and liveness is // an exact key match (no URL-suffix matching). A project whose cover key is // set must report that key live, regardless of the cover_image_url value. // Covers are CDN-served, so their content object (and thus its liveness ref) // lives in the PUBLIC bucket post-promote (S1 public/private split). let mut h = TestHarness::new().await; let user_id = h .signup("coverliveuser", "coverlive@example.com", "Password1!") .await; let pid: uuid::Uuid = sqlx::query_scalar( "INSERT INTO projects (user_id, slug, title, cover_image_url, cover_s3_key) \ VALUES ($1, 'cov', 'Cov', 'https://cdn.example/abc/projects/p/image/c.png', $2) RETURNING id", ) .bind(user_id) .bind("abc/projects/p/image/c.png") .fetch_one(&h.db) .await .unwrap(); let _ = pid; assert!( makenotwork::db::pending_s3_deletions::is_s3_key_live( &h.db, "public", "abc/projects/p/image/c.png" ) .await .unwrap(), "a project cover's bare s3_key must be reported live in the public bucket" ); assert!( !makenotwork::db::pending_s3_deletions::is_s3_key_live( &h.db, "public", "abc/projects/p/image/OTHER.png" ) .await .unwrap(), "a non-matching cover key must not be reported live" ); }