Skip to main content

max / makenotwork

35.1 KB · 1150 lines History Blame Raw
1 //! OTA update integration tests, slug management, releases, artifacts, updater endpoint.
2
3 use crate::harness::{BuildOptions, TestHarness, storage::InMemoryStorage};
4 use makenotwork::db::{OtaReleaseId, SyncAppId, UserId};
5 use serde::Deserialize;
6 use serde_json::json;
7 use sqlx::PgPool;
8 use std::sync::Arc;
9
10 /// The artifact-register route rejects signatures shorter than 40 chars (a real
11 /// base64-encoded minisign signature is well over that), so any test that
12 /// uploads an artifact must supply a plausible-length one. Tests that assert the
13 /// *reject* path use their own short/empty values inline.
14 const TEST_SIGNATURE: &str =
15 "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZQoxYWJjZGVmZ2hpamtsbW5vcA==";
16
17 /// A second distinct signature, used to prove each artifact carries its own,
18 /// the whole point of moving the signature onto `ota_artifacts`.
19 const TEST_SIGNATURE_B: &str =
20 "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZSBCCjJ6eXh3dnV0c3JxcG9ubQ==";
21
22 // ── Response types ──
23
24 #[derive(Deserialize)]
25 struct AuthResponse {
26 token: String,
27 #[serde(rename = "user_id")]
28 _user_id: UserId,
29 #[serde(rename = "app_id")]
30 _app_id: SyncAppId,
31 }
32
33 #[derive(Deserialize)]
34 struct ReleaseResponse {
35 id: OtaReleaseId,
36 version: String,
37 }
38
39 #[derive(Deserialize)]
40 struct UploadArtifactResponse {
41 upload_url: String,
42 s3_key: String,
43 }
44
45 #[derive(Deserialize)]
46 struct TauriUpdaterResponse {
47 version: String,
48 url: String,
49 signature: String,
50 notes: String,
51 }
52
53 // ── Helpers ──
54
55 /// Insert a sync app with a slug directly via SQL.
56 async fn create_sync_app_with_slug(
57 pool: &PgPool,
58 user_id: UserId,
59 slug: &str,
60 ) -> (SyncAppId, String) {
61 let api_key = format!("test-ota-key-{slug}");
62 let key_hash = crate::harness::hash_api_key(&api_key);
63 let key_prefix = &api_key[..8];
64 let app_id: SyncAppId = sqlx::query_scalar(
65 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, slug) VALUES ($1, $2, $3, $4, $5) RETURNING id",
66 )
67 .bind(user_id)
68 .bind(format!("OTA App {slug}"))
69 .bind(&key_hash)
70 .bind(key_prefix)
71 .bind(slug)
72 .fetch_one(pool)
73 .await
74 .expect("Failed to create sync app");
75
76 (app_id, api_key)
77 }
78
79 /// Insert a sync app without a slug.
80 async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
81 let api_key = format!("test-ota-key-{}", uuid::Uuid::new_v4());
82 let key_hash = crate::harness::hash_api_key(&api_key);
83 let key_prefix = &api_key[..8];
84 let app_id: SyncAppId = sqlx::query_scalar(
85 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'OTA App', $2, $3) RETURNING id",
86 )
87 .bind(user_id)
88 .bind(&key_hash)
89 .bind(key_prefix)
90 .fetch_one(pool)
91 .await
92 .expect("Failed to create sync app");
93
94 (app_id, api_key)
95 }
96
97 /// Sign up, create an app, get a JWT token.
98 async fn setup_authenticated(h: &mut TestHarness) -> (SyncAppId, String) {
99 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
100 let (app_id, api_key) = create_sync_app(&h.db, user_id).await;
101
102 let resp = h
103 .client
104 .post_json(
105 "/api/sync/auth",
106 &json!({
107 "email": "ota@example.com",
108 "password": "Password1!",
109 "api_key": api_key,
110 "key": "test-sdk-key",
111 })
112 .to_string(),
113 )
114 .await;
115 assert_eq!(resp.status, 200, "Auth failed: {}", resp.text);
116
117 let auth: AuthResponse = resp.json();
118 h.client.set_bearer_token(&auth.token);
119
120 (app_id, api_key)
121 }
122
123 /// Build a harness with synckit storage enabled (needed for artifact upload/download).
124 async fn harness_with_synckit_storage() -> TestHarness {
125 TestHarness::with_synckit_storage().await
126 }
127
128 /// Same harness, with the storage handle kept. The register-artifact route only
129 /// presigns, so nothing ever lands in the backend; a download test has to put
130 /// the object there itself, standing in for the client PUT.
131 async fn harness_with_synckit_blobs() -> (TestHarness, Arc<InMemoryStorage>) {
132 let mem = Arc::new(InMemoryStorage::new());
133 let h = TestHarness::build(BuildOptions {
134 synckit_storage: Some(mem.clone()),
135 ..Default::default()
136 })
137 .await;
138 (h, mem)
139 }
140
141 /// Mark a release's artifacts scanned-clean (simulates a completed scan). New
142 /// artifacts start `pending` and are not advertised/downloadable until the scan
143 /// pipeline clears them; serve-path tests call this to reach the served state.
144 async fn mark_artifacts_clean(pool: &PgPool, release_id: &makenotwork::db::OtaReleaseId) {
145 sqlx::query("UPDATE ota_artifacts SET scan_status = 'clean' WHERE release_id = $1")
146 .bind(release_id)
147 .execute(pool)
148 .await
149 .unwrap();
150 }
151
152 // ── Tests ──
153
154 #[tokio::test]
155 async fn set_app_slug() {
156 let mut h = TestHarness::new().await;
157 let (app_id, _) = setup_authenticated(&mut h).await;
158
159 // Set slug
160 let resp = h
161 .client
162 .put_json(
163 &format!("/api/sync/ota/apps/{app_id}/slug"),
164 &json!({ "slug": "goingson" }).to_string(),
165 )
166 .await;
167 assert_eq!(resp.status, 204, "Set slug failed: {}", resp.text);
168
169 // Verify the slug is set (the updater endpoint should resolve it, returning 204 = no releases)
170 h.client.clear_bearer_token();
171 let resp = h
172 .client
173 .get("/api/sync/ota/goingson/linux/x86_64/0.0.1")
174 .await;
175 assert_eq!(resp.status, 204, "Slug lookup should work: {}", resp.text);
176 }
177
178 #[tokio::test]
179 async fn slug_validation() {
180 let mut h = TestHarness::new().await;
181 let (app_id, _) = setup_authenticated(&mut h).await;
182
183 // Too short (2 chars)
184 let resp = h
185 .client
186 .put_json(
187 &format!("/api/sync/ota/apps/{app_id}/slug"),
188 &json!({ "slug": "ab" }).to_string(),
189 )
190 .await;
191 assert_eq!(resp.status, 400, "Should reject 2-char slug");
192
193 // Uppercase
194 let resp = h
195 .client
196 .put_json(
197 &format!("/api/sync/ota/apps/{app_id}/slug"),
198 &json!({ "slug": "GoingsOn" }).to_string(),
199 )
200 .await;
201 assert_eq!(resp.status, 400, "Should reject uppercase");
202
203 // Special chars
204 let resp = h
205 .client
206 .put_json(
207 &format!("/api/sync/ota/apps/{app_id}/slug"),
208 &json!({ "slug": "my_app!" }).to_string(),
209 )
210 .await;
211 assert_eq!(resp.status, 400, "Should reject special chars");
212
213 // Leading hyphen
214 let resp = h
215 .client
216 .put_json(
217 &format!("/api/sync/ota/apps/{app_id}/slug"),
218 &json!({ "slug": "-myapp" }).to_string(),
219 )
220 .await;
221 assert_eq!(resp.status, 400, "Should reject leading hyphen");
222
223 // Valid slug should work
224 let resp = h
225 .client
226 .put_json(
227 &format!("/api/sync/ota/apps/{app_id}/slug"),
228 &json!({ "slug": "my-app" }).to_string(),
229 )
230 .await;
231 assert_eq!(resp.status, 204, "Valid slug should work: {}", resp.text);
232 }
233
234 #[tokio::test]
235 async fn slug_uniqueness() {
236 let mut h = TestHarness::new().await;
237 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
238 let (app1_id, api_key) = create_sync_app(&h.db, user_id).await;
239
240 // Authenticate
241 let resp = h
242 .client
243 .post_json(
244 "/api/sync/auth",
245 &json!({
246 "email": "ota@example.com",
247 "password": "Password1!",
248 "api_key": api_key,
249 "key": "test-sdk-key",
250 })
251 .to_string(),
252 )
253 .await;
254 let auth: AuthResponse = resp.json();
255 h.client.set_bearer_token(&auth.token);
256
257 // Set slug on app1
258 let resp = h
259 .client
260 .put_json(
261 &format!("/api/sync/ota/apps/{app1_id}/slug"),
262 &json!({ "slug": "unique-slug" }).to_string(),
263 )
264 .await;
265 assert_eq!(resp.status, 204);
266
267 // Create a second app and try the same slug
268 let api_key2 = "test-ota-key-second";
269 let key_hash2 = crate::harness::hash_api_key(api_key2);
270 let key_prefix2 = &api_key2[..8];
271 let app2_id: SyncAppId = sqlx::query_scalar(
272 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'Second', $2, $3) RETURNING id",
273 )
274 .bind(user_id)
275 .bind(&key_hash2)
276 .bind(key_prefix2)
277 .fetch_one(&h.db)
278 .await
279 .unwrap();
280
281 // Re-authenticate with second app's key
282 let resp = h
283 .client
284 .post_json(
285 "/api/sync/auth",
286 &json!({
287 "email": "ota@example.com",
288 "password": "Password1!",
289 "api_key": api_key2,
290 "key": "test-sdk-key",
291 })
292 .to_string(),
293 )
294 .await;
295 let auth2: AuthResponse = resp.json();
296 h.client.set_bearer_token(&auth2.token);
297
298 let resp = h
299 .client
300 .put_json(
301 &format!("/api/sync/ota/apps/{app2_id}/slug"),
302 &json!({ "slug": "unique-slug" }).to_string(),
303 )
304 .await;
305 assert_eq!(
306 resp.status, 500,
307 "Duplicate slug should fail: {}",
308 resp.text
309 );
310 }
311
312 #[tokio::test]
313 async fn create_and_list_releases() {
314 let mut h = TestHarness::new().await;
315 let (app_id, _) = setup_authenticated(&mut h).await;
316
317 // Create a release
318 let resp = h
319 .client
320 .post_json(
321 &format!("/api/sync/ota/apps/{app_id}/releases"),
322 &json!({
323 "version": "0.2.1",
324 "notes": "Bug fixes"
325 })
326 .to_string(),
327 )
328 .await;
329 assert_eq!(resp.status, 201, "Create release failed: {}", resp.text);
330 let release: ReleaseResponse = resp.json();
331 assert_eq!(release.version, "0.2.1");
332
333 // List releases
334 let resp = h
335 .client
336 .get(&format!("/api/sync/ota/apps/{app_id}/releases"))
337 .await;
338 assert_eq!(resp.status, 200);
339 let releases: Vec<ReleaseResponse> = resp.json();
340 assert_eq!(releases.len(), 1);
341 assert_eq!(releases[0].version, "0.2.1");
342 }
343
344 #[tokio::test]
345 async fn version_validation() {
346 let mut h = TestHarness::new().await;
347 let (app_id, _) = setup_authenticated(&mut h).await;
348
349 // Invalid semver
350 let resp = h
351 .client
352 .post_json(
353 &format!("/api/sync/ota/apps/{app_id}/releases"),
354 &json!({ "version": "not-semver", "notes": "" }).to_string(),
355 )
356 .await;
357 assert_eq!(resp.status, 400, "Should reject non-semver");
358
359 // Also invalid
360 let resp = h
361 .client
362 .post_json(
363 &format!("/api/sync/ota/apps/{app_id}/releases"),
364 &json!({ "version": "1.2", "notes": "" }).to_string(),
365 )
366 .await;
367 assert_eq!(resp.status, 400, "Should reject incomplete semver");
368 }
369
370 #[tokio::test]
371 async fn duplicate_version() {
372 let mut h = TestHarness::new().await;
373 let (app_id, _) = setup_authenticated(&mut h).await;
374
375 // First release
376 let resp = h
377 .client
378 .post_json(
379 &format!("/api/sync/ota/apps/{app_id}/releases"),
380 &json!({ "version": "1.0.0", "notes": "first" }).to_string(),
381 )
382 .await;
383 assert_eq!(resp.status, 201);
384
385 // Duplicate version
386 let resp = h
387 .client
388 .post_json(
389 &format!("/api/sync/ota/apps/{app_id}/releases"),
390 &json!({ "version": "1.0.0", "notes": "duplicate" }).to_string(),
391 )
392 .await;
393 assert_eq!(
394 resp.status, 409,
395 "Duplicate version should return 409 Conflict: {}",
396 resp.text
397 );
398 }
399
400 #[tokio::test]
401 async fn upload_artifact() {
402 let mut h = harness_with_synckit_storage().await;
403 let (app_id, _) = setup_authenticated(&mut h).await;
404
405 // Create release
406 let resp = h
407 .client
408 .post_json(
409 &format!("/api/sync/ota/apps/{app_id}/releases"),
410 &json!({
411 "version": "0.3.0",
412 "notes": "New release"
413 })
414 .to_string(),
415 )
416 .await;
417 assert_eq!(resp.status, 201);
418 let release: ReleaseResponse = resp.json();
419
420 // Upload artifact
421 let resp = h
422 .client
423 .post_json(
424 &format!(
425 "/api/sync/ota/apps/{}/releases/{}/artifacts",
426 app_id, release.id
427 ),
428 &json!({
429 "target": "linux",
430 "arch": "x86_64",
431 "file_size": 12_345_678,
432 "signature": TEST_SIGNATURE
433 })
434 .to_string(),
435 )
436 .await;
437 assert_eq!(resp.status, 201, "Upload artifact failed: {}", resp.text);
438 let upload: UploadArtifactResponse = resp.json();
439 assert!(!upload.upload_url.is_empty());
440 }
441
442 #[tokio::test]
443 async fn updater_check_newer_version() {
444 let mut h = harness_with_synckit_storage().await;
445 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
446 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await;
447
448 // Authenticate
449 let resp = h
450 .client
451 .post_json(
452 "/api/sync/auth",
453 &json!({
454 "email": "ota@example.com",
455 "password": "Password1!",
456 "api_key": api_key,
457 "key": "test-sdk-key",
458 })
459 .to_string(),
460 )
461 .await;
462 let auth: AuthResponse = resp.json();
463 h.client.set_bearer_token(&auth.token);
464
465 // Create release
466 let resp = h
467 .client
468 .post_json(
469 &format!("/api/sync/ota/apps/{app_id}/releases"),
470 &json!({
471 "version": "1.2.0",
472 "notes": "Big update"
473 })
474 .to_string(),
475 )
476 .await;
477 assert_eq!(resp.status, 201);
478 let release: ReleaseResponse = resp.json();
479
480 // Upload artifact for linux/x86_64
481 let resp = h
482 .client
483 .post_json(
484 &format!(
485 "/api/sync/ota/apps/{}/releases/{}/artifacts",
486 app_id, release.id
487 ),
488 &json!({ "target": "linux", "arch": "x86_64", "file_size": 5_000_000, "signature": TEST_SIGNATURE }).to_string(),
489 )
490 .await;
491 assert_eq!(resp.status, 201);
492 // The artifact is scan-gated; mark it clean so the updater advertises it.
493 mark_artifacts_clean(&h.db, &release.id).await;
494
495 // Check for update with older version (unauthenticated)
496 h.client.clear_bearer_token();
497 let resp = h
498 .client
499 .get("/api/sync/ota/testapp/linux/x86_64/1.0.0")
500 .await;
501 assert_eq!(resp.status, 200, "Should return update: {}", resp.text);
502 let update: TauriUpdaterResponse = resp.json();
503 assert_eq!(update.version, "1.2.0");
504 assert_eq!(update.signature, TEST_SIGNATURE);
505 assert_eq!(update.notes, "Big update");
506 assert!(update.url.contains("/download/"));
507 }
508
509 /// A freshly-uploaded artifact is scan-gated: the updater returns 204 until the
510 /// artifact is scanned clean, then advertises it. Regression for the OTA
511 /// scan-bypass.
512 #[tokio::test]
513 async fn updater_check_gated_until_artifact_clean() {
514 let mut h = harness_with_synckit_storage().await;
515 let user_id = h
516 .signup("otagate", "otagate@example.com", "Password1!")
517 .await;
518 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "gateapp").await;
519
520 let resp = h
521 .client
522 .post_json(
523 "/api/sync/auth",
524 &json!({
525 "email": "otagate@example.com",
526 "password": "Password1!",
527 "api_key": api_key,
528 "key": "test-sdk-key",
529 })
530 .to_string(),
531 )
532 .await;
533 let auth: AuthResponse = resp.json();
534 h.client.set_bearer_token(&auth.token);
535
536 let resp = h
537 .client
538 .post_json(
539 &format!("/api/sync/ota/apps/{app_id}/releases"),
540 &json!({ "version": "1.5.0", "notes": "" }).to_string(),
541 )
542 .await;
543 assert_eq!(resp.status, 201);
544 let release: ReleaseResponse = resp.json();
545
546 let resp = h
547 .client
548 .post_json(
549 &format!(
550 "/api/sync/ota/apps/{}/releases/{}/artifacts",
551 app_id, release.id
552 ),
553 &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(),
554 )
555 .await;
556 assert_eq!(resp.status, 201);
557
558 h.client.clear_bearer_token();
559 // Pending (unscanned) artifact: no update advertised.
560 let resp = h
561 .client
562 .get("/api/sync/ota/gateapp/linux/x86_64/1.0.0")
563 .await;
564 assert_eq!(resp.status, 204, "pending artifact must not be advertised");
565
566 // After the scan clears it, the update is advertised.
567 mark_artifacts_clean(&h.db, &release.id).await;
568 let resp = h
569 .client
570 .get("/api/sync/ota/gateapp/linux/x86_64/1.0.0")
571 .await;
572 assert_eq!(
573 resp.status, 200,
574 "clean artifact should be advertised: {}",
575 resp.text
576 );
577 }
578
579 #[tokio::test]
580 async fn updater_check_no_update() {
581 let mut h = harness_with_synckit_storage().await;
582 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
583 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await;
584
585 // Authenticate + create release 1.0.0
586 let resp = h
587 .client
588 .post_json(
589 "/api/sync/auth",
590 &json!({
591 "email": "ota@example.com",
592 "password": "Password1!",
593 "api_key": api_key,
594 "key": "test-sdk-key",
595 })
596 .to_string(),
597 )
598 .await;
599 let auth: AuthResponse = resp.json();
600 h.client.set_bearer_token(&auth.token);
601
602 let resp = h
603 .client
604 .post_json(
605 &format!("/api/sync/ota/apps/{app_id}/releases"),
606 &json!({ "version": "1.0.0", "notes": "" }).to_string(),
607 )
608 .await;
609 assert_eq!(resp.status, 201);
610 let release: ReleaseResponse = resp.json();
611
612 let resp = h
613 .client
614 .post_json(
615 &format!(
616 "/api/sync/ota/apps/{}/releases/{}/artifacts",
617 app_id, release.id
618 ),
619 &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(),
620 )
621 .await;
622 assert_eq!(resp.status, 201);
623
624 // Check with same version, no update
625 h.client.clear_bearer_token();
626 let resp = h
627 .client
628 .get("/api/sync/ota/testapp/linux/x86_64/1.0.0")
629 .await;
630 assert_eq!(resp.status, 204, "Same version = no update");
631
632 // Check with newer version, no update
633 let resp = h
634 .client
635 .get("/api/sync/ota/testapp/linux/x86_64/2.0.0")
636 .await;
637 assert_eq!(resp.status, 204, "Newer version = no update");
638 }
639
640 #[tokio::test]
641 async fn updater_check_missing_platform() {
642 let mut h = harness_with_synckit_storage().await;
643 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
644 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "testapp").await;
645
646 let resp = h
647 .client
648 .post_json(
649 "/api/sync/auth",
650 &json!({
651 "email": "ota@example.com",
652 "password": "Password1!",
653 "api_key": api_key,
654 "key": "test-sdk-key",
655 })
656 .to_string(),
657 )
658 .await;
659 let auth: AuthResponse = resp.json();
660 h.client.set_bearer_token(&auth.token);
661
662 // Create release + linux artifact only
663 let resp = h
664 .client
665 .post_json(
666 &format!("/api/sync/ota/apps/{app_id}/releases"),
667 &json!({ "version": "2.0.0", "notes": "" }).to_string(),
668 )
669 .await;
670 assert_eq!(resp.status, 201);
671 let release: ReleaseResponse = resp.json();
672
673 let resp = h
674 .client
675 .post_json(
676 &format!(
677 "/api/sync/ota/apps/{}/releases/{}/artifacts",
678 app_id, release.id
679 ),
680 &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(),
681 )
682 .await;
683 assert_eq!(resp.status, 201);
684
685 // Check for darwin (no artifact), should be 204
686 h.client.clear_bearer_token();
687 let resp = h
688 .client
689 .get("/api/sync/ota/testapp/darwin/aarch64/1.0.0")
690 .await;
691 assert_eq!(
692 resp.status, 204,
693 "Missing platform artifact should return 204"
694 );
695 }
696
697 /// The artifact-register route rejects an empty/implausible signature: an
698 /// unsigned artifact can never be installed (Tauri refuses it), so publishing one
699 /// just advertises a dead download.
700 #[tokio::test]
701 async fn artifact_register_rejects_missing_signature() {
702 let mut h = harness_with_synckit_storage().await;
703 let (app_id, _) = setup_authenticated(&mut h).await;
704
705 let resp = h
706 .client
707 .post_json(
708 &format!("/api/sync/ota/apps/{app_id}/releases"),
709 &json!({ "version": "1.0.0", "notes": "" }).to_string(),
710 )
711 .await;
712 assert_eq!(resp.status, 201);
713 let release: ReleaseResponse = resp.json();
714
715 // No signature field at all.
716 let resp = h
717 .client
718 .post_json(
719 &format!(
720 "/api/sync/ota/apps/{}/releases/{}/artifacts",
721 app_id, release.id
722 ),
723 &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(),
724 )
725 .await;
726 assert_eq!(resp.status, 400, "missing signature must be rejected");
727
728 // Present but too short to be a real minisign signature.
729 let resp = h
730 .client
731 .post_json(
732 &format!(
733 "/api/sync/ota/apps/{}/releases/{}/artifacts",
734 app_id, release.id
735 ),
736 &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": "short" })
737 .to_string(),
738 )
739 .await;
740 assert_eq!(resp.status, 400, "too-short signature must be rejected");
741 }
742
743 /// Regression for the per-artifact signature bug: two
744 /// platforms of one release each carry their OWN minisign signature, and the
745 /// updater serves each platform its own, not a single shared release-level one.
746 /// Before the fix, the second platform was advertised with the first's signature
747 /// and the Tauri updater silently refused it.
748 #[tokio::test]
749 async fn updater_serves_per_artifact_signature() {
750 let mut h = harness_with_synckit_storage().await;
751 let user_id = h
752 .signup("otaperart", "peart@example.com", "Password1!")
753 .await;
754 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "perart").await;
755
756 let resp = h
757 .client
758 .post_json(
759 "/api/sync/auth",
760 &json!({
761 "email": "peart@example.com",
762 "password": "Password1!",
763 "api_key": api_key,
764 "key": "test-sdk-key",
765 })
766 .to_string(),
767 )
768 .await;
769 let auth: AuthResponse = resp.json();
770 h.client.set_bearer_token(&auth.token);
771
772 let resp = h
773 .client
774 .post_json(
775 &format!("/api/sync/ota/apps/{app_id}/releases"),
776 &json!({ "version": "3.0.0", "notes": "" }).to_string(),
777 )
778 .await;
779 assert_eq!(resp.status, 201);
780 let release: ReleaseResponse = resp.json();
781
782 // Two platforms, two distinct signatures.
783 for (target, arch, sig) in [
784 ("linux", "x86_64", TEST_SIGNATURE),
785 ("darwin", "aarch64", TEST_SIGNATURE_B),
786 ] {
787 let resp = h
788 .client
789 .post_json(
790 &format!(
791 "/api/sync/ota/apps/{}/releases/{}/artifacts",
792 app_id, release.id
793 ),
794 &json!({ "target": target, "arch": arch, "file_size": 1000, "signature": sig })
795 .to_string(),
796 )
797 .await;
798 assert_eq!(resp.status, 201, "upload {target}/{arch}: {}", resp.text);
799 }
800 mark_artifacts_clean(&h.db, &release.id).await;
801
802 h.client.clear_bearer_token();
803
804 // Each platform's updater check returns THAT platform's signature.
805 let resp = h
806 .client
807 .get("/api/sync/ota/perart/linux/x86_64/1.0.0")
808 .await;
809 assert_eq!(resp.status, 200, "linux update: {}", resp.text);
810 let update: TauriUpdaterResponse = resp.json();
811 assert_eq!(
812 update.signature, TEST_SIGNATURE,
813 "linux gets its own signature"
814 );
815
816 let resp = h
817 .client
818 .get("/api/sync/ota/perart/darwin/aarch64/1.0.0")
819 .await;
820 assert_eq!(resp.status, 200, "darwin update: {}", resp.text);
821 let update: TauriUpdaterResponse = resp.json();
822 assert_eq!(
823 update.signature, TEST_SIGNATURE_B,
824 "darwin gets its own signature, not linux's"
825 );
826 }
827
828 #[tokio::test]
829 async fn artifact_download_redirect() {
830 let (mut h, blobs) = harness_with_synckit_blobs().await;
831 let user_id = h.signup("otauser", "ota@example.com", "Password1!").await;
832 let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "dlapp").await;
833
834 let resp = h
835 .client
836 .post_json(
837 "/api/sync/auth",
838 &json!({
839 "email": "ota@example.com",
840 "password": "Password1!",
841 "api_key": api_key,
842 "key": "test-sdk-key",
843 })
844 .to_string(),
845 )
846 .await;
847 let auth: AuthResponse = resp.json();
848 h.client.set_bearer_token(&auth.token);
849
850 let resp = h
851 .client
852 .post_json(
853 &format!("/api/sync/ota/apps/{app_id}/releases"),
854 &json!({ "version": "1.0.0", "notes": "" }).to_string(),
855 )
856 .await;
857 assert_eq!(resp.status, 201);
858 let release: ReleaseResponse = resp.json();
859
860 let resp = h
861 .client
862 .post_json(
863 &format!(
864 "/api/sync/ota/apps/{}/releases/{}/artifacts",
865 app_id, release.id
866 ),
867 &json!({ "target": "linux", "arch": "x86_64", "file_size": 999, "signature": TEST_SIGNATURE }).to_string(),
868 )
869 .await;
870 assert_eq!(resp.status, 201);
871 let upload: UploadArtifactResponse = resp.json();
872
873 // The client PUT the route presigned for, performed against the backend
874 // directly: `presign_download` refuses a key with no object behind it, and
875 // the download route checks `object_exists` before it presigns anything.
876 blobs.put(&upload.s3_key, b"artifact bytes".to_vec());
877 mark_artifacts_clean(&h.db, &release.id).await;
878
879 let resp = h
880 .client
881 .get(&format!(
882 "/api/sync/ota/dlapp/download/{}/linux/x86_64",
883 release.id
884 ))
885 .await;
886 assert_eq!(resp.status, 302, "download: {}", resp.text);
887 let expected = format!("http://test-storage/{}", upload.s3_key);
888 assert_eq!(
889 resp.header("location"),
890 Some(expected.as_str()),
891 "redirects to a presigned URL for the artifact's own key"
892 );
893
894 // A target with no artifact 404s rather than redirecting somewhere wrong.
895 let resp = h
896 .client
897 .get(&format!(
898 "/api/sync/ota/dlapp/download/{}/darwin/aarch64",
899 release.id
900 ))
901 .await;
902 assert_eq!(resp.status, 404, "no darwin artifact: {}", resp.text);
903 }
904
905 #[tokio::test]
906 async fn delete_release_cascades() {
907 let mut h = harness_with_synckit_storage().await;
908 let (app_id, _) = setup_authenticated(&mut h).await;
909
910 // Create release + artifact
911 let resp = h
912 .client
913 .post_json(
914 &format!("/api/sync/ota/apps/{app_id}/releases"),
915 &json!({ "version": "1.0.0", "notes": "" }).to_string(),
916 )
917 .await;
918 assert_eq!(resp.status, 201);
919 let release: ReleaseResponse = resp.json();
920
921 let resp = h
922 .client
923 .post_json(
924 &format!(
925 "/api/sync/ota/apps/{}/releases/{}/artifacts",
926 app_id, release.id
927 ),
928 &json!({ "target": "linux", "arch": "x86_64", "file_size": 500, "signature": TEST_SIGNATURE }).to_string(),
929 )
930 .await;
931 assert_eq!(resp.status, 201);
932
933 // Verify release exists
934 let resp = h
935 .client
936 .get(&format!("/api/sync/ota/apps/{app_id}/releases"))
937 .await;
938 let releases: Vec<ReleaseResponse> = resp.json();
939 assert_eq!(releases.len(), 1);
940
941 // Delete release
942 let resp = h
943 .client
944 .delete(&format!(
945 "/api/sync/ota/apps/{}/releases/{}",
946 app_id, release.id
947 ))
948 .await;
949 assert_eq!(resp.status, 204, "Delete failed: {}", resp.text);
950
951 // Verify release is gone
952 let resp = h
953 .client
954 .get(&format!("/api/sync/ota/apps/{app_id}/releases"))
955 .await;
956 let releases: Vec<ReleaseResponse> = resp.json();
957 assert_eq!(releases.len(), 0);
958
959 // Verify artifact cascade (check DB directly)
960 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ota_artifacts WHERE release_id = $1")
961 .bind(release.id)
962 .fetch_one(&h.db)
963 .await
964 .unwrap();
965 assert_eq!(count.0, 0, "Artifacts should be cascade-deleted");
966 }
967
968 #[tokio::test]
969 async fn ownership_check() {
970 let mut h = TestHarness::new().await;
971
972 // User A creates an app
973 let user_a = h.signup("usera", "a@example.com", "Password1!").await;
974 let (app_a_id, _) = create_sync_app(&h.db, user_a).await;
975
976 // User B signs up and gets a different app + JWT
977 let user_b = h.signup("userb", "b@example.com", "Password1!").await;
978 let (_, api_key_b) = create_sync_app(&h.db, user_b).await;
979
980 let resp = h
981 .client
982 .post_json(
983 "/api/sync/auth",
984 &json!({
985 "email": "b@example.com",
986 "password": "Password1!",
987 "api_key": api_key_b,
988 "key": "test-sdk-key",
989 })
990 .to_string(),
991 )
992 .await;
993 assert_eq!(resp.status, 200);
994 let auth_b: AuthResponse = resp.json();
995 h.client.set_bearer_token(&auth_b.token);
996
997 // User B tries to set slug on User A's app
998 let resp = h
999 .client
1000 .put_json(
1001 &format!("/api/sync/ota/apps/{app_a_id}/slug"),
1002 &json!({ "slug": "stolen" }).to_string(),
1003 )
1004 .await;
1005 assert_eq!(resp.status, 403, "Should deny cross-user access");
1006
1007 // User B tries to create release on User A's app
1008 let resp = h
1009 .client
1010 .post_json(
1011 &format!("/api/sync/ota/apps/{app_a_id}/releases"),
1012 &json!({ "version": "9.9.9", "notes": "hack" }).to_string(),
1013 )
1014 .await;
1015 assert_eq!(resp.status, 403, "Should deny cross-user release creation");
1016 }
1017
1018 /// OTA artifacts live in the synckit bucket, so `is_s3_key_live`'s synckit
1019 /// branch must check `ota_artifacts` as well as `sync_blobs`. OTA keys are
1020 /// deterministic, so a delete-then-reupload of the same release reclaims the
1021 /// exact key, and a branch that misses `ota_artifacts` lets the deletion worker
1022 /// wipe the live artifact.
1023 #[tokio::test]
1024 async fn is_s3_key_live_covers_ota_artifacts_in_synckit_bucket() {
1025 let mut h = TestHarness::new().await;
1026 let user_id = h
1027 .signup("otaliveuser", "otalive@example.com", "Password1!")
1028 .await;
1029 let (app_id, _key) = create_sync_app(&h.db, user_id).await;
1030
1031 let release_id: OtaReleaseId = sqlx::query_scalar(
1032 "INSERT INTO ota_releases (app_id, version, notes, signature) VALUES ($1, '1.0.0', '', 'sig') RETURNING id",
1033 )
1034 .bind(app_id)
1035 .fetch_one(&h.db)
1036 .await
1037 .unwrap();
1038
1039 let key = format!("ota/{app_id}/1.0.0/darwin/aarch64/app.tar.gz");
1040 sqlx::query("INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size) VALUES ($1, 'darwin', 'aarch64', $2, 1234)")
1041 .bind(release_id)
1042 .bind(&key)
1043 .execute(&h.db)
1044 .await
1045 .unwrap();
1046
1047 // A live OTA artifact must be reported live so the deletion worker skips it.
1048 assert!(
1049 makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "synckit", &key)
1050 .await
1051 .unwrap(),
1052 "live OTA artifact in the synckit bucket must be reported live"
1053 );
1054 // A key with no backing row is not live.
1055 assert!(
1056 !makenotwork::db::pending_s3_deletions::is_s3_key_live(
1057 &h.db,
1058 "synckit",
1059 "ota/ghost/0.0.0/x/y/z"
1060 )
1061 .await
1062 .unwrap(),
1063 "an unreferenced synckit key must not be reported live"
1064 );
1065 }
1066
1067 /// Registry regression (Storage A+): the `main`-bucket branch of `is_s3_key_live`
1068 /// is generated from the same `S3_KEY_REFS` registry as the synckit branch. A
1069 /// live `media_files` row must report its key as live so the deletion worker
1070 /// skips it (delete-then-reupload race), and an unreferenced key must not.
1071 #[tokio::test]
1072 async fn is_s3_key_live_covers_main_bucket() {
1073 let mut h = TestHarness::new().await;
1074 let user_id = h
1075 .signup("mainliveuser", "mainlive@example.com", "Password1!")
1076 .await;
1077
1078 let key = format!("{user_id}/media/cover.png");
1079 sqlx::query(
1080 "INSERT INTO media_files (user_id, filename, s3_key, content_type, media_type) \
1081 VALUES ($1, 'cover.png', $2, 'image/png', 'image')",
1082 )
1083 .bind(user_id)
1084 .bind(&key)
1085 .execute(&h.db)
1086 .await
1087 .unwrap();
1088
1089 assert!(
1090 makenotwork::db::pending_s3_deletions::is_s3_key_live(&h.db, "main", &key)
1091 .await
1092 .unwrap(),
1093 "a live media_files key must be reported live in the main bucket"
1094 );
1095 assert!(
1096 !makenotwork::db::pending_s3_deletions::is_s3_key_live(
1097 &h.db,
1098 "main",
1099 "nobody/media/ghost.mp3"
1100 )
1101 .await
1102 .unwrap(),
1103 "an unreferenced main-bucket key must not be reported live"
1104 );
1105 }
1106
1107 #[tokio::test]
1108 async fn is_s3_key_live_matches_project_cover_by_bare_key() {
1109 // Migration 152: project covers store a bare `cover_s3_key` and liveness is
1110 // an exact key match (no URL-suffix matching). A project whose cover key is
1111 // set must report that key live, regardless of the cover_image_url value.
1112 // Covers are CDN-served, so their content object (and thus its liveness ref)
1113 // lives in the PUBLIC bucket post-promote (S1 public/private split).
1114 let mut h = TestHarness::new().await;
1115 let user_id = h
1116 .signup("coverliveuser", "coverlive@example.com", "Password1!")
1117 .await;
1118 let pid: uuid::Uuid = sqlx::query_scalar(
1119 "INSERT INTO projects (user_id, slug, title, cover_image_url, cover_s3_key) \
1120 VALUES ($1, 'cov', 'Cov', 'https://cdn.example/abc/projects/p/image/c.png', $2) RETURNING id",
1121 )
1122 .bind(user_id)
1123 .bind("abc/projects/p/image/c.png")
1124 .fetch_one(&h.db)
1125 .await
1126 .unwrap();
1127 let _ = pid;
1128
1129 assert!(
1130 makenotwork::db::pending_s3_deletions::is_s3_key_live(
1131 &h.db,
1132 "public",
1133 "abc/projects/p/image/c.png"
1134 )
1135 .await
1136 .unwrap(),
1137 "a project cover's bare s3_key must be reported live in the public bucket"
1138 );
1139 assert!(
1140 !makenotwork::db::pending_s3_deletions::is_s3_key_live(
1141 &h.db,
1142 "public",
1143 "abc/projects/p/image/OTHER.png"
1144 )
1145 .await
1146 .unwrap(),
1147 "a non-matching cover key must not be reported live"
1148 );
1149 }
1150