|
| 1 | +//! E2E (real server): the PUSH-model streaming upload must produce a normally |
| 2 | +//! downloadable, BYTE-EXACT object on the real gateway. Validates P2 of |
| 3 | +//! docs/web-streaming-resumable-upload-plan.md against production — not just the |
| 4 | +//! hermetic mock in streaming_upload_roundtrip.rs. |
| 5 | +//! |
| 6 | +//! Drives the exact sequence the FRB handle uses: |
| 7 | +//! streaming_begin -> plan-only ChunkedEncoder (pass 1) -> streaming_finalize_plan |
| 8 | +//! -> streaming_put_chunk loop (pass 2) -> streaming_finish |
| 9 | +//! then downloads via get_object_flat and asserts byte-exact. The file is large |
| 10 | +//! enough (~50 MB, ~200 chunks at 256 KB) that the index metadata exceeds the |
| 11 | +//! gateway's 16 KB header budget, so this also exercises header_safe_enc_metadata |
| 12 | +//! stripping + the body/forest fallback on the real server. |
| 13 | +//! |
| 14 | +//! `#[ignore]` — needs network + real credentials. Run (PowerShell, env loaded |
| 15 | +//! from e2e-credentials.env): |
| 16 | +//! cargo test -p fula-client --test streaming_upload_e2e --release -- --ignored --nocapture |
| 17 | +//! |
| 18 | +//! Required env: FULA_S3, FULA_JWT, FULA_TEST_PROVIDER, FULA_TEST_OAUTH_SUB, |
| 19 | +//! FULA_TEST_EMAIL (the Mode A derivation triple). |
| 20 | +
|
| 21 | +#![cfg(not(target_arch = "wasm32"))] |
| 22 | + |
| 23 | +use fula_client::{Config, EncryptedClient, EncryptionConfig}; |
| 24 | +use fula_crypto::chunked::ChunkedEncoder; |
| 25 | +use fula_crypto::keys::SecretKey; |
| 26 | + |
| 27 | +fn env(name: &str) -> String { |
| 28 | + std::env::var(name).unwrap_or_else(|_| panic!("missing required env {name}")) |
| 29 | +} |
| 30 | + |
| 31 | +#[tokio::test] |
| 32 | +#[ignore = "real-server; needs FULA_S3 + FULA_JWT + Mode A triple"] |
| 33 | +async fn streaming_upload_large_file_roundtrips_on_real_server() { |
| 34 | + let s3 = env("FULA_S3"); |
| 35 | + let jwt = env("FULA_JWT"); |
| 36 | + let input = format!( |
| 37 | + "{}:{}:{}", |
| 38 | + env("FULA_TEST_PROVIDER"), |
| 39 | + env("FULA_TEST_OAUTH_SUB"), |
| 40 | + env("FULA_TEST_EMAIL"), |
| 41 | + ); |
| 42 | + let kek = fula_crypto::hashing::derive_key_argon2id("fula-files-v1", input.as_bytes()); |
| 43 | + let secret = SecretKey::from_bytes(&kek).expect("32-byte secret from Argon2id"); |
| 44 | + |
| 45 | + let mut config = Config::new(&s3).with_token(&jwt); |
| 46 | + // Match FxFiles production: stamps chunk_cids into the index metadata. |
| 47 | + config.walkable_v8_writer_enabled = true; |
| 48 | + let client = EncryptedClient::new(config, EncryptionConfig::from_secret_key(secret)) |
| 49 | + .expect("EncryptedClient::new"); |
| 50 | + |
| 51 | + let epoch = std::time::SystemTime::now() |
| 52 | + .duration_since(std::time::UNIX_EPOCH) |
| 53 | + .unwrap() |
| 54 | + .as_secs(); |
| 55 | + let bucket = format!("e2e-streaming-{epoch}-v8"); |
| 56 | + eprintln!("[streaming_e2e] BUCKET={bucket}"); |
| 57 | + if let Err(e) = client.create_bucket(&bucket).await { |
| 58 | + eprintln!("[streaming_e2e] create_bucket({bucket}) -> {e} (continuing)"); |
| 59 | + } |
| 60 | + |
| 61 | + // ~50 MB -> ~200 chunks at 256 KB -> index metadata over the 16 KB header |
| 62 | + // budget (exercises header_safe_enc_metadata stripping + body fallback). |
| 63 | + let size = 50 * 1024 * 1024; |
| 64 | + let mut data = vec![0u8; size]; |
| 65 | + for (i, b) in data.iter_mut().enumerate() { |
| 66 | + *b = ((i * 31 + 7) % 251) as u8; |
| 67 | + } |
| 68 | + let key_path = "/big-streamed.bin"; |
| 69 | + |
| 70 | + // ---- streaming upload (push model; mirrors the FRB handle) ---- |
| 71 | + eprintln!("[streaming_e2e] streaming_begin..."); |
| 72 | + let (storage_key, dek, wrapped_dek, kek_version) = client |
| 73 | + .streaming_begin(&bucket, key_path) |
| 74 | + .await |
| 75 | + .expect("streaming_begin"); |
| 76 | + |
| 77 | + // pass 1: plan-only encoder (default 256 KB chunks, matching production), |
| 78 | + // fed in arbitrary 1 MiB streaming slices. |
| 79 | + let aad_prefix = format!("fula:v4:chunk:{}", storage_key); |
| 80 | + let mut encoder = |
| 81 | + ChunkedEncoder::with_aad(dek.clone(), aad_prefix.into_bytes()).into_plan_only(); |
| 82 | + eprintln!("[streaming_e2e] pass 1 (plan)..."); |
| 83 | + for slice in data.chunks(1024 * 1024) { |
| 84 | + encoder.update(slice).expect("plan update"); |
| 85 | + } |
| 86 | + let (chunked_metadata, private_meta, encrypted_meta) = client |
| 87 | + .streaming_finalize_plan(encoder, &dek, &storage_key, key_path, Some("application/octet-stream")) |
| 88 | + .expect("streaming_finalize_plan"); |
| 89 | + |
| 90 | + let cs = chunked_metadata.chunk_size as usize; |
| 91 | + let num_chunks = chunked_metadata.num_chunks as usize; |
| 92 | + eprintln!("[streaming_e2e] {num_chunks} chunks @ {cs} bytes; pass 2 (upload)..."); |
| 93 | + assert!( |
| 94 | + num_chunks > 120, |
| 95 | + "need >120 chunks to exceed the 16 KB header budget (got {num_chunks})" |
| 96 | + ); |
| 97 | + |
| 98 | + // pass 2: upload each chunk from its committed nonce. |
| 99 | + let mut chunk_cids = vec![None; num_chunks]; |
| 100 | + for i in 0..num_chunks { |
| 101 | + let start = i * cs; |
| 102 | + let end = ((i + 1) * cs).min(data.len()); |
| 103 | + let (_chunk_key, cid) = client |
| 104 | + .streaming_put_chunk( |
| 105 | + &bucket, |
| 106 | + &storage_key, |
| 107 | + &chunked_metadata, |
| 108 | + i as u32, |
| 109 | + &data[start..end], |
| 110 | + &dek, |
| 111 | + ) |
| 112 | + .await |
| 113 | + .unwrap_or_else(|e| panic!("streaming_put_chunk[{i}] failed: {e}")); |
| 114 | + chunk_cids[i] = cid; |
| 115 | + if i % 50 == 0 { |
| 116 | + eprintln!("[streaming_e2e] uploaded chunk {i}/{num_chunks}"); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + eprintln!("[streaming_e2e] streaming_finish..."); |
| 121 | + client |
| 122 | + .streaming_finish( |
| 123 | + &bucket, |
| 124 | + key_path, |
| 125 | + &storage_key, |
| 126 | + &wrapped_dek, |
| 127 | + &encrypted_meta, |
| 128 | + kek_version, |
| 129 | + &private_meta, |
| 130 | + chunked_metadata, |
| 131 | + chunk_cids, |
| 132 | + ) |
| 133 | + .await |
| 134 | + .expect("streaming_finish"); |
| 135 | + eprintln!("[streaming_e2e] upload OK"); |
| 136 | + |
| 137 | + // ---- download + verify byte-exact (the gate) ---- |
| 138 | + eprintln!("[streaming_e2e] downloading via get_object_flat..."); |
| 139 | + let downloaded = client |
| 140 | + .get_object_flat(&bucket, key_path) |
| 141 | + .await |
| 142 | + .expect("get_object_flat"); |
| 143 | + assert_eq!(downloaded.len(), data.len(), "round-trip length mismatch"); |
| 144 | + assert_eq!( |
| 145 | + downloaded.as_ref(), |
| 146 | + data.as_slice(), |
| 147 | + "streaming upload -> download MUST be byte-exact on the real gateway" |
| 148 | + ); |
| 149 | + eprintln!( |
| 150 | + "[streaming_e2e] OK: {} bytes ({} chunks) round-tripped byte-exact via streaming on the real gateway", |
| 151 | + data.len(), |
| 152 | + num_chunks |
| 153 | + ); |
| 154 | +} |
0 commit comments