use std::collections::BTreeMap;
use std::fmt;
use super::canonical::{Canonical, CanonicalValue};
use super::ids::{InvalidId, ProjectId, ResourceId, Slug, TenantId};
use super::record::{
BodyError, DISPLAY_NAME_FIELD, PROJECT_ID_FIELD, Record, SCHEMA_FIELD, TENANT_ID_FIELD,
};
use super::resource::{
ResourceBody, ResourceKind, ResourceRef, ResourceScope, ResourceVersion, ResourceVersionNumber,
};
use super::revision::DesiredState;
pub const TENANT_SCHEMA: &str = "axond.tenant.v1";
pub const PROJECT_SCHEMA: &str = "axond.project.v1";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TenancyError {
#[error("{reference} is a {} resource, not a {}", found.as_str(), expected.as_str())]
Kind {
reference: ResourceRef,
expected: ResourceKind,
found: ResourceKind,
},
#[error("{reference} is a blob body; a tenancy record is inline")]
NotInline { reference: ResourceRef },
#[error("{reference} is not a record")]
NotARecord { reference: ResourceRef },
#[error(
"{reference} declares schema `{found}`, which this build does not read (expected `{expected}`)"
)]
Schema {
reference: ResourceRef,
expected: &'static str,
found: String,
},
#[error("{reference} has no `{field}`")]
MissingField {
reference: ResourceRef,
field: &'static str,
},
#[error("{reference} carries `{field}`, which `{schema}` does not define")]
UnknownField {
reference: ResourceRef,
schema: &'static str,
field: String,
},
#[error("{reference} field `{field}` is not a string")]
FieldType {
reference: ResourceRef,
field: &'static str,
},
#[error("{reference} field `{field}` is not an id: {source}")]
MalformedId {
reference: ResourceRef,
field: &'static str,
#[source]
source: InvalidId,
},
#[error("{reference} field `{field}` is not a display name: {source}")]
MalformedDisplayName {
reference: ResourceRef,
field: &'static str,
#[source]
source: InvalidDisplayName,
},
#[error("{reference} carries {declared}, but its resource identity is {identity}")]
IdentityMismatch {
reference: ResourceRef,
declared: String,
identity: ResourceId,
},
#[error("{reference} declares owner {declared} but is scoped to {scoped:?}")]
OwnerMismatch {
reference: ResourceRef,
declared: TenantId,
scoped: Option<TenantId>,
},
#[error("{reference} belongs to {tenant}, which this revision does not declare")]
UnknownTenant {
reference: ResourceRef,
tenant: TenantId,
},
#[error("{reference} places {project} under {scoped}, but that project belongs to {owner}")]
ProjectOwnerMismatch {
reference: ResourceRef,
project: ProjectId,
scoped: TenantId,
owner: TenantId,
},
}
impl TenancyError {
pub fn is_incompatible(&self) -> bool {
match self {
Self::Schema { .. } | Self::UnknownField { .. } | Self::MalformedDisplayName { .. } => {
true
}
Self::MissingField { field, .. } | Self::FieldType { field, .. } => {
*field == SCHEMA_FIELD
}
Self::Kind { .. }
| Self::NotInline { .. }
| Self::NotARecord { .. }
| Self::MalformedId { .. }
| Self::IdentityMismatch { .. }
| Self::OwnerMismatch { .. }
| Self::UnknownTenant { .. }
| Self::ProjectOwnerMismatch { .. } => false,
}
}
pub const fn reference(&self) -> ResourceRef {
match self {
Self::Kind { reference, .. }
| Self::NotInline { reference }
| Self::NotARecord { reference }
| Self::Schema { reference, .. }
| Self::MissingField { reference, .. }
| Self::UnknownField { reference, .. }
| Self::FieldType { reference, .. }
| Self::MalformedId { reference, .. }
| Self::MalformedDisplayName { reference, .. }
| Self::IdentityMismatch { reference, .. }
| Self::OwnerMismatch { reference, .. }
| Self::UnknownTenant { reference, .. }
| Self::ProjectOwnerMismatch { reference, .. } => *reference,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidDisplayName {
#[error("a display name must not be empty")]
Empty,
#[error("a display name of {length} characters is over the {max}-character limit")]
TooLong { length: usize, max: usize },
#[error("a display name may not contain the control character {codepoint:#06x}")]
ControlCharacter { codepoint: u32 },
#[error("a display name may not contain a byte-order mark")]
ByteOrderMark,
#[error("a display name may not begin or end with whitespace")]
Untrimmed,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DisplayName(String);
impl DisplayName {
pub const MAX_LEN: usize = 128;
pub fn parse(input: &str) -> Result<Self, InvalidDisplayName> {
if input.is_empty() {
return Err(InvalidDisplayName::Empty);
}
if input.trim() != input {
return Err(InvalidDisplayName::Untrimmed);
}
let length = input.chars().count();
if length > Self::MAX_LEN {
return Err(InvalidDisplayName::TooLong {
length,
max: Self::MAX_LEN,
});
}
for character in input.chars() {
if character == '\u{feff}' {
return Err(InvalidDisplayName::ByteOrderMark);
}
if character.is_control() {
return Err(InvalidDisplayName::ControlCharacter {
codepoint: u32::from(character),
});
}
}
Ok(Self(input.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DisplayName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl BodyError for TenancyError {
fn kind(reference: ResourceRef, expected: ResourceKind, found: ResourceKind) -> Self {
Self::Kind {
reference,
expected,
found,
}
}
fn not_inline(reference: ResourceRef) -> Self {
Self::NotInline { reference }
}
fn not_a_record(reference: ResourceRef) -> Self {
Self::NotARecord { reference }
}
fn schema(reference: ResourceRef, expected: &'static str, found: String) -> Self {
Self::Schema {
reference,
expected,
found,
}
}
fn missing_field(reference: ResourceRef, field: &'static str) -> Self {
Self::MissingField { reference, field }
}
fn unknown_field(reference: ResourceRef, schema: &'static str, field: String) -> Self {
Self::UnknownField {
reference,
schema,
field,
}
}
fn field_type(reference: ResourceRef, field: &'static str) -> Self {
Self::FieldType { reference, field }
}
fn malformed_id(reference: ResourceRef, field: &'static str, source: InvalidId) -> Self {
Self::MalformedId {
reference,
field,
source,
}
}
fn malformed_display_name(
reference: ResourceRef,
field: &'static str,
source: InvalidDisplayName,
) -> Self {
Self::MalformedDisplayName {
reference,
field,
source,
}
}
fn identity_mismatch(reference: ResourceRef, declared: String, identity: ResourceId) -> Self {
Self::IdentityMismatch {
reference,
declared,
identity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantBody {
tenant: TenantId,
display_name: DisplayName,
}
impl TenantBody {
pub const SCHEMA: &'static str = TENANT_SCHEMA;
const KNOWN_FIELDS: &'static [&'static str] = &[TENANT_ID_FIELD, DISPLAY_NAME_FIELD];
pub const fn new(tenant: TenantId, display_name: DisplayName) -> Self {
Self {
tenant,
display_name,
}
}
pub const fn tenant(&self) -> TenantId {
self.tenant
}
pub const fn display_name(&self) -> &DisplayName {
&self.display_name
}
pub const fn resource_id(&self) -> ResourceId {
ResourceId::new(self.tenant.uuid())
}
pub fn body(&self) -> ResourceBody {
ResourceBody::Inline(self.canonical())
}
pub fn version(&self, slug: Slug) -> ResourceVersion {
self.version_at(slug, ResourceVersionNumber::FIRST)
}
pub fn version_at(&self, slug: Slug, version: ResourceVersionNumber) -> ResourceVersion {
ResourceVersion::new(
ResourceRef::new(ResourceKind::Tenant, self.resource_id(), version),
ResourceScope::Deployment,
slug,
self.body(),
)
}
pub fn read(resource: &ResourceVersion) -> Result<Self, TenancyError> {
let record = Record::<TenancyError>::open(
resource,
ResourceKind::Tenant,
Self::SCHEMA,
Self::KNOWN_FIELDS,
)?;
let tenant = record.tenant()?;
record.identity(tenant, ResourceId::new(tenant.uuid()))?;
Ok(Self {
tenant,
display_name: record.display_name()?,
})
}
}
impl Canonical for TenantBody {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
(SCHEMA_FIELD, CanonicalValue::string(Self::SCHEMA)),
(
TENANT_ID_FIELD,
CanonicalValue::string(self.tenant.to_string()),
),
(
DISPLAY_NAME_FIELD,
CanonicalValue::string(self.display_name.as_str()),
),
])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectBody {
project: ProjectId,
tenant: TenantId,
display_name: DisplayName,
}
impl ProjectBody {
pub const SCHEMA: &'static str = PROJECT_SCHEMA;
const KNOWN_FIELDS: &'static [&'static str] =
&[PROJECT_ID_FIELD, TENANT_ID_FIELD, DISPLAY_NAME_FIELD];
pub const fn new(project: ProjectId, tenant: TenantId, display_name: DisplayName) -> Self {
Self {
project,
tenant,
display_name,
}
}
pub const fn project(&self) -> ProjectId {
self.project
}
pub const fn tenant(&self) -> TenantId {
self.tenant
}
pub const fn display_name(&self) -> &DisplayName {
&self.display_name
}
pub const fn resource_id(&self) -> ResourceId {
ResourceId::new(self.project.uuid())
}
pub fn body(&self) -> ResourceBody {
ResourceBody::Inline(self.canonical())
}
pub const fn scope(&self) -> ResourceScope {
ResourceScope::Tenant(self.tenant)
}
pub const fn child_scope(&self) -> ResourceScope {
ResourceScope::Project {
tenant: self.tenant,
project: self.project,
}
}
pub fn version(&self, slug: Slug) -> ResourceVersion {
self.version_at(slug, ResourceVersionNumber::FIRST)
}
pub fn version_at(&self, slug: Slug, version: ResourceVersionNumber) -> ResourceVersion {
ResourceVersion::new(
ResourceRef::new(ResourceKind::Project, self.resource_id(), version),
self.scope(),
slug,
self.body(),
)
}
pub fn read(resource: &ResourceVersion) -> Result<Self, TenancyError> {
let record = Record::<TenancyError>::open(
resource,
ResourceKind::Project,
Self::SCHEMA,
Self::KNOWN_FIELDS,
)?;
let project = record.project()?;
record.identity(project, ResourceId::new(project.uuid()))?;
let tenant = record.tenant()?;
if resource.scope != ResourceScope::Tenant(tenant) {
return Err(TenancyError::OwnerMismatch {
reference: resource.reference,
declared: tenant,
scoped: resource.scope.tenant(),
});
}
Ok(Self {
project,
tenant,
display_name: record.display_name()?,
})
}
}
impl Canonical for ProjectBody {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
(SCHEMA_FIELD, CanonicalValue::string(Self::SCHEMA)),
(
PROJECT_ID_FIELD,
CanonicalValue::string(self.project.to_string()),
),
(
TENANT_ID_FIELD,
CanonicalValue::string(self.tenant.to_string()),
),
(
DISPLAY_NAME_FIELD,
CanonicalValue::string(self.display_name.as_str()),
),
])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tenant {
pub reference: ResourceRef,
pub slug: Slug,
pub body: TenantBody,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub reference: ResourceRef,
pub slug: Slug,
pub body: ProjectBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Tenancy {
tenants: BTreeMap<TenantId, Tenant>,
projects: BTreeMap<ProjectId, Project>,
}
impl Tenancy {
pub fn of(state: &DesiredState) -> Result<Self, TenancyError> {
let mut tenancy = Self::default();
for resource in state.resources() {
match resource.reference.kind {
ResourceKind::Tenant => {
let body = TenantBody::read(resource)?;
tenancy.tenants.insert(
body.tenant(),
Tenant {
reference: resource.reference,
slug: resource.slug.clone(),
body,
},
);
}
ResourceKind::Project => {
let body = ProjectBody::read(resource)?;
tenancy.projects.insert(
body.project(),
Project {
reference: resource.reference,
slug: resource.slug.clone(),
body,
},
);
}
_ => {}
}
}
for project in tenancy.projects.values() {
if !tenancy.tenants.contains_key(&project.body.tenant()) {
return Err(TenancyError::UnknownTenant {
reference: project.reference,
tenant: project.body.tenant(),
});
}
}
for resource in state.resources() {
let ResourceScope::Project { tenant, project } = &resource.scope else {
continue;
};
let Some(owner) = tenancy
.projects
.get(project)
.map(|project| project.body.tenant())
else {
continue;
};
if owner != *tenant {
return Err(TenancyError::ProjectOwnerMismatch {
reference: resource.reference,
project: *project,
scoped: *tenant,
owner,
});
}
}
Ok(tenancy)
}
pub fn tenants(&self) -> impl ExactSizeIterator<Item = &Tenant> {
self.tenants.values()
}
pub fn projects(&self) -> impl ExactSizeIterator<Item = &Project> {
self.projects.values()
}
pub fn tenant(&self, id: TenantId) -> Option<&Tenant> {
self.tenants.get(&id)
}
pub fn project(&self, id: ProjectId) -> Option<&Project> {
self.projects.get(&id)
}
pub fn projects_of(&self, tenant: TenantId) -> impl Iterator<Item = &Project> {
self.projects
.values()
.filter(move |project| project.body.tenant() == tenant)
}
pub fn qualified_name(&self, project: ProjectId) -> Option<QualifiedProject> {
let project = self.projects.get(&project)?;
let tenant = self.tenants.get(&project.body.tenant())?;
Some(QualifiedProject {
tenant: tenant.slug.clone(),
project: project.slug.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QualifiedProject {
pub tenant: Slug,
pub project: Slug,
}
impl QualifiedProject {
pub const SEPARATOR: char = '/';
pub fn parse(input: &str) -> Option<Self> {
let (tenant, project) = input.split_once(Self::SEPARATOR)?;
Some(Self {
tenant: Slug::parse(tenant).ok()?,
project: Slug::parse(project).ok()?,
})
}
}
impl fmt::Display for QualifiedProject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}{}", self.tenant, Self::SEPARATOR, self.project)
}
}
#[cfg(test)]
mod tests {
use super::super::canonical::{Canonical as _, SerializerVersion};
use super::super::fixtures::{
alias, candidate, display_name, project, project_body, project_credential, project_id,
reference, resource_id, state, tenant, tenant_body, tenant_id,
};
use super::super::mutation::ExpectedRevision;
use super::super::revision::{
BodySkew, IntegrityError, LoadedRevision, RevisionManifest, ValidationError,
};
use super::*;
use std::time::SystemTime;
fn tenant_resource() -> ResourceVersion {
tenant(1, "acme")
}
fn project_resource() -> ResourceVersion {
project(&tenant_id(1), 2, "core")
}
fn with_fields(
resource: &ResourceVersion,
edit: impl FnOnce(&mut Vec<(String, CanonicalValue)>),
) -> ResourceVersion {
let ResourceBody::Inline(CanonicalValue::Map(fields)) = &resource.body else {
panic!("a tenancy fixture body is an inline record");
};
let mut fields = fields.clone();
edit(&mut fields);
ResourceVersion {
body: ResourceBody::Inline(CanonicalValue::Map(fields)),
..resource.clone()
}
}
fn set(fields: &mut Vec<(String, CanonicalValue)>, field: &str, value: CanonicalValue) {
fields.retain(|(name, _)| name != field);
fields.push((field.to_owned(), value));
}
#[test]
fn a_body_round_trips_through_its_envelope_and_its_canonical_bytes() {
let body = tenant_body(1, "Acme");
let resource = tenant_resource();
assert_eq!(TenantBody::read(&resource).unwrap(), body);
assert_eq!(resource.reference.id, resource_id(1));
assert_eq!(resource.slug.as_str(), "acme");
let project = project_body(2, 1, "Core");
let resource = project_resource();
assert_eq!(ProjectBody::read(&resource).unwrap(), project);
assert_eq!(project.tenant(), body.tenant());
assert_eq!(project.child_scope().tenant(), Some(body.tenant()));
assert_eq!(
body.checksum().unwrap(),
tenant_body(1, "Acme").checksum().unwrap()
);
assert_ne!(
body.checksum().unwrap(),
tenant_body(1, "Globex").checksum().unwrap()
);
let bytes = SerializerVersion::V1.encode(&project.canonical()).unwrap();
let decoded = SerializerVersion::V1
.decode(&bytes)
.expect("a tenancy body is canonical, so storage returns what it took");
assert_eq!(
SerializerVersion::V1.encode(&decoded).unwrap(),
bytes,
"the decoded body re-encodes to the bytes storage holds"
);
assert_eq!(
ProjectBody::read(&ResourceVersion {
body: ResourceBody::Inline(decoded),
..resource
})
.unwrap(),
project,
"and reads back as the same body"
);
assert!(
String::from_utf8_lossy(&bytes).contains(PROJECT_SCHEMA),
"the schema identifier is part of the checksummed body"
);
}
#[test]
fn a_schema_this_build_does_not_read_is_refused_rather_than_guessed_at() {
let newer = with_fields(&tenant_resource(), |fields| {
set(fields, "schema", CanonicalValue::string("axond.tenant.v2"));
});
assert_eq!(
TenantBody::read(&newer),
Err(TenancyError::Schema {
reference: newer.reference,
expected: TENANT_SCHEMA,
found: "axond.tenant.v2".to_owned()
})
);
let extended = with_fields(&project_resource(), |fields| {
set(fields, "residency", CanonicalValue::string("eu"));
});
assert_eq!(
ProjectBody::read(&extended),
Err(TenancyError::UnknownField {
reference: extended.reference,
schema: PROJECT_SCHEMA,
field: "residency".to_owned()
})
);
}
#[test]
fn a_malformed_body_is_a_typed_refusal_for_every_way_it_can_be_malformed() {
let resource = tenant_resource();
let missing = with_fields(&resource, |fields| {
fields.retain(|(name, _)| name != DISPLAY_NAME_FIELD);
});
assert_eq!(
TenantBody::read(&missing),
Err(TenancyError::MissingField {
reference: resource.reference,
field: DISPLAY_NAME_FIELD
})
);
let wrong_type = with_fields(&resource, |fields| {
set(fields, TENANT_ID_FIELD, CanonicalValue::integer(7));
});
assert_eq!(
TenantBody::read(&wrong_type),
Err(TenancyError::FieldType {
reference: resource.reference,
field: TENANT_ID_FIELD
})
);
let mistyped = with_fields(&resource, |fields| {
set(
fields,
TENANT_ID_FIELD,
CanonicalValue::string(project_id(1).to_string()),
);
});
assert!(matches!(
TenantBody::read(&mistyped),
Err(TenancyError::MalformedId {
field: TENANT_ID_FIELD,
..
})
));
let untrimmed = with_fields(&resource, |fields| {
set(fields, DISPLAY_NAME_FIELD, CanonicalValue::string(" Acme"));
});
assert!(matches!(
TenantBody::read(&untrimmed),
Err(TenancyError::MalformedDisplayName {
source: InvalidDisplayName::Untrimmed,
..
})
));
let as_project = ResourceVersion {
reference: reference(ResourceKind::Project, 1),
..resource.clone()
};
assert!(matches!(
TenantBody::read(&as_project),
Err(TenancyError::Kind {
expected: ResourceKind::Tenant,
found: ResourceKind::Project,
..
})
));
let not_a_record = ResourceVersion {
body: ResourceBody::Inline(CanonicalValue::string("acme")),
..resource.clone()
};
assert_eq!(
TenantBody::read(¬_a_record),
Err(TenancyError::NotARecord {
reference: resource.reference
})
);
}
#[test]
fn a_body_that_claims_another_identity_than_its_row_is_refused() {
let mismatched = with_fields(&tenant_resource(), |fields| {
set(
fields,
TENANT_ID_FIELD,
CanonicalValue::string(tenant_id(9).to_string()),
);
});
assert_eq!(
TenantBody::read(&mismatched),
Err(TenancyError::IdentityMismatch {
reference: mismatched.reference,
declared: tenant_id(9).to_string(),
identity: resource_id(1)
})
);
let mismatched = with_fields(&project_resource(), |fields| {
set(
fields,
PROJECT_ID_FIELD,
CanonicalValue::string(project_id(8).to_string()),
);
});
assert!(matches!(
ProjectBody::read(&mismatched),
Err(TenancyError::IdentityMismatch { .. })
));
}
#[test]
fn a_project_cannot_be_read_under_a_tenant_that_does_not_own_it() {
let moved = ResourceVersion {
scope: ResourceScope::Tenant(tenant_id(9)),
..project_resource()
};
assert_eq!(
ProjectBody::read(&moved),
Err(TenancyError::OwnerMismatch {
reference: moved.reference,
declared: tenant_id(1),
scoped: Some(tenant_id(9))
})
);
let mut state = state();
state
.insert(tenant(9, "globex"))
.expect("a distinct reference");
let mut relocated = DesiredState::new();
for resource in state.resources() {
let resource = if resource.reference.kind == ResourceKind::Project {
moved.clone()
} else {
resource.clone()
};
relocated.insert(resource).expect("distinct references");
}
for blob in state.blobs() {
relocated.declare_blob(*blob);
}
assert_eq!(
relocated.validate(),
Err(ValidationError::Tenancy(TenancyError::OwnerMismatch {
reference: moved.reference,
declared: tenant_id(1),
scoped: Some(tenant_id(9))
})),
"an owner edited underneath a project is refused by the domain"
);
}
#[test]
fn an_invalid_tenancy_body_is_refused_before_a_manifest_exists() {
let mut state = DesiredState::new();
let unreadable = with_fields(&tenant_resource(), |fields| {
set(fields, "schema", CanonicalValue::string("axond.tenant.v2"));
});
state.insert(unreadable.clone()).expect("a fresh state");
let candidate = candidate(ExpectedRevision::Empty, "unreadable", state);
assert!(matches!(
candidate.validated_checksum(),
Err(ValidationError::Tenancy(TenancyError::Schema { .. }))
));
assert!(
matches!(
RevisionManifest::of(
super::super::fixtures::revision_id(1),
None,
SystemTime::UNIX_EPOCH,
&candidate
),
Err(ValidationError::Tenancy(TenancyError::Schema { .. }))
),
"a body this build cannot read must not become a published revision"
);
}
#[test]
fn a_hydrated_revision_re_reads_the_bodies_it_was_published_with() {
let candidate = candidate(ExpectedRevision::Empty, "hydrate", state());
let manifest = RevisionManifest::of(
super::super::fixtures::revision_id(1),
None,
SystemTime::UNIX_EPOCH,
&candidate,
)
.expect("the fixture state is publishable");
let loaded = LoadedRevision::assemble(manifest.clone(), candidate.state.clone())
.expect("the state the manifest describes");
let tenancy = Tenancy::of(loaded.state()).expect("the fixture tenancy resolves");
assert_eq!(tenancy.tenants().len(), 1);
assert_eq!(tenancy.projects().len(), 1);
assert_eq!(
tenancy
.qualified_name(project_id(2))
.map(|name| name.to_string()),
Some("acme/core".to_owned())
);
assert_eq!(
tenancy
.tenant(tenant_id(1))
.map(|tenant| tenant.slug.as_str()),
Some("acme")
);
assert_eq!(loaded.state().checksum().unwrap(), manifest.checksum);
let mut edited = DesiredState::new();
for resource in candidate.state.resources() {
let resource = if resource.reference.kind == ResourceKind::Project {
with_fields(resource, |fields| {
set(
fields,
TENANT_ID_FIELD,
CanonicalValue::string(tenant_id(9).to_string()),
);
})
} else {
resource.clone()
};
edited.insert(resource).expect("distinct references");
}
for blob in candidate.state.blobs() {
edited.declare_blob(*blob);
}
let error = LoadedRevision::assemble(manifest, edited)
.expect_err("an edited tenancy body must not hydrate");
assert_eq!(
error,
IntegrityError::Invalid(ValidationError::Tenancy(TenancyError::OwnerMismatch {
reference: project_resource().reference,
declared: tenant_id(9),
scoped: Some(tenant_id(1))
})),
"the domain refuses it before any checksum is compared, and names the row"
);
}
#[test]
fn a_body_this_build_cannot_read_hydrates_as_an_incompatibility_not_corruption() {
let candidate = candidate(ExpectedRevision::Empty, "hydrate", state());
let manifest = RevisionManifest::of(
super::super::fixtures::revision_id(1),
None,
SystemTime::UNIX_EPOCH,
&candidate,
)
.expect("the fixture state is publishable");
let mut legacy = DesiredState::new();
for resource in candidate.state.resources() {
let resource = if resource.reference.kind == ResourceKind::Tenant {
super::super::fixtures::legacy_tenant(1, "acme")
} else {
resource.clone()
};
legacy.insert(resource).expect("distinct references");
}
for blob in candidate.state.blobs() {
legacy.declare_blob(*blob);
}
let error = LoadedRevision::assemble(manifest.clone(), legacy)
.expect_err("an untyped tenancy body must not hydrate");
assert_eq!(
error,
IntegrityError::Incompatible(BodySkew::Tenancy(TenancyError::MissingField {
reference: tenant_resource().reference,
field: "schema"
})),
"a legacy body is a compatibility refusal, and it names the row"
);
assert!(error.is_incompatible());
assert!(
!error.to_string().contains("unreadable"),
"intact storage must not be described as unreadable: {error}"
);
let mut newer = DesiredState::new();
for resource in candidate.state.resources() {
let resource = if resource.reference.kind == ResourceKind::Tenant {
with_fields(resource, |fields| {
set(fields, "schema", CanonicalValue::string("axond.tenant.v2"));
})
} else {
resource.clone()
};
newer.insert(resource).expect("distinct references");
}
for blob in candidate.state.blobs() {
newer.declare_blob(*blob);
}
let error =
LoadedRevision::assemble(manifest, newer).expect_err("a newer schema must not hydrate");
assert!(
matches!(
error,
IntegrityError::Incompatible(BodySkew::Tenancy(TenancyError::Schema { .. }))
),
"{error}"
);
assert!(
!TenancyError::OwnerMismatch {
reference: project_resource().reference,
declared: tenant_id(9),
scoped: Some(tenant_id(1)),
}
.is_incompatible()
);
let mut damaged = DesiredState::new();
let losing_a_field = with_fields(&tenant_resource(), |fields| {
fields.retain(|(name, _)| name != DISPLAY_NAME_FIELD);
});
damaged.insert(losing_a_field).expect("a fresh state");
let error = damaged
.validate()
.expect_err("a v1 body without a v1 field is not a v1 body");
assert_eq!(
error,
ValidationError::Tenancy(TenancyError::MissingField {
reference: tenant_resource().reference,
field: DISPLAY_NAME_FIELD,
})
);
let ValidationError::Tenancy(tenancy) = &error else {
panic!("expected a tenancy refusal, got {error:?}");
};
assert!(
!tenancy.is_incompatible(),
"a field lost from a schema this build reads is damage, not a skew"
);
assert!(
!TenancyError::FieldType {
reference: tenant_resource().reference,
field: TENANT_ID_FIELD,
}
.is_incompatible(),
"and so is a field whose type changed underneath the gateway"
);
assert!(
TenancyError::MissingField {
reference: tenant_resource().reference,
field: SCHEMA_FIELD,
}
.is_incompatible(),
"only the identifier's own absence is the legacy shape"
);
let mut scalar = DesiredState::new();
let not_a_record = ResourceVersion {
body: ResourceBody::Inline(CanonicalValue::String("acme".to_owned())),
..tenant_resource()
};
scalar.insert(not_a_record).expect("a fresh state");
let error = scalar
.validate()
.expect_err("a tenancy body is a record or it is nothing");
let ValidationError::Tenancy(tenancy) = &error else {
panic!("expected a tenancy refusal, got {error:?}");
};
assert!(
!tenancy.is_incompatible(),
"a body no build ever wrote is damage, and points at storage"
);
assert!(
!TenancyError::NotInline {
reference: tenant_resource().reference,
}
.is_incompatible(),
"and so is a tenancy record replaced by a blob reference"
);
assert!(
!TenancyError::Kind {
reference: tenant_resource().reference,
expected: ResourceKind::Tenant,
found: ResourceKind::Project,
}
.is_incompatible(),
"and so is a row whose kind and body disagree"
);
}
#[test]
fn a_project_needs_a_tenant_this_revision_declares() {
let mut orphaned = DesiredState::new();
let project = project(&tenant_id(9), 2, "core");
orphaned.insert(project.clone()).expect("a fresh state");
assert_eq!(
Tenancy::of(&orphaned),
Err(TenancyError::UnknownTenant {
reference: project.reference,
tenant: tenant_id(9)
})
);
assert!(
!TenancyError::UnknownTenant {
reference: project.reference,
tenant: tenant_id(9),
}
.is_incompatible(),
"a row this build itself wrote and cannot find is not an upgrade"
);
let mut stray = DesiredState::new();
let alias = alias(&tenant_id(9), 4, "fast", &[]);
stray.insert(alias.clone()).expect("a fresh state");
stray
.validate()
.expect("an older revision's tenant-scoped resource is not made unhydratable");
let tenancy = Tenancy::of(&stray).expect("nothing tenancy reads is missing");
assert_eq!(tenancy.tenants().len(), 0);
}
#[test]
fn a_project_scoped_resource_names_its_projects_real_owner() {
let owner = tenant_id(1);
let other = tenant_id(9);
let mut state = state();
state.insert(tenant(9, "globex")).expect("a distinct id");
let leaked = project_credential(&other, &project_id(2), 21, "leaked");
let mut mixed = state.clone();
mixed.insert(leaked.clone()).expect("a distinct reference");
assert_eq!(
Tenancy::of(&mixed),
Err(TenancyError::ProjectOwnerMismatch {
reference: leaked.reference,
project: project_id(2),
scoped: other,
owner
})
);
let dangling = project_credential(&owner, &project_id(77), 22, "dangling");
let mut missing = state.clone();
missing
.insert(dangling.clone())
.expect("a distinct reference");
missing
.validate()
.expect("a scope naming an undeclared project is unroutable, not unreadable");
let inside = project_credential(&owner, &project_id(2), 23, "inside");
let mut consistent = state;
consistent
.insert(inside)
.expect("a distinct reference")
.validate()
.expect("a resource inside its own tenant's project is valid");
}
#[test]
fn a_project_slug_is_unique_per_tenant_and_qualified_beyond_it() {
let mut state = state();
state.insert(tenant(9, "globex")).expect("a distinct id");
state
.insert(project(&tenant_id(9), 12, "core"))
.expect("a distinct reference")
.validate()
.expect("a project slug is unique within its tenant, not across tenants");
let tenancy = Tenancy::of(&state).expect("two tenants, two projects");
assert_eq!(tenancy.projects().len(), 2);
assert_eq!(tenancy.projects_of(tenant_id(9)).count(), 1);
let qualified: Vec<String> = tenancy
.projects()
.map(|project| {
tenancy
.qualified_name(project.body.project())
.expect("a project's tenant is declared")
.to_string()
})
.collect();
assert_eq!(qualified, vec!["acme/core", "globex/core"]);
assert_eq!(
QualifiedProject::parse("acme/core").map(|name| name.to_string()),
Some("acme/core".to_owned()),
"the qualified form decomposes exactly one way"
);
assert_eq!(QualifiedProject::parse("acme"), None);
let mut clashing = state;
clashing
.insert(project(&tenant_id(1), 13, "core"))
.expect("a distinct reference");
assert!(matches!(
clashing.validate(),
Err(ValidationError::DuplicateSlug { .. })
));
}
#[test]
fn a_display_name_is_prose_and_is_normalized_on_the_way_in() {
assert_eq!(display_name("Acme Corp").as_str(), "Acme Corp");
assert_eq!(DisplayName::parse(""), Err(InvalidDisplayName::Empty));
assert_eq!(
DisplayName::parse("Acme "),
Err(InvalidDisplayName::Untrimmed)
);
assert_eq!(
DisplayName::parse("Acme\tCorp"),
Err(InvalidDisplayName::ControlCharacter { codepoint: 0x09 }),
"a name with no canonical form is refused here, not at publication"
);
let long = "a".repeat(DisplayName::MAX_LEN + 1);
assert_eq!(
DisplayName::parse(&long),
Err(InvalidDisplayName::TooLong {
length: DisplayName::MAX_LEN + 1,
max: DisplayName::MAX_LEN
})
);
assert!(
tenant_body(1, "Acme")
.canonical()
.to_canonical_bytes()
.is_ok(),
"a validated body always has canonical bytes"
);
}
#[test]
fn a_display_name_refuses_a_byte_order_mark_exactly_as_the_encoder_does() {
for name in ["\u{feff}Acme", "Ac\u{feff}me", "Acme\u{feff}"] {
assert_eq!(
DisplayName::parse(name),
Err(InvalidDisplayName::ByteOrderMark),
"a mark anywhere in `{name}` is refused, not only a leading one"
);
assert_eq!(
CanonicalValue::string(name).to_canonical_bytes(),
Err(super::super::canonical::CanonicalError::ByteOrderMark),
"the two layers agree about `{name}`"
);
}
assert!(
!'\u{feff}'.is_control(),
"a mark is refused by its own rule, not by the control-character check"
);
}
#[test]
fn the_view_is_ordered_by_id_so_two_replicas_read_it_the_same_way() {
let mut state = state();
state.insert(tenant(9, "globex")).expect("a distinct id");
state
.insert(project(&tenant_id(9), 12, "later"))
.expect("a distinct reference");
let tenancy = Tenancy::of(&state).expect("valid tenancy");
let tenants: Vec<TenantId> = tenancy
.tenants()
.map(|tenant| tenant.body.tenant())
.collect();
let mut sorted = tenants.clone();
sorted.sort();
assert_eq!(tenants, sorted);
let projects: Vec<ProjectId> = tenancy
.projects()
.map(|project| project.body.project())
.collect();
let mut sorted = projects.clone();
sorted.sort();
assert_eq!(projects, sorted);
assert_eq!(
tenancy.project(project_id(12)).map(|p| p.slug.as_str()),
Some("later")
);
assert_eq!(tenancy.project(project_id(77)), None);
}
}