Skip to main content

max / makenotwork

server: integration tests for the multipart upload session Cover the four internal multipart endpoints end to end: part geometry above the single-PUT ceiling, exact per-part signed Content-Length (including the remainder on the final part and window clamping at the end), window bounds, tier-cap refusal staging nothing, cross-creator rejection on parts/complete/ abort, and the complete-then-confirm handoff committing the object and charging the real size. The band test asserts a 6 GiB file starts successfully: past S3's 5 GiB single-PUT ceiling and inside the big_files tier's 20 GB per-file cap, the range no upload path could service before. It is the regression guard that it stays open. The cross-creator test was mutation-checked: with the pending_uploads ownership guard removed it fails, minting the victim's part URLs for the attacker. 1180 integration tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 12:56 UTC
Commit: bdbff0472222c3acbd112dfc7d09afe0b6b8f538
Parent: 405e0fa
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 + }