feat(registry): add workspace-scoped artifact metadata
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
mod integration {
|
||||
mod agents_usage;
|
||||
mod approval;
|
||||
mod artifact_sources;
|
||||
mod common;
|
||||
mod credential_touch;
|
||||
mod master_key_identity;
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
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(®istry, "ws_artifacts_a").await;
|
||||
let workspace_b = create_workspace(®istry, "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: ®istered,
|
||||
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: ®istered,
|
||||
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: ®istered,
|
||||
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: ®istered,
|
||||
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: ®istered,
|
||||
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: ®istered,
|
||||
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: ®istered,
|
||||
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(®istry, "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: ®istered,
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
|
||||
use sqlx::Row;
|
||||
|
||||
mod artifact_metadata;
|
||||
mod rollback;
|
||||
|
||||
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
@@ -49,6 +50,7 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
|
||||
(9, "agent-catalog-lifecycle-v9"),
|
||||
(10, "approval-side-effects-v10"),
|
||||
(11, "onboarding-product-events-v11"),
|
||||
(12, "artifact-metadata-v12"),
|
||||
];
|
||||
assert_eq!(rows.len(), expected.len());
|
||||
for (row, (version, name)) in rows.iter().zip(expected) {
|
||||
@@ -91,9 +93,20 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
|
||||
.await
|
||||
.expect("V11 key provenance column must be readable");
|
||||
assert!(key_scope_column);
|
||||
let artifact_relations = sqlx::query(
|
||||
"select table_name
|
||||
from information_schema.tables
|
||||
where table_schema = current_schema()
|
||||
and table_name in ('artifact_blobs', 'artifact_sources')
|
||||
order by table_name",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("V12 artifact metadata relations must be readable");
|
||||
assert_eq!(artifact_relations.len(), 2);
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(first.pool()).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
MigrationPreflight::Current { version: 12 }
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
@@ -216,7 +229,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
@@ -295,7 +308,7 @@ async fn future_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("update __crank_migrations set version = 12 where version = 11")
|
||||
sqlx::query("update __crank_migrations set version = 13 where version = 12")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -307,6 +320,7 @@ async fn future_sequence_fails_closed() {
|
||||
"future_version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v2_to_v3_identity").await;
|
||||
@@ -322,13 +336,13 @@ async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 2,
|
||||
target: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
MigrationPreflight::Current { version: 12 }
|
||||
);
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
@@ -371,7 +385,7 @@ async fn healthy_v3_upgrades_to_v4_with_honest_legacy_snapshot_provenance() {
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 3,
|
||||
target: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
@@ -431,7 +445,7 @@ async fn healthy_v4_upgrades_to_v5_without_fabricating_legacy_outcomes() {
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 4,
|
||||
target: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
@@ -685,7 +699,7 @@ async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 11 }
|
||||
MigrationPreflight::Current { version: 12 }
|
||||
);
|
||||
}
|
||||
async fn remove_v3_schema(pool: &sqlx::PgPool) {
|
||||
@@ -822,6 +836,7 @@ async fn remove_v10_schema(pool: &sqlx::PgPool) {
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v11_schema(pool: &sqlx::PgPool) {
|
||||
remove_v12_schema(pool).await;
|
||||
sqlx::raw_sql(
|
||||
"drop table if exists onboarding_selections;
|
||||
drop trigger if exists product_events_append_only_guard on product_events;
|
||||
@@ -838,6 +853,16 @@ async fn remove_v11_schema(pool: &sqlx::PgPool) {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn remove_v12_schema(pool: &sqlx::PgPool) {
|
||||
sqlx::raw_sql(
|
||||
"drop table if exists artifact_sources;
|
||||
drop table if exists artifact_blobs;
|
||||
delete from __crank_migrations where version = 12;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn partial_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_partial_sequence").await;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_v11_upgrades_to_v12_without_rewriting_prior_ledger() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v11_to_v12_artifacts").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let prior = sqlx::query(
|
||||
"select version, name, checksum, applied_at
|
||||
from __crank_migrations where version <= 11 order by version",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>("version"),
|
||||
row.get::<String, _>("name"),
|
||||
row.get::<String, _>("checksum"),
|
||||
row.get::<time::OffsetDateTime, _>("applied_at"),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
remove_v12_schema(&pool).await;
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let after = sqlx::query(
|
||||
"select version, name, checksum, applied_at
|
||||
from __crank_migrations where version <= 11 order by version",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>("version"),
|
||||
row.get::<String, _>("name"),
|
||||
row.get::<String, _>("checksum"),
|
||||
row.get::<time::OffsetDateTime, _>("applied_at"),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(after, prior);
|
||||
|
||||
let v12_applied_at: time::OffsetDateTime =
|
||||
sqlx::query_scalar("select applied_at from __crank_migrations where version = 12")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let replayed_at: time::OffsetDateTime =
|
||||
sqlx::query_scalar("select applied_at from __crank_migrations where version = 12")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replayed_at, v12_applied_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v12_exact_guard_rejects_column_constraint_index_and_relation_drift() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v12_artifact_drift").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
|
||||
for (drift, restore) in [
|
||||
(
|
||||
"alter table artifact_blobs add column unexpected text null;",
|
||||
"alter table artifact_blobs drop column unexpected;",
|
||||
),
|
||||
(
|
||||
"create index unrelated_idx on artifact_sources(source_id) include (mime_type);",
|
||||
"drop index unrelated_idx;",
|
||||
),
|
||||
(
|
||||
"alter table artifact_blobs alter column storage_lifecycle set default 'unavailable';",
|
||||
"alter table artifact_blobs alter column storage_lifecycle set default 'available';",
|
||||
),
|
||||
(
|
||||
"alter table artifact_sources alter column mime_type drop not null;",
|
||||
"alter table artifact_sources alter column mime_type set not null;",
|
||||
),
|
||||
(
|
||||
"alter table artifact_blobs drop constraint artifact_blobs_size_check;
|
||||
alter table artifact_blobs add constraint artifact_blobs_size_check
|
||||
check (size_bytes between 1 and 262145);",
|
||||
"alter table artifact_blobs drop constraint artifact_blobs_size_check;
|
||||
alter table artifact_blobs add constraint artifact_blobs_size_check
|
||||
check (size_bytes between 1 and 262144);",
|
||||
),
|
||||
(
|
||||
"drop index artifact_sources_workspace_created_idx;
|
||||
create index artifact_sources_workspace_created_idx
|
||||
on artifact_sources(workspace_id, source_id, created_at);",
|
||||
"drop index artifact_sources_workspace_created_idx;
|
||||
create index artifact_sources_workspace_created_idx
|
||||
on artifact_sources(workspace_id, created_at, source_id);",
|
||||
),
|
||||
(
|
||||
"alter table artifact_sources alter column created_at type timestamptz(3);",
|
||||
"alter table artifact_sources alter column created_at type timestamptz;",
|
||||
),
|
||||
(
|
||||
"alter table artifact_sources enable row level security;",
|
||||
"alter table artifact_sources disable row level security;",
|
||||
),
|
||||
(
|
||||
"create policy unexpected_policy on artifact_sources using (true);",
|
||||
"drop policy unexpected_policy on artifact_sources;",
|
||||
),
|
||||
(
|
||||
"create function unrelated_trigger_fn() returns trigger language plpgsql as $$
|
||||
begin return new; end
|
||||
$$;
|
||||
create trigger unexpected_trigger before insert on artifact_sources
|
||||
for each row execute function unrelated_trigger_fn();",
|
||||
"drop trigger unexpected_trigger on artifact_sources;
|
||||
drop function unrelated_trigger_fn();",
|
||||
),
|
||||
(
|
||||
"alter table artifact_sources set unlogged;",
|
||||
"alter table artifact_sources set logged;",
|
||||
),
|
||||
(
|
||||
"alter table artifact_sources drop constraint artifact_sources_id_check;
|
||||
alter table artifact_sources add constraint artifact_sources_id_check
|
||||
check (source_id ~ '^src_[a-zA-Z0-9_-]{1,128}$');",
|
||||
"alter table artifact_sources drop constraint artifact_sources_id_check;
|
||||
alter table artifact_sources add constraint artifact_sources_id_check
|
||||
check (source_id ~ '^src_[A-Za-z0-9_-]{1,128}$');",
|
||||
),
|
||||
(
|
||||
"alter table artifact_blobs drop constraint artifact_blobs_claim_shape_check;
|
||||
alter table artifact_blobs add constraint artifact_blobs_claim_shape_check check (
|
||||
claim_token is null and
|
||||
(claim_expires_at is null or claim_token is not null) and
|
||||
claim_expires_at is not null
|
||||
);",
|
||||
"alter table artifact_blobs drop constraint artifact_blobs_claim_shape_check;
|
||||
alter table artifact_blobs add constraint artifact_blobs_claim_shape_check check (
|
||||
(claim_token is null and claim_expires_at is null)
|
||||
or (claim_token is not null and claim_expires_at is not null)
|
||||
);",
|
||||
),
|
||||
] {
|
||||
sqlx::raw_sql(drift).execute(&pool).await.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence", "drift: {drift}");
|
||||
assert_eq!(error.version(), Some(12), "drift: {drift}");
|
||||
sqlx::raw_sql(restore).execute(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 12 },
|
||||
"restore: {restore}"
|
||||
);
|
||||
}
|
||||
|
||||
sqlx::query("alter table artifact_sources rename to artifact_sources_table")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("create view artifact_sources as select * from artifact_sources_table")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.version(), Some(12));
|
||||
}
|
||||
@@ -1,5 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_artifact_metadata_migration_rolls_back_schema_and_ledger() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_artifact_metadata_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
remove_v12_schema(&pool).await;
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story21b_v12() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%artifact_sources%' then
|
||||
raise exception 'injected v12 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story21b_v12 on ddl_command_start
|
||||
execute function reject_story21b_v12();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story21b_v12;
|
||||
drop function reject_story21b_v12();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
assert_eq!(error.version(), Some(12));
|
||||
for relation in [
|
||||
"artifact_blobs",
|
||||
"artifact_blobs_pkey",
|
||||
"artifact_blobs_artifact_ref_key",
|
||||
"artifact_sources",
|
||||
"artifact_sources_pkey",
|
||||
"artifact_sources_workspace_created_idx",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{relation} must roll back with failed V12 DDL");
|
||||
}
|
||||
let ledger_v12: i64 =
|
||||
sqlx::query_scalar("select count(*) from __crank_migrations where version = 12")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ledger_v12, 0);
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 11,
|
||||
target: 12,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_consolidation_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
|
||||
Reference in New Issue
Block a user