mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
fix: test & lint
This commit is contained in:
Vendored
+1
@@ -68,6 +68,7 @@ export declare class StorageRuntime {
|
||||
rebuildWorkspaceDocBlobRefs(workspaceId: string, limit: number): Promise<RuntimeDocBlobRefsResult>
|
||||
constructor()
|
||||
start(): Promise<void>
|
||||
configure(configJson: string): void
|
||||
stop(): Promise<void>
|
||||
runMigrations(): Promise<void>
|
||||
health(): Promise<StorageRuntimeHealth>
|
||||
|
||||
@@ -94,6 +94,13 @@ impl BackendRuntime {
|
||||
owner: String,
|
||||
ttl_ms: i64,
|
||||
) -> RuntimeResult<Option<CoordinationLeaseGrant>> {
|
||||
if ttl_ms <= 0 {
|
||||
return Err(RuntimeError::invalid_input("coordination lease ttl must be positive"));
|
||||
}
|
||||
if owner.is_empty() {
|
||||
return Err(RuntimeError::invalid_input("coordination lease owner is required"));
|
||||
}
|
||||
|
||||
CoordinationLeaseStore::new(self.pool().await?)
|
||||
.acquire(key, owner, ttl_ms)
|
||||
.await
|
||||
@@ -117,13 +124,6 @@ impl BackendRuntime {
|
||||
owner: String,
|
||||
ttl_ms: i64,
|
||||
) -> Result<Option<CoordinationLeaseGrant>> {
|
||||
if ttl_ms <= 0 {
|
||||
return Err(napi_error("coordination lease ttl must be positive"));
|
||||
}
|
||||
if owner.is_empty() {
|
||||
return Err(napi_error("coordination lease owner is required"));
|
||||
}
|
||||
|
||||
self
|
||||
.acquire_coordination_lease_inner(key, owner, ttl_ms)
|
||||
.await
|
||||
|
||||
@@ -208,21 +208,21 @@ pub(super) async fn list(
|
||||
scope: &str,
|
||||
prefix: Option<String>,
|
||||
) -> RuntimeResult<Vec<ObjectListEntry>> {
|
||||
let prefix = prefix.unwrap_or_default();
|
||||
if !prefix.is_empty() {
|
||||
super::normalize_storage_prefix(&prefix)?;
|
||||
}
|
||||
let prefix = prefix
|
||||
.map(|prefix| super::normalize_storage_prefix(&prefix))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let store = open_store(config).await?;
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT key, content_length, last_modified_ms
|
||||
FROM storage_assetpack_blobs
|
||||
WHERE scope = ?1 AND key LIKE ?2
|
||||
WHERE scope = ?1 AND key LIKE ?2 ESCAPE '\'
|
||||
ORDER BY key ASC
|
||||
"#,
|
||||
)
|
||||
.bind(scope)
|
||||
.bind(format!("{prefix}%"))
|
||||
.bind(format!("{}%", escape_sqlite_like(&prefix)))
|
||||
.fetch_all(store.pool())
|
||||
.await
|
||||
.map_err(|err| RuntimeError::database("Assetpack manifest list failed", err))?;
|
||||
@@ -239,6 +239,20 @@ pub(super) async fn list(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_sqlite_like(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'%' | '_' | '\\' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
pub(super) async fn delete(config: &FsStorageConfig, scope: &str, key: &str) -> RuntimeResult<()> {
|
||||
normalize_storage_key(key)?;
|
||||
let store = open_store(config).await?;
|
||||
|
||||
@@ -6,45 +6,6 @@ use super::{
|
||||
napi_error,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blob_cleanup_plan_result_keeps_run_id_for_execute() {
|
||||
let result = RuntimeBlobCleanupPlanResult {
|
||||
run_id: Some("00000000-0000-0000-0000-000000000000".to_string()),
|
||||
scanned_blobs: 1,
|
||||
candidates_marked: 1,
|
||||
protected_by_doc_refs: 0,
|
||||
protected_by_metadata: 0,
|
||||
protected_by_other_refs: 0,
|
||||
next_cursor: None,
|
||||
};
|
||||
|
||||
assert!(result.run_id.is_some());
|
||||
assert_eq!(result.candidates_marked, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_cleanup_execute_result_tracks_skipped_and_failed_counts() {
|
||||
let result = RuntimeBlobCleanupExecuteResult {
|
||||
scanned_candidates: 3,
|
||||
deleted_objects: 1,
|
||||
deleted_metadata: 1,
|
||||
skipped_still_referenced: 1,
|
||||
failed: 1,
|
||||
workspace_ids: vec!["workspace".to_string()],
|
||||
};
|
||||
|
||||
assert_eq!(result.scanned_candidates, 3);
|
||||
assert_eq!(
|
||||
result.skipped_still_referenced + result.failed + result.deleted_objects,
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct BlobCandidateRow {
|
||||
workspace_id: String,
|
||||
|
||||
@@ -111,7 +111,10 @@ struct CopilotConfigFile {
|
||||
|
||||
impl StorageRuntimeConfig {
|
||||
fn from_config_files() -> RuntimeResult<Self> {
|
||||
let app_config = app_config_from_config_files()?;
|
||||
Self::from_app_config_file(app_config_from_config_files()?)
|
||||
}
|
||||
|
||||
fn from_app_config_file(app_config: AppConfigFile) -> RuntimeResult<Self> {
|
||||
let database_url = database_url_from_env()
|
||||
.or(app_config.database_url())
|
||||
.unwrap_or_else(|| "postgresql://localhost:5432/affine".to_string());
|
||||
@@ -120,11 +123,12 @@ impl StorageRuntimeConfig {
|
||||
}
|
||||
|
||||
async fn with_db_overrides(&self, pool: &PgPool) -> RuntimeResult<Self> {
|
||||
let mut app_config = app_config_from_config_files()?;
|
||||
app_config.apply_file_config(load_app_config_overrides_from_db(pool).await?);
|
||||
let app_config = load_app_config_overrides_from_db(pool).await?;
|
||||
let mut backends = self.backends.clone();
|
||||
backends.extend(app_config.storage_backends()?);
|
||||
Ok(Self {
|
||||
database_url: self.database_url.clone(),
|
||||
backends: app_config.storage_backends()?,
|
||||
backends,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -300,6 +304,14 @@ impl StorageRuntime {
|
||||
self.start_inner().await.map_err(to_napi_error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn configure(&self, config_json: String) -> napi::Result<()> {
|
||||
let app_config: AppConfigFile = serde_json::from_str(&config_json)
|
||||
.map_err(|err| to_napi_error(RuntimeError::json("invalid storage runtime config", err)))?;
|
||||
let config = StorageRuntimeConfig::from_app_config_file(app_config).map_err(to_napi_error)?;
|
||||
self.update_config(config).map_err(to_napi_error)
|
||||
}
|
||||
|
||||
async fn start_inner(&self) -> RuntimeResult<()> {
|
||||
let mut guard = self.pool.lock().await;
|
||||
if guard.is_some() {
|
||||
@@ -1295,11 +1307,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fs_key_normalization_rejects_traversal() {
|
||||
for key in ["", "/a", "a//b", "a/./b", "a/../b", "..\\secret"] {
|
||||
assert!(normalize_storage_key(key).is_err(), "{key}");
|
||||
for (key, valid) in [
|
||||
("", false),
|
||||
("/a", false),
|
||||
("a//b", false),
|
||||
("a/./b", false),
|
||||
("a/../b", false),
|
||||
("..\\secret", false),
|
||||
("workspace/blob", true),
|
||||
("workspace\\blob", true),
|
||||
] {
|
||||
assert_eq!(normalize_storage_key(key).is_ok(), valid, "{key}");
|
||||
}
|
||||
assert_eq!(normalize_storage_key("workspace/blob").unwrap(), ["workspace", "blob"]);
|
||||
assert_eq!(normalize_storage_prefix("workspace/").unwrap(), "workspace/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_prefix_normalization_rejects_traversal() {
|
||||
for (prefix, expected) in [
|
||||
("", Some("")),
|
||||
("workspace/", Some("workspace/")),
|
||||
("workspace\\blob", Some("workspace/blob")),
|
||||
("../escape", None),
|
||||
("nested/../../escape", None),
|
||||
("/absolute", None),
|
||||
("nested//escape", None),
|
||||
("nested/./escape", None),
|
||||
("nested/../escape", None),
|
||||
] {
|
||||
assert_eq!(normalize_storage_prefix(prefix).ok().as_deref(), expected, "{prefix}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1468,6 +1505,185 @@ mod tests {
|
||||
assert_eq!(keys, ["workspace/blob-a", "workspace/nested/blob-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_backend_lists_old_node_prefix_semantics() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = FsStorageConfig {
|
||||
provider: "fs".to_string(),
|
||||
root: temp.path().to_string_lossy().to_string(),
|
||||
bucket: "bucket".to_string(),
|
||||
};
|
||||
for key in ["root-a", "a/item", "a/b/item", "a/b/t/item", "a/b/tail", "z/item"] {
|
||||
fs_put(&config, key, key.as_bytes().to_vec(), ObjectPutMetadata::default()).unwrap();
|
||||
}
|
||||
|
||||
for (prefix, expected) in [
|
||||
(
|
||||
None,
|
||||
vec!["a/b/item", "a/b/t/item", "a/b/tail", "a/item", "root-a", "z/item"],
|
||||
),
|
||||
(Some("a"), vec!["a/b/item", "a/b/t/item", "a/b/tail", "a/item"]),
|
||||
(Some("a/b"), vec!["a/b/item", "a/b/t/item", "a/b/tail"]),
|
||||
(Some("a/b/"), vec!["a/b/item", "a/b/t/item", "a/b/tail"]),
|
||||
(Some("a/b/t"), vec!["a/b/t/item", "a/b/tail"]),
|
||||
(Some("missing"), vec![]),
|
||||
] {
|
||||
let keys = fs_list(&config, prefix.map(ToString::to_string))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|entry| entry.key)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys, expected, "{prefix:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_backend_delete_removes_object_and_sidecar_idempotently() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = FsStorageConfig {
|
||||
provider: "fs".to_string(),
|
||||
root: temp.path().to_string_lossy().to_string(),
|
||||
bucket: "bucket".to_string(),
|
||||
};
|
||||
|
||||
fs_put(
|
||||
&config,
|
||||
"workspace/blob",
|
||||
b"body".to_vec(),
|
||||
ObjectPutMetadata::default(),
|
||||
)
|
||||
.unwrap();
|
||||
fs_delete(&config, "workspace/blob").unwrap();
|
||||
fs_delete(&config, "workspace/blob").unwrap();
|
||||
|
||||
assert!(fs_head(&config, "workspace/blob").unwrap().is_none());
|
||||
assert!(fs_get(&config, "workspace/blob").unwrap().is_none());
|
||||
assert!(!temp.path().join("bucket/workspace/blob").exists());
|
||||
assert!(!temp.path().join("bucket/workspace/blob.metadata.json").exists());
|
||||
}
|
||||
|
||||
fn test_storage_runtime() -> StorageRuntime {
|
||||
StorageRuntime {
|
||||
config: RwLock::new(StorageRuntimeConfig {
|
||||
database_url: "postgresql://unused".to_string(),
|
||||
backends: HashMap::new(),
|
||||
}),
|
||||
pool: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_workspace_blob_complete_returns_native_failure_reasons_before_db_upsert() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = FsStorageConfig {
|
||||
provider: "fs".to_string(),
|
||||
root: temp.path().to_string_lossy().to_string(),
|
||||
bucket: "bucket".to_string(),
|
||||
};
|
||||
let runtime = test_storage_runtime();
|
||||
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config.clone(),
|
||||
"workspace".to_string(),
|
||||
"missing".to_string(),
|
||||
1,
|
||||
"text/plain".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("not_found"));
|
||||
|
||||
fs_put(
|
||||
&config,
|
||||
"workspace/blob",
|
||||
b"body".to_vec(),
|
||||
ObjectPutMetadata {
|
||||
content_type: Some("text/plain".to_string()),
|
||||
content_length: Some(4),
|
||||
checksum_crc32: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config.clone(),
|
||||
"workspace".to_string(),
|
||||
"blob".to_string(),
|
||||
5,
|
||||
"text/plain".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("size_mismatch"));
|
||||
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config.clone(),
|
||||
"workspace".to_string(),
|
||||
"blob".to_string(),
|
||||
4,
|
||||
"image/png".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("mime_mismatch"));
|
||||
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config.clone(),
|
||||
"workspace".to_string(),
|
||||
"not-the-sha-key".to_string(),
|
||||
4,
|
||||
"text/plain".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("not_found"));
|
||||
|
||||
fs_put(
|
||||
&config,
|
||||
"workspace/not-the-sha-key",
|
||||
b"body".to_vec(),
|
||||
ObjectPutMetadata {
|
||||
content_type: Some("text/plain".to_string()),
|
||||
content_length: Some(4),
|
||||
checksum_crc32: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config.clone(),
|
||||
"workspace".to_string(),
|
||||
"not-the-sha-key".to_string(),
|
||||
4,
|
||||
"text/plain".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("checksum_mismatch"));
|
||||
assert!(fs_get(&config, "workspace/not-the-sha-key").unwrap().is_none());
|
||||
|
||||
let result = runtime
|
||||
.complete_fs_workspace_blob(
|
||||
config,
|
||||
"workspace".to_string(),
|
||||
"too-large".to_string(),
|
||||
MAX_BLOB_SIZE + 1,
|
||||
"text/plain".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.reason.as_deref(), Some("size_too_large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_backend_rejects_metadata_mismatch() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -1543,6 +1759,26 @@ mod tests {
|
||||
1
|
||||
);
|
||||
|
||||
let percent_key = "workspace/%literal.txt";
|
||||
let wildcard_collision_key = "workspace/aliteral.txt";
|
||||
for key in [percent_key, wildcard_collision_key] {
|
||||
assetpack::put(
|
||||
&config,
|
||||
&scope,
|
||||
key,
|
||||
b"literal prefix body".to_vec(),
|
||||
ObjectPutMetadata {
|
||||
content_type: None,
|
||||
content_length: None,
|
||||
checksum_crc32: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let percent_matches = assetpack::list(&config, &scope, Some("workspace/%".to_string())).await?;
|
||||
assert_eq!(percent_matches.len(), 1);
|
||||
assert_eq!(percent_matches[0].key, percent_key);
|
||||
|
||||
assetpack::delete(&config, &scope, key).await?;
|
||||
assert!(assetpack::head(&config, &scope, key).await?.is_none());
|
||||
Ok(())
|
||||
|
||||
@@ -27,6 +27,8 @@ use super::{
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
|
||||
const MAX_MULTIPART_PART_NUMBER: i32 = 10_000;
|
||||
const MAX_RESPONSE_BODY_BYTES: usize = i32::MAX as usize;
|
||||
|
||||
type StorageHttpFuture<'a> = Pin<Box<dyn Future<Output = ObjectStorageResult<StorageHttpResponse>> + Send + 'a>>;
|
||||
@@ -57,10 +59,9 @@ struct ReqwestStorageHttpClient {
|
||||
|
||||
impl ReqwestStorageHttpClient {
|
||||
fn new(request_timeout_ms: Option<u64>) -> ObjectStorageResult<Self> {
|
||||
let mut builder = ReqwestClient::builder();
|
||||
if let Some(request_timeout_ms) = request_timeout_ms {
|
||||
builder = builder.timeout(Duration::from_millis(request_timeout_ms));
|
||||
}
|
||||
let builder = ReqwestClient::builder().timeout(Duration::from_millis(
|
||||
request_timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS),
|
||||
));
|
||||
Ok(Self {
|
||||
client: builder.build().map_err(ObjectStorageError::HttpClientBuild)?,
|
||||
})
|
||||
@@ -627,7 +628,12 @@ fn response_header_name(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn checked_part_number(part_number: i32) -> ObjectStorageResult<u16> {
|
||||
u16::try_from(part_number).map_err(|_| ObjectStorageError::InvalidInput("part number must fit u16".to_string()))
|
||||
if !(1..=MAX_MULTIPART_PART_NUMBER).contains(&part_number) {
|
||||
return Err(ObjectStorageError::InvalidInput(
|
||||
"multipart part number must be between 1 and 10000".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(part_number as u16)
|
||||
}
|
||||
|
||||
fn validate_completed_parts(parts: &[MultipartUploadPart]) -> ObjectStorageResult<()> {
|
||||
@@ -677,3 +683,168 @@ fn parse_rfc3339_ms(value: &str) -> i64 {
|
||||
.map(|value| value.timestamp_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metadata_from_headers_uses_s3_defaults_and_checksum() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
|
||||
headers.insert(CONTENT_LENGTH, HeaderValue::from_static("42"));
|
||||
headers.insert(LAST_MODIFIED, HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"));
|
||||
headers.insert("x-amz-checksum-crc32", HeaderValue::from_static("checksum"));
|
||||
|
||||
let metadata = metadata_from_headers(&headers);
|
||||
|
||||
assert_eq!(metadata.content_type, "text/plain");
|
||||
assert_eq!(metadata.content_length, 42);
|
||||
assert_eq!(metadata.last_modified_ms, 1_445_412_480_000);
|
||||
assert_eq!(metadata.checksum_crc32.as_deref(), Some("checksum"));
|
||||
|
||||
let defaults = metadata_from_headers(&HeaderMap::new());
|
||||
assert_eq!(defaults.content_type, "application/octet-stream");
|
||||
assert_eq!(defaults.content_length, 0);
|
||||
assert_eq!(defaults.last_modified_ms, 0);
|
||||
assert!(defaults.checksum_crc32.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_body_accepts_object_missing_codes_only() {
|
||||
for body in [
|
||||
"<Error><Code>NoSuchKey</Code></Error>",
|
||||
"<Error><Code>NotFound</Code></Error>",
|
||||
"<Error><Code>NoSuchUpload</Code></Error>",
|
||||
] {
|
||||
assert!(is_not_found_body(body.as_bytes()), "{body}");
|
||||
}
|
||||
assert!(!is_not_found_body(b""));
|
||||
assert!(!is_not_found_body(b"<Error><Code>AccessDenied</Code></Error>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_parts_xml_handles_array_single_part_and_pagination() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>test</Bucket>
|
||||
<Key>key</Key>
|
||||
<UploadId>upload-id</UploadId>
|
||||
<PartNumberMarker>0</PartNumberMarker>
|
||||
<NextPartNumberMarker>3</NextPartNumberMarker>
|
||||
<MaxParts>2</MaxParts>
|
||||
<IsTruncated>true</IsTruncated>
|
||||
<Part>
|
||||
<PartNumber>1</PartNumber>
|
||||
<LastModified>2010-11-10T20:48:34.000Z</LastModified>
|
||||
<ETag>"etag-1"</ETag>
|
||||
<Size>10485760</Size>
|
||||
</Part>
|
||||
<Part>
|
||||
<PartNumber>2</PartNumber>
|
||||
<LastModified>2010-11-10T20:48:33.000Z</LastModified>
|
||||
<ETag>etag-2</ETag>
|
||||
<Size>10485760</Size>
|
||||
</Part>
|
||||
</ListPartsResult>"#;
|
||||
let parsed = ListParts::parse_response(xml).unwrap();
|
||||
let parts = parsed
|
||||
.parts
|
||||
.into_iter()
|
||||
.map(|part| MultipartUploadPart {
|
||||
part_number: i32::from(part.number),
|
||||
etag: trim_etag(&part.etag),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
parts,
|
||||
vec![
|
||||
MultipartUploadPart {
|
||||
part_number: 1,
|
||||
etag: "etag-1".to_string()
|
||||
},
|
||||
MultipartUploadPart {
|
||||
part_number: 2,
|
||||
etag: "etag-2".to_string()
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(parsed.next_part_number_marker, Some(3));
|
||||
|
||||
let single = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>test</Bucket>
|
||||
<Key>key</Key>
|
||||
<UploadId>upload-id</UploadId>
|
||||
<MaxParts>1</MaxParts>
|
||||
<IsTruncated>false</IsTruncated>
|
||||
<Part>
|
||||
<PartNumber>5</PartNumber>
|
||||
<LastModified>2010-11-10T20:48:34.000Z</LastModified>
|
||||
<ETag>"etag-5"</ETag>
|
||||
<Size>10485760</Size>
|
||||
</Part>
|
||||
</ListPartsResult>"#;
|
||||
let parsed = ListParts::parse_response(single).unwrap();
|
||||
assert_eq!(parsed.parts.len(), 1);
|
||||
assert_eq!(parsed.parts[0].number, 5);
|
||||
assert_eq!(trim_etag(&parsed.parts[0].etag), "etag-5");
|
||||
assert_eq!(parsed.next_part_number_marker, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_multipart_body_orders_and_escapes_parts() {
|
||||
let mut parts = completed_multipart_parts(vec![
|
||||
MultipartUploadPart {
|
||||
part_number: 2,
|
||||
etag: "b&c".to_string(),
|
||||
},
|
||||
MultipartUploadPart {
|
||||
part_number: 1,
|
||||
etag: "a<tag>".to_string(),
|
||||
},
|
||||
]);
|
||||
validate_completed_parts(&parts).unwrap();
|
||||
|
||||
let body = complete_multipart_body(&parts);
|
||||
|
||||
assert_eq!(
|
||||
body,
|
||||
"<CompleteMultipartUpload><Part><ETag>a<tag></ETag><PartNumber>1</PartNumber></Part><Part><ETag>b&c</\
|
||||
ETag><PartNumber>2</PartNumber></Part></CompleteMultipartUpload>"
|
||||
);
|
||||
|
||||
parts[0].etag.clear();
|
||||
assert!(validate_completed_parts(&parts).is_err());
|
||||
assert!(
|
||||
validate_completed_parts(&[MultipartUploadPart {
|
||||
part_number: -1,
|
||||
etag: "etag".to_string(),
|
||||
}])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_completed_parts(&[MultipartUploadPart {
|
||||
part_number: 0,
|
||||
etag: "etag".to_string(),
|
||||
}])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_completed_parts(&[MultipartUploadPart {
|
||||
part_number: 10_001,
|
||||
etag: "etag".to_string(),
|
||||
}])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rfc3339_ms_returns_zero_for_invalid_values() {
|
||||
assert_eq!(parse_rfc3339_ms("2024-01-02T03:04:05Z"), 1_704_164_645_000);
|
||||
assert_eq!(parse_rfc3339_ms("not a date"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ impl ObjectStorageError {
|
||||
match self {
|
||||
Self::Operation { source, .. } => source.is_not_found(),
|
||||
Self::HttpStatus { status, body, .. } => {
|
||||
*status == StatusCode::NOT_FOUND && (body.contains("NoSuchKey") || body.contains("NotFound"))
|
||||
*status == StatusCode::NOT_FOUND
|
||||
&& (body.contains("NoSuchKey") || body.contains("NoSuchUpload") || body.contains("NotFound"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ use super::{
|
||||
types::{MultipartUploadPart, ObjectPutMetadata, StorageProviderConfig, completed_multipart_parts, trim_etag},
|
||||
};
|
||||
|
||||
fn storage_config(provider: &str, config: serde_json::Value) -> StorageProviderConfig {
|
||||
StorageProviderConfig {
|
||||
provider: provider.to_string(),
|
||||
bucket: "test-bucket".to_string(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_r2_config_from_config_json_shape() {
|
||||
let storage = StorageProviderConfig {
|
||||
@@ -38,6 +46,66 @@ fn resolves_r2_config_from_config_json_shape() {
|
||||
assert_eq!(config.access_key_id.as_deref(), Some("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_r2_endpoint_cases_from_config_json_shape() {
|
||||
for (case, config, expected_endpoint) in [
|
||||
(
|
||||
"default account endpoint",
|
||||
serde_json::json!({
|
||||
"accountId": "account",
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
}
|
||||
}),
|
||||
Some("https://account.r2.cloudflarestorage.com"),
|
||||
),
|
||||
(
|
||||
"explicit null jurisdiction",
|
||||
serde_json::json!({
|
||||
"accountId": "account",
|
||||
"jurisdiction": null,
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
}
|
||||
}),
|
||||
Some("https://account.r2.cloudflarestorage.com"),
|
||||
),
|
||||
(
|
||||
"eu jurisdiction",
|
||||
serde_json::json!({
|
||||
"accountId": "account",
|
||||
"jurisdiction": "eu",
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
}
|
||||
}),
|
||||
Some("https://account.eu.r2.cloudflarestorage.com"),
|
||||
),
|
||||
] {
|
||||
let config = ObjectStorageConfig::from_r2_config(storage_config("cloudflare-r2", config))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(config.endpoint.as_deref(), expected_endpoint, "{case}");
|
||||
assert!(config.force_path_style, "{case}");
|
||||
}
|
||||
|
||||
assert!(
|
||||
ObjectStorageConfig::from_r2_config(storage_config(
|
||||
"cloudflare-r2",
|
||||
serde_json::json!({
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
}
|
||||
})
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_storage_not_found_requires_object_error_code() {
|
||||
let bucket_or_route_missing = ObjectStorageError::HttpStatus {
|
||||
@@ -50,9 +118,15 @@ fn object_storage_not_found_requires_object_error_code() {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "<Error><Code>NoSuchKey</Code></Error>".to_string(),
|
||||
};
|
||||
let upload_missing = ObjectStorageError::HttpStatus {
|
||||
context: "abort failed".to_string(),
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "<Error><Code>NoSuchUpload</Code></Error>".to_string(),
|
||||
};
|
||||
|
||||
assert!(!bucket_or_route_missing.is_not_found());
|
||||
assert!(object_missing.is_not_found());
|
||||
assert!(upload_missing.is_not_found());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -113,6 +187,28 @@ fn resolves_s3_config_from_config_json_shape() {
|
||||
assert_eq!(config.presign_sign_content_type_for_put, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_s3_default_endpoint_cases_from_config_json_shape() {
|
||||
for (region, expected_endpoint) in [
|
||||
("us-east-1", "https://s3.amazonaws.com"),
|
||||
("us-west-2", "https://s3.us-west-2.amazonaws.com"),
|
||||
] {
|
||||
let config = ObjectStorageConfig::from_s3_config(storage_config(
|
||||
"aws-s3",
|
||||
serde_json::json!({
|
||||
"region": region,
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
}
|
||||
}),
|
||||
))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(config.endpoint.as_deref(), Some(expected_endpoint), "{region}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_storage_presign_put_returns_sigv4_url_and_headers() {
|
||||
let storage = StorageProviderConfig {
|
||||
@@ -155,6 +251,47 @@ async fn object_storage_presign_put_returns_sigv4_url_and_headers() {
|
||||
assert!(result.expires_at_ms > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_storage_presign_put_respects_content_length_and_signed_content_type_flag() {
|
||||
let config = ObjectStorageConfig::from_s3_config(storage_config(
|
||||
"aws-s3",
|
||||
serde_json::json!({
|
||||
"region": "us-east-1",
|
||||
"endpoint": "https://s3.us-east-1.amazonaws.com",
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
},
|
||||
"presign": {
|
||||
"expiresInSeconds": 60,
|
||||
"signContentTypeForPut": false
|
||||
}
|
||||
}),
|
||||
))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let client = config.build_client().unwrap();
|
||||
let result = client
|
||||
.presign_put(
|
||||
"key",
|
||||
ObjectPutMetadata {
|
||||
content_type: Some("text/plain".to_string()),
|
||||
content_length: Some(42),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.headers.get("Content-Type").map(String::as_str),
|
||||
Some("text/plain")
|
||||
);
|
||||
assert_eq!(result.headers.get("Content-Length").map(String::as_str), Some("42"));
|
||||
assert!(!result.url.contains("content-type"));
|
||||
assert!(result.url.contains("content-length"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_storage_presign_get_returns_sigv4_url_without_headers() {
|
||||
let storage = StorageProviderConfig {
|
||||
@@ -182,6 +319,34 @@ async fn object_storage_presign_get_returns_sigv4_url_without_headers() {
|
||||
assert!(result.expires_at_ms > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_storage_presign_upload_part_returns_sigv4_url() {
|
||||
let config = ObjectStorageConfig::from_s3_config(storage_config(
|
||||
"aws-s3",
|
||||
serde_json::json!({
|
||||
"region": "us-east-1",
|
||||
"endpoint": "https://s3.us-east-1.amazonaws.com",
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
},
|
||||
"presign": {
|
||||
"expiresInSeconds": 60
|
||||
}
|
||||
}),
|
||||
))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let client = config.build_client().unwrap();
|
||||
let result = client.presign_upload_part("key", "upload-1", 3).await.unwrap();
|
||||
|
||||
assert!(result.url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
|
||||
assert!(result.url.contains("partNumber=3"));
|
||||
assert!(result.url.contains("uploadId=upload-1"));
|
||||
assert!(result.headers.is_empty());
|
||||
assert!(result.expires_at_ms > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_storage_orders_completed_multipart_parts_and_trims_etags() {
|
||||
let parts = completed_multipart_parts(vec![
|
||||
|
||||
Reference in New Issue
Block a user