remora/crates/remora-target/tests/substrate.rs

436 lines
13 KiB
Rust
Raw Normal View History

use std::num::NonZeroUsize;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use remora_domain::OpaqueId;
use remora_stream::TargetBody;
use remora_target::{
CreateObjectRequest, ObjectIdentity, RESERVED_NAMESPACE_SEGMENT, TargetError, TargetStore,
TargetStoreConfig, attempt_object_key, hash_bytes, is_reserved_key, validate_attempt_key,
};
fn require_env() -> bool {
std::env::var("REMORA_TARGET_REQUIRE").is_ok()
}
fn store_from_env() -> Option<TargetStore> {
match TargetStoreConfig::from_env() {
Ok(config) => Some(TargetStore::connect(config).expect("connect target")),
Err(error) => {
if require_env() {
panic!("target env required when REMORA_TARGET_REQUIRE is set: {error}");
}
eprintln!("skipping target test: {error}");
None
}
}
}
fn unique_id(prefix: &str) -> OpaqueId {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let value = format!("{prefix}{nanos:x}");
OpaqueId::parse(&value).unwrap_or_else(|_| {
OpaqueId::parse(format!("{prefix}aaaaaaaa")).expect("fallback opaque id")
})
}
fn attempt_key(label: &str) -> String {
attempt_object_key(
&unique_id(&format!("att{label}")),
&unique_id(&format!("obj{label}")),
None,
)
}
fn create_request(key: impl Into<String>) -> CreateObjectRequest {
CreateObjectRequest::parse(key).expect("valid create-only request")
}
#[tokio::test]
async fn create_only_independently_hashes_and_rejects_wrong_expectation() {
let Some(store) = store_from_env() else {
return;
};
let body = b"immutable-member-v1".as_slice();
let digest = hash_bytes(body);
let key = attempt_key("csha");
assert!(is_reserved_key(&key));
assert!(validate_attempt_key(&key).is_ok());
assert!(validate_attempt_key("not-reserved/key").is_err());
let wrong = store
.put_create_only(
&create_request(key.clone()).with_expected_content_sha256(hash_bytes(b"other-payload")),
body,
)
.await;
assert!(
matches!(wrong, Err(TargetError::Integrity(_))),
"wrong expectation must fail: {wrong:?}"
);
let stored = store
.put_create_only(
&create_request(key.clone()).with_expected_content_sha256(digest.clone()),
body,
)
.await
.expect("create-only put");
assert_eq!(stored.identity.key, key);
assert_eq!(
stored.identity.target_store_id,
store.config().target_store_id
);
assert_eq!(stored.content_sha256, digest);
assert_eq!(
store.read_exact(&stored.identity).await.expect("read"),
body
);
let head = store.head_exact(&stored.identity).await.expect("head");
assert_eq!(head.content_sha256, Some(digest));
let conflict = store
.put_create_only(&create_request(key.clone()), b"replacement".as_slice())
.await;
assert!(
matches!(conflict, Err(TargetError::AlreadyExists)),
"expected create-only conflict, got {conflict:?}"
);
// Cross-store identity must fail closed.
let mut foreign = stored.identity.clone();
foreign.target_store_id = "other-store".into();
let cross = store.read_exact(&foreign).await;
assert!(
matches!(cross, Err(TargetError::Validation(_))),
"cross-store identity: {cross:?}"
);
}
#[tokio::test]
async fn streaming_create_only_computes_digest() {
let Some(store) = store_from_env() else {
return;
};
let payload = vec![0x5Au8; 256 * 1024];
let expected = hash_bytes(&payload);
let key = attempt_key("strm");
let mut body = store
.open_create_only(
&create_request(key.clone())
.with_expected_content_sha256(expected.clone())
.with_chunk_size(NonZeroUsize::new(64 * 1024).expect("non-zero chunk size")),
)
.await
.expect("open writer");
body.write_all(&payload).await.expect("write");
let stored = body.finish_object().await.expect("finish");
assert_eq!(stored.bytes_written, payload.len() as u64);
assert_eq!(stored.content_sha256, expected);
assert_eq!(stored.identity.key, key);
assert_eq!(
store.read_exact(&stored.identity).await.expect("read"),
payload
);
}
#[tokio::test]
async fn multipart_relist_complete_abort_and_create_only() {
let Some(store) = store_from_env() else {
return;
};
let replaced_part_one = vec![b'x'; 5 * 1024 * 1024];
let part_one = vec![b'a'; 5 * 1024 * 1024];
let part_two = vec![b'b'; 1024];
let combined = [part_one.as_slice(), part_two.as_slice()].concat();
let digest = hash_bytes(&combined);
let key = attempt_key("mpu");
let multipart_request =
create_request(key.clone()).with_expected_content_sha256(digest.clone());
let session = store
.begin_multipart(&multipart_request)
.await
.expect("begin");
let p2 = store
.upload_part(&session, 2, part_two.clone().into())
.await
.expect("part 2");
let replaced_p1 = store
.upload_part(&session, 1, replaced_part_one.into())
.await
.expect("original part 1");
let stale = store.list_parts(&session).await.expect("stale part list");
let p1 = store
.upload_part(&session, 1, part_one.clone().into())
.await
.expect("replacement part 1");
let durable_identity = session.identity().clone();
drop(session);
let (session, listed) = store
.resume_multipart(durable_identity.clone(), Some(digest.clone()))
.await
.expect("resume and re-list");
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].etag, p1.etag);
assert_eq!(listed[1].etag, p2.etag);
assert_ne!(listed[0].etag, replaced_p1.etag);
let stale_completion = store.complete_multipart(session, &stale).await;
assert!(
matches!(stale_completion, Err(TargetError::Integrity(_))),
"completion must reject a replaced part: {stale_completion:?}"
);
let (session, listed) = store
.resume_multipart(durable_identity, Some(digest.clone()))
.await
.expect("resume after stale completion");
let completed = store
.complete_multipart(session, &listed)
.await
.expect("complete");
assert_eq!(completed.identity.key, key);
assert_eq!(completed.content_sha256, digest);
assert_eq!(
store
.head_exact(&completed.identity)
.await
.expect("head multipart")
.content_sha256,
None
);
let read = store
.read_exact(&completed.identity)
.await
.expect("read completed");
assert_eq!(read.as_ref(), combined.as_slice());
let occupied = store.begin_multipart(&multipart_request).await;
assert!(
matches!(occupied, Err(TargetError::AlreadyExists)),
"multipart begin on occupied key: {occupied:?}"
);
let abort_key = attempt_key("mpa");
let abort_request = create_request(abort_key);
let abort_session = store
.begin_multipart(&abort_request)
.await
.expect("begin abort");
store
.upload_part(&abort_session, 1, part_one.into())
.await
.expect("abort part");
store.abort_multipart(abort_session).await.expect("abort");
}
#[tokio::test]
async fn unicode_key_exact_delete() {
let Some(store) = store_from_env() else {
return;
};
let key = attempt_object_key(
&unique_id("attuni"),
&unique_id("objuni"),
Some("한글/é/member"),
);
let body = "정규화".as_bytes();
let stored = store
.put_create_only(&create_request(key.clone()), body)
.await
.expect("unicode put");
assert_eq!(stored.content_sha256, hash_bytes(body));
store
.delete_exact(&stored.identity)
.await
.expect("exact delete");
let missing = store.read_exact(&stored.identity).await;
assert!(
matches!(missing, Err(TargetError::NotFound)),
"expected not found after delete, got {missing:?}"
);
}
#[tokio::test]
async fn time_limited_exact_access_rejects_tamper_and_expiry() {
let Some(store) = store_from_env() else {
return;
};
let body = b"presign-body";
let key = attempt_key("prs");
let sibling_key = attempt_key("sib");
let stored = store
.put_create_only(&create_request(key.clone()), body.as_slice())
.await
.expect("put");
store
.put_create_only(&create_request(sibling_key.clone()), b"sibling".as_slice())
.await
.expect("sibling put");
let access = store
.presign_read(&stored.identity, Duration::from_secs(2))
.await
.expect("presign");
let client = reqwest::Client::new();
let ok = client
.get(&access.url)
.send()
.await
.expect("presign get")
.error_for_status()
.expect("presign status")
.bytes()
.await
.expect("presign body");
assert_eq!(ok.as_ref(), body);
let tampered = access.url.replacen(&key, &sibling_key, 1);
assert_ne!(tampered, access.url);
let tamper_status = client
.get(&tampered)
.send()
.await
.expect("tampered get")
.status()
.as_u16();
assert!(
matches!(tamper_status, 400 | 403),
"tampered URL must not authorize sibling, got {tamper_status}"
);
tokio::time::sleep(Duration::from_secs(3)).await;
let expired_status = client
.get(&access.url)
.send()
.await
.expect("expired get")
.status()
.as_u16();
assert!(
matches!(expired_status, 400 | 403),
"expired URL must fail, got {expired_status}"
);
}
#[tokio::test]
async fn version_addressed_and_stale_version_matrix() {
let Some(store) = store_from_env() else {
return;
};
if !store
.enable_bucket_versioning()
.await
.expect("enable versioning")
{
if require_env() {
let provider =
std::env::var("REMORA_TARGET_PROVIDER").unwrap_or_else(|_| "unknown".into());
if provider == "versitygw" {
panic!("versitygw must support bucket versioning for WP-08 matrix");
}
}
eprintln!(
"target provider {} cannot enable versioning; skipping version matrix",
std::env::var("REMORA_TARGET_PROVIDER").unwrap_or_else(|_| "unknown".into())
);
return;
}
let key = attempt_key("ver");
let first = store
.put_create_only(&create_request(key.clone()), b"one".as_slice())
.await
.expect("first version");
let first_version = first
.identity
.version_id
.clone()
.expect("version id after enable");
let second = store
.put_overwrite(&create_request(key.clone()), b"two".as_slice())
.await
.expect("second version");
let second_version = second
.identity
.version_id
.clone()
.expect("second version id");
assert_ne!(first_version, second_version);
let first_identity = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: key.clone(),
version_id: Some(first_version),
};
let second_identity = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: key.clone(),
version_id: Some(second_version),
};
assert_eq!(
store.read_exact(&first_identity).await.expect("v1"),
b"one".as_slice()
);
assert_eq!(
store.read_exact(&second_identity).await.expect("v2"),
b"two".as_slice()
);
let versioned_access = store
.presign_read(&first_identity, Duration::from_secs(60))
.await
.expect("versioned presign");
let client = reqwest::Client::new();
let body = client
.get(&versioned_access.url)
.send()
.await
.expect("versioned get")
.bytes()
.await
.expect("versioned body");
assert_eq!(body.as_ref(), b"one");
store
.delete_exact(&first_identity)
.await
.expect("delete v1");
assert!(matches!(
store.read_exact(&first_identity).await,
Err(TargetError::NotFound)
));
let stale_key = attempt_key("stv");
let keep = store
.put_create_only(&create_request(stale_key.clone()), b"keep".as_slice())
.await
.expect("stale put");
let version = keep.identity.version_id.clone().expect("stale version id");
let bogus = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: stale_key,
version_id: Some(format!("{version}-stale")),
};
let result = store.read_exact(&bogus).await;
assert!(
matches!(
result,
Err(TargetError::NotFound | TargetError::Internal(_))
),
"stale version must fail closed: {result:?}"
);
store
.purge_versions_with_prefix(RESERVED_NAMESPACE_SEGMENT)
.await
.expect("purge versions");
}