Files
crank/crates/crank-registry/tests/integration/artifact_sources.rs
T

537 lines
18 KiB
Rust

use super::common::TestDatabase;
use std::{
fs,
os::unix::fs::PermissionsExt,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use crank_artifacts::{ArtifactRef, ArtifactStore};
use crank_core::{Workspace, WorkspaceId, WorkspaceStatus};
use crank_registry::{
ArtifactSourceId, ArtifactSourceLifecycle, ArtifactSourceSensitivity,
CreateArtifactSourceRequest, CreateWorkspaceRequest, DetachArtifactSourceRequest,
ListArtifactSourcesQuery, RegistryError,
};
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
struct TestRoot(PathBuf);
impl TestRoot {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"crank-registry-artifacts-{name}-{}-{}",
std::process::id(),
NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
let _ = fs::remove_dir_all(&self.0);
}
}
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
async fn create_workspace(registry: &crank_registry::PostgresRegistry, id: &str) -> WorkspaceId {
let workspace_id = WorkspaceId::new(id);
let created_at = timestamp("2026-08-26T10:00:00Z");
registry
.create_workspace(CreateWorkspaceRequest {
workspace: &Workspace {
id: workspace_id.clone(),
slug: id.replace('_', "-"),
display_name: id.to_owned(),
status: WorkspaceStatus::Active,
settings: json!({}),
created_at,
updated_at: created_at,
},
})
.await
.unwrap();
workspace_id
}
#[tokio::test]
async fn source_relations_are_scoped_replayable_pageable_and_detachable() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_a = create_workspace(&registry, "ws_artifacts_a").await;
let workspace_b = create_workspace(&registry, "ws_artifacts_b").await;
let root = TestRoot::new("relations");
let store = ArtifactStore::open(&root.0).unwrap();
let registered = store.put_registered(b"openapi: 3.1.0\n").unwrap();
let source_id = ArtifactSourceId::new("src_shared");
let created_at = timestamp("2026-08-26T10:01:00Z");
let created = registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at,
})
.await
.unwrap();
let replayed = registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at: timestamp("2026-08-26T10:02:00Z"),
})
.await
.unwrap();
assert_eq!(replayed, created);
assert!(matches!(
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
artifact: &registered,
mime_type: "application/json",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at,
})
.await,
Err(RegistryError::SourceConflict { .. })
));
assert!(matches!(
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Secret,
created_at,
})
.await,
Err(RegistryError::SourceConflict { .. })
));
let different = store.put_registered(b"openapi: 3.0.3\n").unwrap();
assert!(matches!(
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
artifact: &different,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at,
})
.await,
Err(RegistryError::SourceConflict { .. })
));
assert!(matches!(
registry.get_artifact_source(&workspace_b, &source_id).await,
Err(RegistryError::SourceNotFound { .. })
));
let shared_in_b = registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_b,
source_id: &source_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Secret,
created_at,
})
.await
.unwrap();
assert_eq!(shared_in_b.blob.digest, created.blob.digest);
assert_ne!(shared_in_b.sensitivity, created.sensitivity);
let raw_pool = database.raw_pool().await;
let blob_count =
sqlx::query_scalar::<_, i64>("select count(*) from artifact_blobs where digest = $1")
.bind(registered.artifact_ref().digest_hex())
.fetch_one(&raw_pool)
.await
.unwrap();
assert_eq!(blob_count, 1);
let total_blob_count: i64 = sqlx::query_scalar("select count(*) from artifact_blobs")
.fetch_one(&raw_pool)
.await
.unwrap();
assert_eq!(
total_blob_count, 1,
"conflicting replay must roll back blob metadata"
);
for id in ["src_page_a", "src_page_b"] {
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &ArtifactSourceId::new(id),
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Public,
created_at,
})
.await
.unwrap();
}
let first = registry
.list_artifact_sources(ListArtifactSourcesQuery {
workspace_id: &workspace_a,
cursor: None,
limit: 2,
})
.await
.unwrap();
assert_eq!(first.items.len(), 2);
let second = registry
.list_artifact_sources(ListArtifactSourcesQuery {
workspace_id: &workspace_a,
cursor: first.next_cursor.as_ref(),
limit: 2,
})
.await
.unwrap();
assert_eq!(second.items.len(), 1);
assert!(second.next_cursor.is_none());
let listed_ids = first
.items
.iter()
.chain(second.items.iter())
.map(|source| source.source_id.as_str())
.collect::<Vec<_>>();
assert_eq!(listed_ids, vec!["src_page_a", "src_page_b", "src_shared"]);
assert!(
first
.items
.iter()
.chain(second.items.iter())
.all(|source| source.workspace_id == workspace_a)
);
assert!(matches!(
registry
.list_artifact_sources(ListArtifactSourcesQuery {
workspace_id: &workspace_b,
cursor: first.next_cursor.as_ref(),
limit: 2,
})
.await,
Err(RegistryError::InvalidArtifactSource { field: "cursor" })
));
sqlx::query(
"insert into artifact_sources
(workspace_id, source_id, blob_digest, mime_type, sensitivity, lifecycle,
created_at, updated_at)
select $1, 'src_bulk_' || lpad(value::text, 3, '0'), $2,
'application/yaml', 'public', 'active', $3, $3
from generate_series(0, 100) as value",
)
.bind(workspace_a.as_str())
.bind(registered.artifact_ref().digest_hex())
.bind(created_at)
.execute(&raw_pool)
.await
.unwrap();
let capped = registry
.list_artifact_sources(ListArtifactSourcesQuery {
workspace_id: &workspace_a,
cursor: None,
limit: u32::MAX,
})
.await
.unwrap();
assert_eq!(capped.items.len(), 100);
let tail = registry
.list_artifact_sources(ListArtifactSourcesQuery {
workspace_id: &workspace_a,
cursor: capped.next_cursor.as_ref(),
limit: u32::MAX,
})
.await
.unwrap();
assert_eq!(tail.items.len(), 4);
assert!(tail.next_cursor.is_none());
let invalid_id = ArtifactSourceId::new(format!("src_{}", "x".repeat(129)));
assert!(matches!(
registry
.get_artifact_source(&workspace_a, &invalid_id)
.await,
Err(RegistryError::InvalidArtifactSource { field: "source_id" })
));
assert!(matches!(
registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &invalid_id,
expected_updated_at: None,
detached_at: created_at,
})
.await,
Err(RegistryError::InvalidArtifactSource { field: "source_id" })
));
let detached_at = timestamp("2026-08-26T10:03:00Z");
let detached = registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
expected_updated_at: Some(created.updated_at),
detached_at,
})
.await
.unwrap();
assert_eq!(detached.lifecycle, ArtifactSourceLifecycle::Detached);
let retry = registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
expected_updated_at: Some(created.updated_at),
detached_at,
})
.await
.unwrap();
assert_eq!(retry, detached);
assert!(matches!(
registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
expected_updated_at: Some(created.updated_at),
detached_at: timestamp("2026-08-26T10:04:00Z"),
})
.await,
Err(RegistryError::SourceConflict { .. })
));
assert_eq!(
registry
.get_artifact_source(&workspace_a, &source_id)
.await
.unwrap()
.lifecycle,
ArtifactSourceLifecycle::Detached
);
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_a, &source_id)
.await,
Err(RegistryError::SourceUnavailable)
));
assert_eq!(
store.read(registered.artifact_ref()).unwrap(),
b"openapi: 3.1.0\n"
);
let concurrent_id = ArtifactSourceId::new("src_concurrent");
let concurrent = registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &concurrent_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at,
})
.await
.unwrap();
let first_registry = registry.clone();
let second_registry = registry.clone();
let first_workspace = workspace_a.clone();
let second_workspace = workspace_a.clone();
let first_id = concurrent_id.clone();
let second_id = concurrent_id.clone();
let (first_detach, second_detach) = tokio::join!(
async move {
first_registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &first_workspace,
source_id: &first_id,
expected_updated_at: Some(concurrent.updated_at),
detached_at: timestamp("2026-08-26T10:05:00Z"),
})
.await
},
async move {
second_registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &second_workspace,
source_id: &second_id,
expected_updated_at: Some(concurrent.updated_at),
detached_at: timestamp("2026-08-26T10:06:00Z"),
})
.await
}
);
assert_eq!(
usize::from(first_detach.is_ok()) + usize::from(second_detach.is_ok()),
1
);
assert_eq!(
usize::from(matches!(
first_detach,
Err(RegistryError::SourceConflict { .. })
)) + usize::from(matches!(
second_detach,
Err(RegistryError::SourceConflict { .. })
)),
1
);
database.cleanup().await;
}
#[tokio::test]
async fn verified_read_returns_only_digest_and_size_verified_bytes() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = create_workspace(&registry, "ws_verified_read").await;
let root = TestRoot::new("verified-read");
let store = ArtifactStore::open(&root.0).unwrap();
let bytes = b"openapi: 3.1.0\ninfo: {}\n";
let registered = store.put_registered(bytes).unwrap();
let valid_id = ArtifactSourceId::new("src_valid");
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_id,
source_id: &valid_id,
artifact: &registered,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Secret,
created_at: timestamp("2026-08-26T11:00:00Z"),
})
.await
.unwrap();
let verified = registry
.read_artifact_source(&store, &workspace_id, &valid_id)
.await
.unwrap();
assert_eq!(verified.bytes, bytes);
let raw_pool = database.raw_pool().await;
let missing_ref = ArtifactRef::from_digest_hex(&"f".repeat(64)).unwrap();
let missing_id = ArtifactSourceId::new("src_missing");
sqlx::query(
"insert into artifact_blobs
(digest, artifact_ref, size_bytes, storage_lifecycle, created_at, updated_at)
values ($1, $2, 12, 'available', $3, $3)",
)
.bind(missing_ref.digest_hex())
.bind(missing_ref.as_str())
.bind(timestamp("2026-08-26T11:01:00Z"))
.execute(&raw_pool)
.await
.unwrap();
sqlx::query(
"insert into artifact_sources
(workspace_id, source_id, blob_digest, mime_type, sensitivity, lifecycle,
created_at, updated_at)
values ($1, $2, $3, 'application/yaml', 'internal', 'active', $4, $4)",
)
.bind(workspace_id.as_str())
.bind(missing_id.as_str())
.bind(missing_ref.digest_hex())
.bind(timestamp("2026-08-26T11:01:00Z"))
.execute(&raw_pool)
.await
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &missing_id)
.await,
Err(RegistryError::SourceUnavailable)
));
assert!(
registry
.get_artifact_source(&workspace_id, &missing_id)
.await
.is_ok()
);
let wrong_size_id = ArtifactSourceId::new("src_wrong_size");
let wrong_size = store.put_registered(b"different source").unwrap();
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_id,
source_id: &wrong_size_id,
artifact: &wrong_size,
mime_type: "application/yaml",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at: timestamp("2026-08-26T11:02:00Z"),
})
.await
.unwrap();
sqlx::query("update artifact_blobs set size_bytes = size_bytes + 1 where digest = $1")
.bind(wrong_size.artifact_ref().digest_hex())
.execute(&raw_pool)
.await
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &wrong_size_id)
.await,
Err(RegistryError::SourceIntegrity)
));
let unavailable = store.put_registered(b"temporarily unavailable").unwrap();
let unavailable_id = ArtifactSourceId::new("src_unavailable");
registry
.create_artifact_source(CreateArtifactSourceRequest {
workspace_id: &workspace_id,
source_id: &unavailable_id,
artifact: &unavailable,
mime_type: "text/plain",
sensitivity: ArtifactSourceSensitivity::Internal,
created_at: timestamp("2026-08-26T11:03:00Z"),
})
.await
.unwrap();
sqlx::query("update artifact_blobs set storage_lifecycle = 'unavailable' where digest = $1")
.bind(unavailable.artifact_ref().digest_hex())
.execute(&raw_pool)
.await
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &unavailable_id)
.await,
Err(RegistryError::SourceUnavailable)
));
let tampered = root
.0
.join("sha256")
.join(registered.artifact_ref().digest_hex().get(..2).unwrap())
.join(registered.artifact_ref().digest_hex());
fs::set_permissions(&tampered, fs::Permissions::from_mode(0o600)).unwrap();
fs::write(&tampered, vec![b'x'; bytes.len()]).unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &valid_id)
.await,
Err(RegistryError::SourceIntegrity)
));
assert!(
registry
.get_artifact_source(&workspace_id, &valid_id)
.await
.is_ok()
);
database.cleanup().await;
}