max / makenotwork
1 file changed,
+384 insertions,
-0 deletions
| @@ -1451,3 +1451,387 @@ async fn confirm_item_image_tx_error_rolls_back_and_orphans() { | |||
| 1451 | 1451 | assert!(resp.status.is_server_error(), "tx-error item image confirm must 5xx: {} {}", resp.status, resp.text); | |
| 1452 | 1452 | assert_uncredited_and_orphaned(&h, &user_id, &s3_key, before, "item_image_update_failed").await; | |
| 1453 | 1453 | } | |
| 1454 | + | ||
| 1455 | + | // --------------------------------------------------------------------------- | |
| 1456 | + | // Multipart upload sessions (CLI / desktop, large files) | |
| 1457 | + | // | |
| 1458 | + | // These live on the internal surface only — a browser keeps the one-shot | |
| 1459 | + | // presigned PUT. The InMemoryStorage backend stubs the session (its presigned | |
| 1460 | + | // URLs are fake, so no client bytes flow back), so a full start -> confirm test | |
| 1461 | + | // simulates the part PUTs with `put()`, exactly as the presign+confirm tests do. | |
| 1462 | + | // --------------------------------------------------------------------------- | |
| 1463 | + | ||
| 1464 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 1465 | + | const CLI_TOKEN: &str = "test-cli-token"; | |
| 1466 | + | const ACTOR_SECRET: &str = "test-signing-secret-for-integration-tests"; | |
| 1467 | + | ||
| 1468 | + | /// Harness wired for the internal (CLI) API with an in-memory backend. | |
| 1469 | + | async fn cli_harness() -> TestHarness { | |
| 1470 | + | let mem = std::sync::Arc::new(crate::harness::storage::InMemoryStorage::new()); | |
| 1471 | + | TestHarness::build(crate::harness::BuildOptions { | |
| 1472 | + | storage: Some(mem), | |
| 1473 | + | cli_service_token: Some(CLI_TOKEN.to_string()), | |
| 1474 | + | ..Default::default() | |
| 1475 | + | }) | |
| 1476 | + | .await | |
| 1477 | + | } | |
| 1478 | + | ||
| 1479 | + | /// Authenticate the client as `user_id` over the internal API (ServiceAuth | |
| 1480 | + | /// bearer plus the signed actor assertion). | |
| 1481 | + | fn act_as(h: &mut TestHarness, user_id: makenotwork::db::UserId) { | |
| 1482 | + | h.client.set_bearer_token(CLI_TOKEN); | |
| 1483 | + | let actor = makenotwork::crypto::mint_internal_actor_token( | |
| 1484 | + | user_id, | |
| 1485 | + | chrono::Utc::now().timestamp() + 3600, | |
| 1486 | + | ACTOR_SECRET, | |
| 1487 | + | ); | |
| 1488 | + | h.client.set_actor_token(&actor); | |
| 1489 | + | } | |
| 1490 | + | ||
| 1491 | + | #[tokio::test] | |
| 1492 | + | async fn multipart_start_opens_the_band_above_the_single_put_ceiling() { | |
| 1493 | + | // 6 GiB is past S3's 5 GiB single-PUT ceiling and inside the big_files tier's | |
| 1494 | + | // 20 GB per-file cap. Before multipart this band was unreachable by every | |
| 1495 | + | // path; this test is the regression guard that it stays open. | |
| 1496 | + | let mut h = cli_harness().await; | |
| 1497 | + | let setup = h.create_creator_with_item("mpcreator", "video", 0).await; | |
| 1498 | + | h.trust_user(setup.user_id).await; | |
| 1499 | + | h.grant_tier(setup.user_id, "big_files").await; | |
| 1500 | + | act_as(&mut h, setup.user_id); | |
| 1501 | + | ||
| 1502 | + | let body = json!({ | |
| 1503 | + | "item_id": setup.item_id, | |
| 1504 | + | "file_type": "video", | |
| 1505 | + | "file_name": "movie.mp4", | |
| 1506 | + | "content_type": "video/mp4", | |
| 1507 | + | "file_size_bytes": 6 * GIB, | |
| 1508 | + | }); | |
| 1509 | + | let resp = h | |
| 1510 | + | .client | |
| 1511 | + | .post_json("/api/internal/upload/multipart/start", &body.to_string()) | |
| 1512 | + | .await; | |
| 1513 | + | assert!(resp.status.is_success(), "multipart start failed: {}", resp.text); | |
| 1514 | + | ||
| 1515 | + | let v: Value = resp.json(); | |
| 1516 | + | let s3_key = v["s3_key"].as_str().unwrap(); | |
| 1517 | + | assert!(s3_key.starts_with("staging/"), "must stage, never mint a served key: {s3_key}"); | |
| 1518 | + | assert!(!v["upload_id"].as_str().unwrap().is_empty()); | |
| 1519 | + | ||
| 1520 | + | // Geometry is pure arithmetic over the declared size, so the client can | |
| 1521 | + | // derive identical boundaries without a round trip. | |
| 1522 | + | let part_size = v["part_size"].as_u64().unwrap(); | |
| 1523 | + | let part_count = v["part_count"].as_u64().unwrap(); | |
| 1524 | + | assert!(part_size >= 5 * 1024 * 1024, "part size must clear S3's 5 MiB floor"); | |
| 1525 | + | assert!(part_count <= 10_000, "part count must stay within S3's limit"); | |
| 1526 | + | assert_eq!(part_count, (6 * GIB as u64).div_ceil(part_size), "part count must cover the object"); | |
| 1527 | + | } | |
| 1528 | + | ||
| 1529 | + | #[tokio::test] | |
| 1530 | + | async fn multipart_parts_signs_each_part_with_its_exact_length() { | |
| 1531 | + | let mut h = cli_harness().await; | |
| 1532 | + | let setup = h.create_creator_with_item("mpparts", "video", 0).await; | |
| 1533 | + | h.trust_user(setup.user_id).await; | |
| 1534 | + | h.grant_tier(setup.user_id, "big_files").await; | |
| 1535 | + | act_as(&mut h, setup.user_id); | |
| 1536 | + | ||
| 1537 | + | // A size with a deliberate remainder so the final part differs from the rest. | |
| 1538 | + | let size = 6 * GIB + 12_345; | |
| 1539 | + | let start: Value = h | |
| 1540 | + | .client | |
| 1541 | + | .post_json( | |
| 1542 | + | "/api/internal/upload/multipart/start", | |
| 1543 | + | &json!({ | |
| 1544 | + | "item_id": setup.item_id, "file_type": "video", | |
| 1545 | + | "file_name": "movie.mp4", "content_type": "video/mp4", | |
| 1546 | + | "file_size_bytes": size, | |
| 1547 | + | }) | |
| 1548 | + | .to_string(), | |
| 1549 | + | ) | |
| 1550 | + | .await | |
| 1551 | + | .json(); | |
| 1552 | + | let s3_key = start["s3_key"].as_str().unwrap().to_string(); | |
| 1553 | + | let upload_id = start["upload_id"].as_str().unwrap().to_string(); | |
| 1554 | + | let part_size = start["part_size"].as_u64().unwrap(); | |
| 1555 | + | let part_count = start["part_count"].as_u64().unwrap(); | |
| 1556 | + | ||
| 1557 | + | // A leading window: every part is a full part. | |
| 1558 | + | let resp = h | |
| 1559 | + | .client | |
| 1560 | + | .post_json( | |
| 1561 | + | "/api/internal/upload/multipart/parts", | |
| 1562 | + | &json!({ | |
| 1563 | + | "s3_key": s3_key, "upload_id": upload_id, | |
| 1564 | + | "file_size_bytes": size, "first_part": 1, "count": 3, | |
| 1565 | + | }) | |
| 1566 | + | .to_string(), | |
| 1567 | + | ) | |
| 1568 | + | .await; | |
| 1569 | + | assert!(resp.status.is_success(), "parts failed: {}", resp.text); | |
| 1570 | + | let v: Value = resp.json(); | |
| 1571 | + | let parts = v["parts"].as_array().unwrap(); | |
| 1572 | + | assert_eq!(parts.len(), 3); | |
| 1573 | + | for (i, p) in parts.iter().enumerate() { | |
| 1574 | + | assert_eq!(p["part_number"].as_i64().unwrap(), i as i64 + 1); | |
| 1575 | + | assert_eq!(p["content_length"].as_u64().unwrap(), part_size); | |
| 1576 | + | assert!(!p["url"].as_str().unwrap().is_empty()); | |
| 1577 | + | } | |
| 1578 | + | ||
| 1579 | + | // The final part carries only the remainder, and a window running past the | |
| 1580 | + | // end is clamped rather than rejected. | |
| 1581 | + | let resp = h | |
| 1582 | + | .client | |
| 1583 | + | .post_json( | |
| 1584 | + | "/api/internal/upload/multipart/parts", | |
| 1585 | + | &json!({ | |
| 1586 | + | "s3_key": s3_key, "upload_id": upload_id, | |
| 1587 | + | "file_size_bytes": size, "first_part": part_count, "count": 10, | |
| 1588 | + | }) | |
| 1589 | + | .to_string(), | |
| 1590 | + | ) | |
| 1591 | + | .await; | |
| 1592 | + | assert!(resp.status.is_success(), "final-part window failed: {}", resp.text); | |
| 1593 | + | let v: Value = resp.json(); | |
| 1594 | + | let parts = v["parts"].as_array().unwrap(); | |
| 1595 | + | assert_eq!(parts.len(), 1, "window past the last part must clamp"); | |
| 1596 | + | assert_eq!(parts[0]["content_length"].as_u64().unwrap(), 12_345, "last part is the remainder"); | |
| 1597 | + | } | |
| 1598 | + | ||
| 1599 | + | #[tokio::test] | |
| 1600 | + | async fn multipart_parts_bounds_the_window_it_will_mint() { | |
| 1601 | + | // Minting every URL for a 20 GB object would issue thousands of hour-long | |
| 1602 | + | // credentials for an upload that may never happen. | |
| 1603 | + | let mut h = cli_harness().await; | |
| 1604 | + | let setup = h.create_creator_with_item("mpwindow", "video", 0).await; | |
| 1605 | + | h.trust_user(setup.user_id).await; | |
| 1606 | + | h.grant_tier(setup.user_id, "big_files").await; | |
| 1607 | + | act_as(&mut h, setup.user_id); | |
| 1608 | + | ||
| 1609 | + | let start: Value = h | |
| 1610 | + | .client | |
| 1611 | + | .post_json( | |
| 1612 | + | "/api/internal/upload/multipart/start", | |
| 1613 | + | &json!({ | |
| 1614 | + | "item_id": setup.item_id, "file_type": "video", | |
| 1615 | + | "file_name": "movie.mp4", "content_type": "video/mp4", | |
| 1616 | + | "file_size_bytes": 6 * GIB, | |
| 1617 | + | }) | |
| 1618 | + | .to_string(), | |
| 1619 | + | ) | |
| 1620 | + | .await | |
| 1621 | + | .json(); | |
| 1622 | + | ||
| 1623 | + | for (first_part, count) in [(1, 101), (1, 0), (0, 5)] { | |
| 1624 | + | let resp = h | |
| 1625 | + | .client | |
| 1626 | + | .post_json( | |
| 1627 | + | "/api/internal/upload/multipart/parts", | |
| 1628 | + | &json!({ | |
| 1629 | + | "s3_key": start["s3_key"], "upload_id": start["upload_id"], | |
| 1630 | + | "file_size_bytes": 6 * GIB, "first_part": first_part, "count": count, | |
| 1631 | + | }) | |
| 1632 | + | .to_string(), | |
| 1633 | + | ) | |
| 1634 | + | .await; | |
| 1635 | + | assert!( | |
| 1636 | + | resp.status.is_client_error(), | |
| 1637 | + | "first_part={first_part} count={count} must be refused, got {}: {}", | |
| 1638 | + | resp.status, | |
| 1639 | + | resp.text | |
| 1640 | + | ); | |
| 1641 | + | } | |
| 1642 | + | } | |
| 1643 | + | ||
| 1644 | + | #[tokio::test] | |
| 1645 | + | async fn multipart_refuses_another_creators_staging_key() { | |
| 1646 | + | // A `staging/{uuid}` key carries no user in its path, so ownership comes | |
| 1647 | + | // from the pending_uploads row. Without that check any authenticated creator | |
| 1648 | + | // could drive parts into someone else's in-flight session. | |
| 1649 | + | let mut h = cli_harness().await; | |
| 1650 | + | ||
| 1651 | + | let victim = h.create_creator_with_item("mpvictim", "video", 0).await; | |
| 1652 | + | h.trust_user(victim.user_id).await; | |
| 1653 | + | h.grant_tier(victim.user_id, "big_files").await; | |
| 1654 | + | act_as(&mut h, victim.user_id); | |
| 1655 | + | let start: Value = h | |
| 1656 | + | .client | |
| 1657 | + | .post_json( | |
| 1658 | + | "/api/internal/upload/multipart/start", | |
| 1659 | + | &json!({ | |
| 1660 | + | "item_id": victim.item_id, "file_type": "video", | |
| 1661 | + | "file_name": "movie.mp4", "content_type": "video/mp4", | |
| 1662 | + | "file_size_bytes": 6 * GIB, | |
| 1663 | + | }) | |
| 1664 | + | .to_string(), | |
| 1665 | + | ) | |
| 1666 | + | .await | |
| 1667 | + | .json(); | |
| 1668 | + | let victim_key = start["s3_key"].as_str().unwrap().to_string(); | |
| 1669 | + | let victim_upload_id = start["upload_id"].as_str().unwrap().to_string(); | |
| 1670 | + | ||
| 1671 | + | // A second creator, fully authenticated in their own right. | |
| 1672 | + | let attacker = h.create_creator_with_item("mpattacker", "video", 0).await; | |
| 1673 | + | h.trust_user(attacker.user_id).await; | |
| 1674 | + | h.grant_tier(attacker.user_id, "big_files").await; | |
| 1675 | + | act_as(&mut h, attacker.user_id); | |
| 1676 | + | ||
| 1677 | + | let parts = h | |
| 1678 | + | .client | |
| 1679 | + | .post_json( | |
| 1680 | + | "/api/internal/upload/multipart/parts", | |
| 1681 | + | &json!({ | |
| 1682 | + | "s3_key": victim_key, "upload_id": victim_upload_id, | |
| 1683 | + | "file_size_bytes": 6 * GIB, "first_part": 1, "count": 1, | |
| 1684 | + | }) | |
| 1685 | + | .to_string(), | |
| 1686 | + | ) | |
| 1687 | + | .await; | |
| 1688 | + | assert!(parts.status.is_client_error(), "cross-user parts must be refused: {}", parts.text); | |
| 1689 | + | ||
| 1690 | + | let complete = h | |
| 1691 | + | .client | |
| 1692 | + | .post_json( | |
| 1693 | + | "/api/internal/upload/multipart/complete", | |
| 1694 | + | &json!({ | |
| 1695 | + | "s3_key": victim_key, "upload_id": victim_upload_id, | |
| 1696 | + | "parts": [{"part_number": 1, "etag": "\"deadbeef\""}], | |
| 1697 | + | }) | |
| 1698 | + | .to_string(), | |
| 1699 | + | ) | |
| 1700 | + | .await; | |
| 1701 | + | assert!(complete.status.is_client_error(), "cross-user complete must be refused: {}", complete.text); | |
| 1702 | + | ||
| 1703 | + | let abort = h | |
| 1704 | + | .client | |
| 1705 | + | .post_json( | |
| 1706 | + | "/api/internal/upload/multipart/abort", | |
| 1707 | + | &json!({"s3_key": victim_key, "upload_id": victim_upload_id}).to_string(), | |
| 1708 | + | ) | |
| 1709 | + | .await; | |
| 1710 | + | assert!(abort.status.is_client_error(), "cross-user abort must be refused: {}", abort.text); | |
| 1711 | + | } | |
| 1712 | + | ||
| 1713 | + | #[tokio::test] | |
| 1714 | + | async fn multipart_start_refuses_a_file_over_the_tier_cap() { | |
| 1715 | + | // Skipping the single-PUT ceiling must not skip the limits that describe the | |
| 1716 | + | // file: small_files caps a single file at 500 MB, so a 1 GiB video is refused | |
| 1717 | + | // before it stages any parts. | |
| 1718 | + | let mut h = cli_harness().await; | |
| 1719 | + | let setup = h.create_creator_with_item("mptier", "video", 0).await; | |
| 1720 | + | h.trust_user(setup.user_id).await; | |
| 1721 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 1722 | + | act_as(&mut h, setup.user_id); | |
| 1723 | + | ||
| 1724 | + | let resp = h | |
| 1725 | + | .client | |
| 1726 | + | .post_json( | |
| 1727 | + | "/api/internal/upload/multipart/start", | |
| 1728 | + | &json!({ | |
| 1729 | + | "item_id": setup.item_id, "file_type": "video", | |
| 1730 | + | "file_name": "movie.mp4", "content_type": "video/mp4", | |
| 1731 | + | "file_size_bytes": GIB, | |
| 1732 | + | }) | |
| 1733 | + | .to_string(), | |
| 1734 | + | ) | |
| 1735 | + | .await; | |
| 1736 | + | assert!(resp.status.is_client_error(), "over-tier multipart start must be refused: {}", resp.text); | |
| 1737 | + | ||
| 1738 | + | // Nothing was staged for the reaper to clean up. | |
| 1739 | + | let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads WHERE user_id = $1") | |
| 1740 | + | .bind(setup.user_id) | |
| 1741 | + | .fetch_one(&h.db) | |
| 1742 | + | .await | |
| 1743 | + | .unwrap(); | |
| 1744 | + | assert_eq!(pending, 0, "a refused start must not record a pending upload"); | |
| 1745 | + | } | |
| 1746 | + | ||
| 1747 | + | #[tokio::test] | |
| 1748 | + | async fn multipart_complete_then_confirm_commits_the_upload() { | |
| 1749 | + | // The end-to-end handoff: multipart replaces the transport only, and the | |
| 1750 | + | // existing confirm applies every size/tier/scan/commit rule unchanged. | |
| 1751 | + | let mut h = cli_harness().await; | |
| 1752 | + | let setup = h.create_creator_with_item("mpflow", "audio", 0).await; | |
| 1753 | + | h.trust_user(setup.user_id).await; | |
| 1754 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 1755 | + | act_as(&mut h, setup.user_id); | |
| 1756 | + | ||
| 1757 | + | let bytes = b"fake mp3 bytes for a multipart upload".to_vec(); | |
| 1758 | + | let size = bytes.len() as i64; | |
| 1759 | + | ||
| 1760 | + | let start: Value = h | |
| 1761 | + | .client | |
| 1762 | + | .post_json( | |
| 1763 | + | "/api/internal/upload/multipart/start", | |
| 1764 | + | &json!({ | |
| 1765 | + | "item_id": setup.item_id, "file_type": "audio", | |
| 1766 | + | "file_name": "song.mp3", "content_type": "audio/mpeg", | |
| 1767 | + | "file_size_bytes": size, | |
| 1768 | + | }) | |
| 1769 | + | .to_string(), | |
| 1770 | + | ) | |
| 1771 | + | .await | |
| 1772 | + | .json(); | |
| 1773 | + | let s3_key = start["s3_key"].as_str().unwrap().to_string(); | |
| 1774 | + | let upload_id = start["upload_id"].as_str().unwrap().to_string(); | |
| 1775 | + | assert_eq!(start["part_count"].as_u64().unwrap(), 1, "a small file is a single part"); | |
| 1776 | + | ||
| 1777 | + | let parts: Value = h | |
| 1778 | + | .client | |
| 1779 | + | .post_json( | |
| 1780 | + | "/api/internal/upload/multipart/parts", | |
| 1781 | + | &json!({ | |
| 1782 | + | "s3_key": s3_key, "upload_id": upload_id, | |
| 1783 | + | "file_size_bytes": size, "first_part": 1, "count": 1, | |
| 1784 | + | }) | |
| 1785 | + | .to_string(), | |
| 1786 | + | ) | |
| 1787 | + | .await | |
| 1788 | + | .json(); | |
| 1789 | + | assert_eq!(parts["parts"][0]["content_length"].as_i64().unwrap(), size); | |
| 1790 | + | ||
| 1791 | + | // The client PUTs each part to its presigned URL; the in-memory backend has | |
| 1792 | + | // no way to receive them, so stand in for that here. | |
| 1793 | + | h.storage.as_ref().unwrap().put(&s3_key, bytes); | |
| 1794 | + | ||
| 1795 | + | let complete = h | |
| 1796 | + | .client | |
| 1797 | + | .post_json( | |
| 1798 | + | "/api/internal/upload/multipart/complete", | |
| 1799 | + | &json!({ | |
| 1800 | + | "s3_key": s3_key, "upload_id": upload_id, | |
| 1801 | + | "parts": [{"part_number": 1, "etag": "\"etag-1\""}], | |
| 1802 | + | }) | |
| 1803 | + | .to_string(), | |
| 1804 | + | ) | |
| 1805 | + | .await; | |
| 1806 | + | assert!(complete.status.is_success(), "complete failed: {}", complete.text); | |
| 1807 | + | ||
| 1808 | + | // Hand off to the unchanged confirm endpoint. | |
| 1809 | + | let confirm = h | |
| 1810 | + | .client | |
| 1811 | + | .post_json( | |
| 1812 | + | "/api/internal/upload/confirm", | |
| 1813 | + | &json!({ | |
| 1814 | + | "user_id": setup.user_id, "item_id": setup.item_id, | |
| 1815 | + | "file_type": "audio", "s3_key": s3_key, | |
| 1816 | + | }) | |
| 1817 | + | .to_string(), | |
| 1818 | + | ) | |
| 1819 | + | .await; | |
| 1820 | + | assert!(confirm.status.is_success(), "confirm after multipart failed: {}", confirm.text); | |
| 1821 | + | h.client.clear_bearer_token(); | |
| 1822 | + | ||
| 1823 | + | let db_key: Option<String> = | |
| 1824 | + | sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid") | |
| 1825 | + | .bind(&setup.item_id) | |
| 1826 | + | .fetch_one(&h.db) | |
| 1827 | + | .await | |
| 1828 | + | .unwrap(); | |
| 1829 | + | assert_eq!(db_key.as_deref(), Some(s3_key.as_str()), "confirm must commit the multipart object"); | |
| 1830 | + | ||
| 1831 | + | let storage_used: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1") | |
| 1832 | + | .bind(setup.user_id) | |
| 1833 | + | .fetch_one(&h.db) | |
| 1834 | + | .await | |
| 1835 | + | .unwrap(); | |
| 1836 | + | assert_eq!(storage_used, size, "confirm must charge the real object size"); | |
| 1837 | + | } |