Reject invalid session replay
This commit is contained in:
+124
-2
@@ -294,6 +294,18 @@ fn hydrate_session(
|
|||||||
parse_payload(&record, "commitment created")?;
|
parse_payload(&record, "commitment created")?;
|
||||||
ensure_schema_version(payload.schema_version, record.sequence)?;
|
ensure_schema_version(payload.schema_version, record.sequence)?;
|
||||||
ensure_record_commitment(&record, &payload.commitment_id)?;
|
ensure_record_commitment(&record, &payload.commitment_id)?;
|
||||||
|
if record.runtime_state != RuntimeState::Active {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"commitment activation runtime mismatch at sequence {}",
|
||||||
|
record.sequence
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if payload.state != CommitmentState::Active {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"commitment activation state mismatch at sequence {}",
|
||||||
|
record.sequence
|
||||||
|
));
|
||||||
|
}
|
||||||
let commitment = Commitment {
|
let commitment = Commitment {
|
||||||
id: payload.commitment_id,
|
id: payload.commitment_id,
|
||||||
created_at_unix_secs: record.timestamp_unix_secs,
|
created_at_unix_secs: record.timestamp_unix_secs,
|
||||||
@@ -328,6 +340,12 @@ fn hydrate_session(
|
|||||||
record.sequence
|
record.sequence
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if commitment.state != CommitmentState::Active {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"policy requires active commitment at sequence {}",
|
||||||
|
record.sequence
|
||||||
|
));
|
||||||
|
}
|
||||||
let next = transition_runtime(
|
let next = transition_runtime(
|
||||||
runtime_state,
|
runtime_state,
|
||||||
RuntimeAction::Activate {
|
RuntimeAction::Activate {
|
||||||
@@ -408,6 +426,12 @@ fn hydrate_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if active_commitment.is_some() && active_policy.is_none() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"session replay ended with active commitment but no active policy"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok((runtime_state, active_commitment, active_policy))
|
Ok((runtime_state, active_commitment, active_policy))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,9 +471,14 @@ fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::domain::{EnforcementLevel, RuntimeState};
|
use super::{CommitmentCreatedPayload, PolicyAppliedPayload};
|
||||||
use crate::event_log::{EventRecord, EventType};
|
use crate::domain::{
|
||||||
|
AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState,
|
||||||
|
POLICY_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord};
|
||||||
use crate::session::SessionController;
|
use crate::session::SessionController;
|
||||||
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
@@ -473,6 +502,36 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn commitment_payload(commitment_id: &str, state: CommitmentState) -> serde_json::Value {
|
||||||
|
serde_json::to_value(CommitmentCreatedPayload {
|
||||||
|
schema_version: POLICY_SCHEMA_VERSION,
|
||||||
|
commitment_id: commitment_id.to_string(),
|
||||||
|
source: CommitmentSource::Manual,
|
||||||
|
next_action: "Implement session controller".to_string(),
|
||||||
|
success_condition: "session tests pass".to_string(),
|
||||||
|
timebox_secs: 1500,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_payload(commitment_id: &str) -> serde_json::Value {
|
||||||
|
serde_json::to_value(PolicyAppliedPayload {
|
||||||
|
schema_version: POLICY_SCHEMA_VERSION,
|
||||||
|
policy_id: "policy-test".to_string(),
|
||||||
|
commitment_id: commitment_id.to_string(),
|
||||||
|
runtime_state: RuntimeState::Active,
|
||||||
|
enforcement_level: EnforcementLevel::Warn,
|
||||||
|
allowed_context: AllowedContext::default(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_chain_contains(err: &anyhow::Error, expected: &str) -> bool {
|
||||||
|
err.chain()
|
||||||
|
.any(|cause| cause.to_string().contains(expected))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starts_commitment_and_emits_policy() {
|
fn starts_commitment_and_emits_policy() {
|
||||||
let path = temp_log_path("start");
|
let path = temp_log_path("start");
|
||||||
@@ -590,6 +649,69 @@ mod tests {
|
|||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_standalone_commitment_created_on_replay() {
|
||||||
|
let path = temp_log_path("standalone-commitment");
|
||||||
|
let commitment_id = "commitment-test";
|
||||||
|
{
|
||||||
|
let mut log = EventLog::open(&path).unwrap();
|
||||||
|
log.append(
|
||||||
|
EventType::CommitmentCreated,
|
||||||
|
RuntimeState::Active,
|
||||||
|
Some(commitment_id.to_string()),
|
||||||
|
commitment_payload(commitment_id, CommitmentState::Active),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = SessionController::new(&path)
|
||||||
|
.expect_err("standalone commitment without policy must be rejected");
|
||||||
|
|
||||||
|
assert!(error_chain_contains(&err, "active policy"));
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_draft_commitment_before_policy_on_replay() {
|
||||||
|
let path = temp_log_path("draft-commitment-policy");
|
||||||
|
let commitment_id = "commitment-test";
|
||||||
|
{
|
||||||
|
let mut log = EventLog::open(&path).unwrap();
|
||||||
|
log.append_batch([
|
||||||
|
PendingEventRecord {
|
||||||
|
event_type: EventType::RuntimeTransition,
|
||||||
|
runtime_state: RuntimeState::Planning,
|
||||||
|
commitment_id: None,
|
||||||
|
payload_json: json!({
|
||||||
|
"schema_version": POLICY_SCHEMA_VERSION,
|
||||||
|
"action": "enter_planning",
|
||||||
|
"from": "locked",
|
||||||
|
"to": "planning",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
PendingEventRecord {
|
||||||
|
event_type: EventType::CommitmentCreated,
|
||||||
|
runtime_state: RuntimeState::Active,
|
||||||
|
commitment_id: Some(commitment_id.to_string()),
|
||||||
|
payload_json: commitment_payload(commitment_id, CommitmentState::Draft),
|
||||||
|
},
|
||||||
|
PendingEventRecord {
|
||||||
|
event_type: EventType::PolicyApplied,
|
||||||
|
runtime_state: RuntimeState::Active,
|
||||||
|
commitment_id: Some(commitment_id.to_string()),
|
||||||
|
payload_json: policy_payload(commitment_id),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = SessionController::new(&path)
|
||||||
|
.expect_err("draft commitment cannot be activated by policy replay");
|
||||||
|
|
||||||
|
assert!(error_chain_contains(&err, "commitment activation"));
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transition_started_payload_has_stable_schema() {
|
fn transition_started_payload_has_stable_schema() {
|
||||||
let path = temp_log_path("transition-payload");
|
let path = temp_log_path("transition-payload");
|
||||||
|
|||||||
Reference in New Issue
Block a user