use greentic_deploy_spec::{CapabilitySlot, EnvId};
use greentic_deployer::cli::env::{EnvCreatePayload, create};
use greentic_deployer::cli::env_packs::{EnvPackBindingPayload, add as env_packs_add};
use greentic_deployer::cli::{OpError, OpFlags};
use greentic_deployer::environment::{AuditDecision, AuditEvent, AuditResult, LocalFsStore};
use tempfile::tempdir;
fn read_events(store_root: &std::path::Path, env_id: &str) -> Vec<AuditEvent> {
let log = store_root.join(env_id).join("audit").join("events.jsonl");
let raw = std::fs::read_to_string(&log).unwrap_or_default();
raw.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("audit event line is well-formed JSON"))
.collect()
}
#[test]
fn audit_log_records_distinct_verbs_in_order() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let flags = OpFlags::default();
create(
&store,
&flags,
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap();
env_packs_add(
&store,
&flags,
Some(EnvPackBindingPayload {
environment_id: "local".to_string(),
slot: CapabilitySlot::Secrets,
kind: "greentic.secrets.dev-store@1.0.0".to_string(),
pack_ref: "greentic.secrets.dev-store".to_string(),
answers_ref: None,
idempotency_key: None,
}),
)
.unwrap();
env_packs_add(
&store,
&flags,
Some(EnvPackBindingPayload {
environment_id: "local".to_string(),
slot: CapabilitySlot::Telemetry,
kind: "greentic.telemetry.stdout@1.0.0".to_string(),
pack_ref: "greentic.telemetry.stdout".to_string(),
answers_ref: None,
idempotency_key: None,
}),
)
.unwrap();
let events = read_events(dir.path(), "local");
assert_eq!(events.len(), 3, "3 mutating verbs → 3 audit events");
assert_eq!(events[0].noun, "env");
assert_eq!(events[0].verb, "create");
assert_eq!(events[1].noun, "env-packs");
assert_eq!(events[1].verb, "add");
assert_eq!(events[2].noun, "env-packs");
assert_eq!(events[2].verb, "add");
let ids: std::collections::HashSet<_> = events.iter().map(|e| &e.event_id).collect();
assert_eq!(ids.len(), 3, "event ids must be unique");
for event in &events {
assert!(
matches!(&event.authorization, AuditDecision::Allow { .. }),
"expected Allow, got {:?}",
event.authorization
);
assert!(
matches!(&event.result, AuditResult::Ok),
"expected Ok, got {:?}",
event.result
);
}
}
#[test]
fn non_local_env_create_denies_and_audits() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "prod".to_string(),
name: "prod".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Unauthorized { .. }));
let env_json = dir.path().join("prod").join("environment.json");
assert!(!env_json.exists(), "deny must not create env state");
let events = read_events(dir.path(), "prod");
assert_eq!(events.len(), 1);
assert_eq!(events[0].env_id, "prod");
assert_eq!(events[0].noun, "env");
assert_eq!(events[0].verb, "create");
match &events[0].authorization {
AuditDecision::Deny { policy, reason } => {
assert_eq!(policy, "local-only");
assert!(reason.contains("prod"));
}
other => panic!("expected Deny, got {other:?}"),
}
match &events[0].result {
AuditResult::Error { kind, .. } => assert_eq!(kind, "unauthorized"),
other => panic!("expected Error, got {other:?}"),
}
assert_eq!(
events[0].schema.as_str(),
greentic_deployer::environment::AUDIT_EVENT_SCHEMA_V1
);
let _ = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "prod".to_string(),
name: "prod".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
);
let events = read_events(dir.path(), "prod");
assert_eq!(events.len(), 2);
assert_ne!(events[0].event_id, events[1].event_id);
}
#[test]
fn env_packs_update_audit_records_generation_transition() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let flags = OpFlags::default();
create(
&store,
&flags,
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap();
env_packs_add(
&store,
&flags,
Some(EnvPackBindingPayload {
environment_id: "local".to_string(),
slot: CapabilitySlot::Secrets,
kind: "greentic.secrets.dev-store@1.0.0".to_string(),
pack_ref: "greentic.secrets.dev-store".to_string(),
answers_ref: None,
idempotency_key: None,
}),
)
.unwrap();
greentic_deployer::cli::env_packs::update(
&store,
&flags,
Some(EnvPackBindingPayload {
environment_id: "local".to_string(),
slot: CapabilitySlot::Secrets,
kind: "greentic.secrets.aws-sm@1.0.0".to_string(),
pack_ref: "greentic.secrets.aws-sm".to_string(),
answers_ref: None,
idempotency_key: None,
}),
)
.unwrap();
let events = read_events(dir.path(), "local");
assert_eq!(events.len(), 3);
let update_event = events
.iter()
.find(|e| e.verb == "update")
.expect("update event present");
assert_eq!(update_event.previous_generation, Some(0));
assert_eq!(update_event.new_generation, Some(1));
}
#[test]
fn audit_log_uses_safe_env_segment_rejecting_dotdot() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env_id = EnvId::try_from("..").unwrap();
let result = greentic_deployer::environment::AuditLog::for_env(&store, &env_id);
assert!(result.is_err(), "audit log must reject unsafe env segments");
}
#[test]
fn committed_mutation_with_unwritable_audit_dir_fails_closed() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
std::fs::create_dir_all(dir.path().join("local")).unwrap();
std::fs::write(dir.path().join("local").join("audit"), b"not a dir").unwrap();
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::Audit(_)),
"committed mutation with broken audit dir must surface OpError::Audit, got {err:?}"
);
assert!(
dir.path().join("local").join("environment.json").exists(),
"the mutation itself committed before the audit append was attempted"
);
}
#[test]
fn denied_mutation_with_unwritable_audit_dir_still_returns_unauthorized() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
std::fs::create_dir_all(dir.path().join("prod")).unwrap();
std::fs::write(dir.path().join("prod").join("audit"), b"not a dir").unwrap();
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "prod".to_string(),
name: "prod".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::Unauthorized { .. }),
"denied op must surface Unauthorized even when audit append fails, got {err:?}"
);
assert!(
!dir.path().join("prod").join("environment.json").exists(),
"deny must not commit state"
);
}