use super::*;
use crate::{
ids::IntentResourceKey,
model::replay::{ReplayActor, ReplayCostGuardSettlement},
storage::stable::{
intent::{IntentRecord, IntentStore},
replay::{ReplayReceiptRecord, ReplayReceiptStore},
},
};
fn fixture(id: u8) -> ReplayReceiptEntryRecord {
let command = CommandKind::new("test.release.receipt.v1").unwrap();
ReplayReceiptEntryRecord {
key: ReplayReceiptOps::slot_key(&command, OperationId::from_bytes([id; 32])),
record: ReplayReceiptRecord {
schema_version: REPLAY_RECEIPT_SCHEMA_VERSION,
command_kind: command.as_str().into(),
operation_id: [id; 32],
actor: ReplayActor {
effective_principal: Principal::from_slice(&[2]),
auth_kind: AuthKind::DelegatedToken,
},
payload_hash_schema_version: 1,
payload_hash: [3; 32],
status: ReplayReceiptStatus::ExternalEffectInFlight,
created_at_ns: 1,
updated_at_ns: 2,
expires_at_ns: Some(3),
response_schema_version: None,
response_bytes: None,
staged_response_schema_version: Some(1),
staged_response_bytes: Some(vec![9; 1024 * 1024]),
cost_guard_settlement: Some(ReplayCostGuardSettlement {
quota_intent_id: IntentId(7),
reservation_intent_id: IntentId(8),
}),
effect: Some(ExternalEffectDescriptor::ManagementCall {
canister: Principal::from_slice(&[4]),
method: "deposit_cycles".into(),
}),
},
}
}
#[test]
fn census_retains_expired_uncertainty_and_history_without_pruning_or_mutation() {
ReplayReceiptStore::reset_for_tests();
IntentStoreOps::reset_for_tests();
let owner = Principal::from_slice(&[1]);
assert_eq!(observe(owner, None).unwrap().entry, None);
for (id, status) in [
(1, ReplayReceiptStatus::Reserved),
(2, ReplayReceiptStatus::ExternalEffectInFlight),
(
3,
ReplayReceiptStatus::RecoveryRequired {
reason: RecoveryReason::CostSettlementFailed,
},
),
(4, ReplayReceiptStatus::Committed),
] {
let mut entry = fixture(id);
entry.record.status = status;
ReplayReceiptStore::upsert(entry.key, entry.record);
}
let before = ReplayReceiptStore::export();
let mut cursor = None;
let mut seen = Vec::new();
for expected in &before.entries {
let page = observe(owner, cursor).unwrap();
assert_eq!(page.owner, owner);
assert_eq!(observe(owner, cursor).unwrap(), page);
let entry = page.entry.as_ref().unwrap();
assert_eq!(entry.slot, expected.key.0);
assert_eq!(entry.operation_id, expected.record.operation_id);
assert_eq!(entry.actor, expected.record.actor.effective_principal);
assert_eq!(entry.authentication, Authentication::DelegatedToken);
assert_eq!(entry.expires_at_ns, Some(3));
assert_eq!(
entry.cost_guard_settlement,
Some(ReplayReleaseSettlement {
quota_intent_id: 7,
reservation_intent_id: 8,
quota: None,
reservation: None,
})
);
assert_eq!(
entry.effect,
Some(Effect::ManagementCall {
canister: Principal::from_slice(&[4]),
method: "deposit_cycles".into()
})
);
let bytes = candid::encode_one(&page).unwrap();
assert!(
bytes.len() < 4096,
"cached response bytes must stay outside the census"
);
assert_eq!(
candid::decode_one::<ReplayReleaseResponse>(&bytes).unwrap(),
page
);
seen.push((entry.operation_id, entry.phase));
cursor = page.next_after;
}
assert_eq!(cursor, None);
seen.sort_by_key(|(id, _)| *id);
assert_eq!(
seen,
vec![
([1; 32], Phase::Reserved),
([2; 32], Phase::ExternalEffectInFlight),
(
[3; 32],
Phase::RecoveryRequired(Reason::CostSettlementFailed)
),
([4; 32], Phase::Committed),
]
);
assert_eq!(ReplayReceiptStore::export(), before);
assert_eq!(observe(owner, Some([255; 32])).unwrap().entry, None);
}
#[test]
fn accounting_observation_retains_pending_expiry_terminal_state_and_missing_records() {
IntentStoreOps::reset_for_tests();
let quota = IntentRecord {
id: IntentId(7),
resource_key: IntentResourceKey::try_from("canic:quota:test").unwrap(),
quantity: 1,
state: IntentState::Committed,
created_at: 1,
ttl_secs: Some(2),
};
IntentStore::insert_record(quota.clone());
for state in [
IntentState::Pending,
IntentState::Committed,
IntentState::Aborted,
] {
let reservation = IntentRecord {
id: IntentId(8),
resource_key: IntentResourceKey::try_from("canic:cycles:test").unwrap(),
quantity: u64::MAX,
state,
created_at: 2,
ttl_secs: Some(1),
};
IntentStore::insert_record(reservation.clone());
let observed = project(fixture(1)).unwrap().cost_guard_settlement.unwrap();
let projected = observed.reservation.unwrap();
assert_eq!(projected.quantity, u64::MAX);
assert_eq!(projected.resource_key, reservation.resource_key.as_str());
assert_eq!(projected.created_at_secs, 2);
assert_eq!(projected.ttl_secs, Some(1));
assert_eq!(
projected.state,
match state {
IntentState::Pending => ReplayReleaseIntentState::Pending,
IntentState::Committed => ReplayReleaseIntentState::Committed,
IntentState::Aborted => ReplayReleaseIntentState::Aborted,
}
);
assert_eq!(
observed.quota.unwrap().state,
ReplayReleaseIntentState::Committed
);
assert_eq!(
IntentStoreOps::load(IntentId(7)).unwrap(),
Some(quota.clone())
);
assert_eq!(
IntentStoreOps::load(IntentId(8)).unwrap(),
Some(reservation)
);
}
IntentStoreOps::reset_for_tests();
let missing = project(fixture(1)).unwrap().cost_guard_settlement.unwrap();
assert_eq!(missing.quota, None);
assert_eq!(missing.reservation, None);
assert_eq!(missing.quota_intent_id, 7);
assert_eq!(missing.reservation_intent_id, 8);
}
#[test]
fn refuses_mismatched_slot_schema_and_oversized_identity_without_changing_the_row() {
ReplayReceiptStore::reset_for_tests();
let mut wrong_slot = fixture(1);
wrong_slot.key.0[0] ^= 1;
let mut wrong_schema = fixture(2);
wrong_schema.record.schema_version += 1;
let mut oversized = fixture(3);
oversized.record.command_kind = "x".repeat(MAXIMUM_IDENTITY_BYTES + 1);
for (entry, code) in [
(wrong_slot, InternalError::conflict().code()),
(wrong_schema, InternalError::invariant().code()),
(oversized, InternalError::resource_exhausted().code()),
] {
ReplayReceiptStore::reset_for_tests();
ReplayReceiptStore::upsert(entry.key, entry.record);
let before = ReplayReceiptStore::export();
assert_eq!(
observe(Principal::from_slice(&[1]), None)
.unwrap_err()
.code(),
code
);
assert_eq!(ReplayReceiptStore::export(), before);
}
}
#[test]
fn retains_original_effect_identity_across_distinct_accounting_recovery_reasons() {
let mut entry = fixture(1);
entry.record.status = ReplayReceiptStatus::RecoveryRequired {
reason: RecoveryReason::ResponseCommitFailed,
};
entry.record.effect = Some(ExternalEffectDescriptor::IcpTransfer {
operation_id: OperationId::from_bytes([9; 32]),
});
let observed = project(entry.clone()).unwrap();
assert_eq!(
observed.phase,
Phase::RecoveryRequired(Reason::ResponseCommitFailed)
);
assert_eq!(
observed.effect,
Some(Effect::IcpTransfer {
operation_id: [9; 32]
})
);
entry.record.status = ReplayReceiptStatus::RecoveryRequired {
reason: RecoveryReason::ExternalEffectStatusUnknown,
};
assert_eq!(
project(entry.clone()).unwrap().phase,
Phase::RecoveryRequired(Reason::ExternalEffectStatusUnknown)
);
entry.record.status = ReplayReceiptStatus::RecoveryRequired {
reason: RecoveryReason::ComponentChildLifecycleInterrupted,
};
entry.record.actor.auth_kind = AuthKind::RoleAttestation;
entry.record.effect = Some(ExternalEffectDescriptor::RootCanisterProvision {
command_kind: CommandKind::new("child.allocate.v1").unwrap(),
});
let observed = project(entry).unwrap();
assert_eq!(observed.authentication, Authentication::RoleAttestation);
assert_eq!(
observed.phase,
Phase::RecoveryRequired(Reason::ComponentChildLifecycleInterrupted)
);
assert_eq!(
observed.effect,
Some(Effect::RootCanisterProvision {
command_kind: "child.allocate.v1".into()
})
);
}