crates/nornir-saga/src/event.rs
//! [`SagaEvent`] — one row of the user's journey, and the [`EventKind`] that
//! tags it. Toolkit-neutral: an egui app and a web (Leptos + Axum) app emit the
//! *same* event shape; only the (deferred) capture adapter differs.
//!
//! The builders ([`SagaEvent::nav`], [`SagaEvent::click`], [`SagaEvent::log`],
//! [`SagaEvent::error`], [`SagaEvent::state`]) set the `kind` + the fields that
//! matter for that kind and leave the session/app identity (`session_id`,
//! `user`, `app`, `app_version`, `git_sha`) blank — a capture adapter fills those
//! once per session via [`SagaEvent::in_session`]. `seq`/`ts_micros` are stamped
//! by the recorder at [`crate::record`] time.
use serde::{Deserialize, Serialize};
/// What a [`SagaEvent`] represents. **Navigation is the primary event**; the
/// others ride the same pipe so a session is one ordered stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind {
/// A route / view change — the backbone of replay.
Nav,
/// A click / activation on a component.
Click,
/// A log line (carries `level` + `detail`).
Log,
/// An error (a `Log` whose level is error, surfaced distinctly for clustering).
Error,
/// A standalone component-state snapshot (no navigation, just `state_json`).
State,
}
impl EventKind {
/// Stable lowercase label (matches the warehouse `kind` column + serde repr).
pub fn as_str(self) -> &'static str {
match self {
EventKind::Nav => "nav",
EventKind::Click => "click",
EventKind::Log => "log",
EventKind::Error => "error",
EventKind::State => "state",
}
}
/// Parse a label back to a kind (case-insensitive). `None` if unknown.
pub fn parse(s: &str) -> Option<EventKind> {
match s.trim().to_ascii_lowercase().as_str() {
"nav" => Some(EventKind::Nav),
"click" => Some(EventKind::Click),
"log" => Some(EventKind::Log),
"error" => Some(EventKind::Error),
"state" => Some(EventKind::State),
_ => None,
}
}
/// Is this a navigation step the matrix replays into a robot action?
/// (`Nav` + `Click` are actionable; logs/errors/state are context.)
pub fn is_actionable(self) -> bool {
matches!(self, EventKind::Nav | EventKind::Click)
}
}
/// One captured moment in a user's journey. Mirrors the nornir-warehouse
/// `saga_events` table one-to-one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaEvent {
/// Monotonic capture order within the process (stamped by the recorder).
/// Replay sorts by this, so ordering is independent of wall-clock skew.
pub seq: u64,
/// Wall-clock microseconds since the Unix epoch (stamped by the recorder; `0`
/// for an un-recorded builder result).
pub ts_micros: u64,
/// Session this event belongs to (filled by the capture adapter).
pub session_id: String,
/// The acting user (filled by the capture adapter; may be anonymous).
pub user: String,
/// What kind of event this is.
pub kind: EventKind,
/// The route / view at the time (e.g. `/offert/123` or `GraphView`).
pub route: String,
/// The component reporting (e.g. `OffertForm`, `facett-map`).
pub component: String,
/// The action taken (e.g. `submit`, `open`, `select_row`).
pub action: String,
/// A short human note / the log message.
pub detail: String,
/// The component's/route's readable state at this moment (JSON string).
pub state_json: String,
/// Log level for `Log`/`Error` events (`info`/`warn`/`error`); empty otherwise.
pub level: String,
/// Span id for causal grouping (filled by the capture adapter; optional).
pub span_id: String,
/// Parent span/event id (optional).
pub parent: String,
/// The application name (filled by the capture adapter, e.g. `njord`).
pub app: String,
/// The application version (filled by the capture adapter).
pub app_version: String,
/// The git sha the app was built from (filled by the capture adapter).
pub git_sha: String,
}
impl SagaEvent {
/// An empty event with the given kind — internal seed for the builders.
fn of(kind: EventKind) -> SagaEvent {
SagaEvent {
seq: 0,
ts_micros: 0,
session_id: String::new(),
user: String::new(),
kind,
route: String::new(),
component: String::new(),
action: String::new(),
detail: String::new(),
state_json: String::new(),
level: String::new(),
span_id: String::new(),
parent: String::new(),
app: String::new(),
app_version: String::new(),
git_sha: String::new(),
}
}
/// A **navigation** event — the primary, replayable step.
pub fn nav(route: &str, component: &str, action: &str, state_json: &str) -> SagaEvent {
SagaEvent {
route: route.to_string(),
component: component.to_string(),
action: action.to_string(),
state_json: state_json.to_string(),
..SagaEvent::of(EventKind::Nav)
}
}
/// A **click** / activation event — also replayable.
pub fn click(route: &str, component: &str, action: &str, state_json: &str) -> SagaEvent {
SagaEvent {
route: route.to_string(),
component: component.to_string(),
action: action.to_string(),
state_json: state_json.to_string(),
..SagaEvent::of(EventKind::Click)
}
}
/// A **log** line.
pub fn log(level: &str, detail: &str) -> SagaEvent {
SagaEvent {
level: level.to_string(),
detail: detail.to_string(),
..SagaEvent::of(EventKind::Log)
}
}
/// An **error** event (a log whose level is `error`, surfaced distinctly).
pub fn error(component: &str, detail: &str) -> SagaEvent {
SagaEvent {
component: component.to_string(),
detail: detail.to_string(),
level: "error".to_string(),
..SagaEvent::of(EventKind::Error)
}
}
/// A standalone component-**state** snapshot.
pub fn state(component: &str, state_json: &str) -> SagaEvent {
SagaEvent {
component: component.to_string(),
state_json: state_json.to_string(),
..SagaEvent::of(EventKind::State)
}
}
/// Stamp the session/app identity onto this event. A capture adapter calls
/// this once per event (cheaply, from a held [`SessionCtx`]) before
/// [`crate::record`].
pub fn in_session(mut self, ctx: &SessionCtx) -> SagaEvent {
self.session_id = ctx.session_id.clone();
self.user = ctx.user.clone();
self.app = ctx.app.clone();
self.app_version = ctx.app_version.clone();
self.git_sha = ctx.git_sha.clone();
self
}
}
// ─── PII redaction (mask-by-default) ─────────────────────────────────────────
/// Mask-by-default **PII redaction** for [`SagaEvent`] free-text: emails, bearer/
/// token strings, and id-shaped substrings are replaced with a fixed marker before
/// an event is recorded or displayed, so a stored journey never carries a raw user
/// email or secret. Length-preserving-ish markers keep the shape readable.
///
/// Applied in the record path ([`crate::record`]) so events are masked by DEFAULT;
/// a field can be opted OUT via an allowlist (a field the app has judged safe, e.g.
/// a public route). Idempotent: masking an already-masked value is a no-op.
pub mod redact {
/// The marker a redacted email/token/id is replaced with.
pub const MASK: &str = "«redacted»";
/// Mask every email, bearer/token, Swedish personnummer, and long id-shaped
/// run in `value`. Returns
/// the value unchanged when it contains no PII-shaped substring. Idempotent —
/// [`MASK`] itself contains none of the shapes it looks for.
///
/// A secret **label** (`Bearer`/`token`/`password`/…) also masks the value
/// that immediately follows it, so the credential itself is scrubbed even
/// when it is short and not id-shaped: `Bearer abc.def` and `password=hunter2`
/// both mask the secret, not just the label word.
pub fn mask(value: &str) -> String {
let mut out = String::with_capacity(value.len());
// When a secret label was just masked, the credential is the NEXT value
// run — mask it too (separators in between are emitted verbatim and do
// not consume the pending mask).
let mut mask_next_value = false;
for token in split_keep(value) {
let is_value = token.chars().next().map(is_value_char).unwrap_or(false);
if !is_value {
out.push_str(token);
continue;
}
if mask_next_value {
out.push_str(MASK);
mask_next_value = false;
continue;
}
if is_pii(token) {
out.push_str(MASK);
// A secret LABEL carries its credential in the following value run.
if is_secret_word(core_of(token)) {
mask_next_value = true;
}
} else {
out.push_str(token);
}
}
out
}
/// True when `s` looks like a bearer/`token=…`/`sk-…` secret carrier — masked
/// as a whole run (so `Authorization: Bearer abc.def` masks the credential).
fn is_secret_word(s: &str) -> bool {
let low = s.to_ascii_lowercase();
low.starts_with("bearer")
|| low.starts_with("token")
|| low.starts_with("sk-")
|| low.starts_with("ghp_")
|| low.starts_with("apikey")
|| low.starts_with("api_key")
|| low.starts_with("password")
|| low.starts_with("secret")
}
/// Strip surrounding punctuation from a token for the shape checks (the raw
/// token is kept for masking so the whole run is replaced).
fn core_of(token: &str) -> &str {
token.trim_matches(|c: char| {
!c.is_ascii_alphanumeric() && c != '@' && c != '.' && c != '-' && c != '_' && c != '+'
})
}
/// Does this whitespace-delimited token carry PII we mask?
fn is_pii(token: &str) -> bool {
let core = core_of(token);
if core.is_empty() {
return false;
}
if is_email(core) {
return true;
}
if is_secret_word(core) {
return true;
}
if is_personnummer(core) {
return true;
}
if is_organisationsnummer(core) {
return true;
}
// Long opaque id-shaped run: >= 16 chars of hex/base64-ish with no spaces
// and at least one digit (so ordinary long words aren't masked).
if core.len() >= 16
&& core.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& core.chars().any(|c| c.is_ascii_digit())
&& core.chars().any(|c| c.is_ascii_alphabetic())
{
return true;
}
false
}
/// True when `s` is a Swedish personnummer or samordningsnummer.
///
/// These need their own check because the id-shaped run above can never
/// catch one: they are 10–12 characters and carry no letter, so they fail
/// both the `len() >= 16` gate and the "has an alphabetic char" condition.
/// They are also the most sensitive identifier the product handles, which
/// is why they are worth a dedicated shape rather than a looser id rule.
///
/// Accepts `YYMMDD-XXXX`, `YYMMDD+XXXX` (the bearer has turned 100),
/// `YYYYMMDD-XXXX`, and the separator-less 10- and 12-digit forms.
///
/// The Luhn checksum is what keeps this from masking every 10-digit number
/// that flows past: an arbitrary digit run survives it only 1 time in 10,
/// and it must carry a plausible month and day on top of that.
fn is_personnummer(s: &str) -> bool {
// `.` is a value char, so a personnummer ending a sentence arrives with
// the period still attached.
let s = s.trim_matches('.');
let mut digits = [0u8; 12];
let mut n = 0;
let mut separators = 0;
for (i, c) in s.char_indices() {
match c {
'0'..='9' => {
if n == 12 {
return false;
}
digits[n] = c as u8 - b'0';
n += 1;
}
// At most one separator, and only immediately before the
// four-digit suffix — otherwise `2026-07-21` would parse.
'-' | '+' if i > 0 && separators == 0 && s.len() - i == 5 => separators += 1,
_ => return false,
}
}
// The 12-digit form carries the century. Luhn and the date are defined
// on the 10-digit core, so drop it.
let core: &[u8] = match n {
10 => &digits[..10],
12 => &digits[2..],
_ => return false,
};
let month = core[2] * 10 + core[3];
let day = core[4] * 10 + core[5];
if !(1..=12).contains(&month) {
return false;
}
// A samordningsnummer is the birth day plus 60.
if !(1..=31).contains(&day) && !(61..=91).contains(&day) {
return false;
}
luhn_ok(core)
}
/// True when `s` is a Swedish **organisationsnummer** — the company-number
/// counterpart to [`is_personnummer`], which structurally cannot catch one.
///
/// The two share a format (`NNNNNN-NNNN`, Luhn over the first nine digits) and
/// are told apart by the third and fourth digits: on a personnummer they are a
/// birth MONTH (`01`–`12`), on an organisationsnummer they are a group number
/// that is always **≥ 20**. So `is_personnummer`'s month check — the thing that
/// stops it masking every 10-digit run — rejects every orgnr by construction.
/// Without this function they flowed through unmasked.
///
/// Worth masking even though a limited company's number is not itself personal
/// data: for an **enskild firma** the organisationsnummer IS the owner's
/// personnummer, so the two sets overlap on exactly the most sensitive case. (A
/// sole trader's number has a real month and is already caught above; this
/// covers the rest, where the number still identifies a counterparty in a
/// recorded journey.)
///
/// Accepts the `NNNNNN-NNNN` and bare 10-digit forms, plus the 12-digit form
/// prefixed `16` (the only century prefix an orgnr takes).
fn is_organisationsnummer(s: &str) -> bool {
let s = s.trim_matches('.');
let mut digits = [0u8; 12];
let mut n = 0;
let mut separators = 0;
for (i, c) in s.char_indices() {
match c {
'0'..='9' => {
if n == 12 {
return false;
}
digits[n] = c as u8 - b'0';
n += 1;
}
// Same placement rule as a personnummer: one separator, and only
// immediately before the four-digit suffix. `+` is personnummer-only
// (it marks a bearer over 100), so an orgnr never carries it.
'-' if i > 0 && separators == 0 && s.len() - i == 5 => separators += 1,
_ => return false,
}
}
let core: &[u8] = match n {
10 => &digits[..10],
// Only `16` prefixes a 12-digit organisationsnummer.
12 if digits[0] == 1 && digits[1] == 6 => &digits[2..],
_ => return false,
};
// The group-number digits — what separates an orgnr from a personnummer.
if core[2] * 10 + core[3] < 20 {
return false;
}
luhn_ok(core)
}
/// Luhn (mod-10) over the first nine digits, checked against the tenth — the
/// control digit both Swedish number formats carry. Shared so the two shape
/// checks cannot drift apart on the one part of the rule they agree on.
fn luhn_ok(core: &[u8]) -> bool {
let sum: u32 = core[..9]
.iter()
.enumerate()
.map(|(i, &d)| {
// Every other digit doubled, starting with the first.
let p = u32::from(d) * if i % 2 == 0 { 2 } else { 1 };
if p > 9 { p - 9 } else { p }
})
.sum();
(10 - (sum % 10)) % 10 == u32::from(core[9])
}
/// A minimal `local@domain.tld` shape check (no full RFC 5322).
fn is_email(s: &str) -> bool {
let mut parts = s.splitn(2, '@');
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
return false;
};
let domain = domain.trim_end_matches('.');
!local.is_empty()
&& domain.contains('.')
&& domain.split('.').all(|seg| !seg.is_empty())
&& !domain.starts_with('.')
}
/// A char that can be part of a maskable value run (email/token/id): letters,
/// digits, and the punctuation those shapes use internally. Everything else
/// (whitespace, quotes, braces, colons, commas, slashes …) is a separator, so a
/// value embedded in JSON — `"Bearer abc123…"` — still tokenizes out.
fn is_value_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '-' | '_' | '+')
}
/// Split into alternating value-runs and separator-runs, preserving the
/// separators verbatim so re-joining reproduces the original layout (only the
/// value runs that are PII are swapped for [`MASK`]).
fn split_keep(value: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut cur = value.chars().next().map(is_value_char).unwrap_or(false);
for (i, c) in value.char_indices() {
if is_value_char(c) != cur {
out.push(&value[start..i]);
start = i;
cur = is_value_char(c);
}
}
if start < value.len() {
out.push(&value[start..]);
}
out
}
}
impl SagaEvent {
/// Return a **PII-redacted copy** of this event: `detail`, `state_json`, and
/// `user` are mask-by-default scrubbed via [`redact::mask`], EXCEPT any field
/// name present in `allow` (an explicit opt-in allowlist over `"detail"`,
/// `"state_json"`, `"user"`). Structural fields (`route`, `component`,
/// `action`, `kind`, ids) are left intact — they are the journey's shape, not
/// its payload. Idempotent.
pub fn redacted_with_allowlist(&self, allow: &[&str]) -> SagaEvent {
let allowed = |f: &str| allow.contains(&f);
let mut e = self.clone();
if !allowed("detail") {
e.detail = redact::mask(&e.detail);
}
if !allowed("state_json") {
e.state_json = redact::mask(&e.state_json);
}
if !allowed("user") {
e.user = redact::mask(&e.user);
}
e
}
/// Mask-by-default: [`redacted_with_allowlist`](Self::redacted_with_allowlist)
/// with an empty allowlist — every free-text field is scrubbed. This is the
/// default the record path applies.
pub fn redacted(&self) -> SagaEvent {
self.redacted_with_allowlist(&[])
}
}
/// The per-session identity a capture adapter holds and stamps onto every event
/// via [`SagaEvent::in_session`]. Kept here (not in the recorder) so the core
/// stays free of global session state — the adapter owns the session.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionCtx {
pub session_id: String,
pub user: String,
pub app: String,
pub app_version: String,
pub git_sha: String,
}
impl SessionCtx {
/// A new session context for `app` and `user`.
pub fn new(session_id: &str, user: &str, app: &str, app_version: &str, git_sha: &str) -> Self {
SessionCtx {
session_id: session_id.to_string(),
user: user.to_string(),
app: app.to_string(),
app_version: app_version.to_string(),
git_sha: git_sha.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_label_roundtrips() {
for k in [
EventKind::Nav,
EventKind::Click,
EventKind::Log,
EventKind::Error,
EventKind::State,
] {
assert_eq!(EventKind::parse(k.as_str()), Some(k), "{k:?} roundtrips");
}
assert_eq!(EventKind::parse("NAV"), Some(EventKind::Nav), "case-insensitive");
assert_eq!(EventKind::parse("nope"), None, "unknown → None");
}
#[test]
fn only_nav_and_click_are_actionable() {
assert!(EventKind::Nav.is_actionable());
assert!(EventKind::Click.is_actionable());
assert!(!EventKind::Log.is_actionable());
assert!(!EventKind::Error.is_actionable());
assert!(!EventKind::State.is_actionable());
}
#[test]
fn nav_builder_sets_kind_and_fields() {
let e = SagaEvent::nav("/offert/7", "OffertForm", "open", r#"{"id":7}"#);
assert_eq!(e.kind, EventKind::Nav);
assert_eq!(e.route, "/offert/7");
assert_eq!(e.component, "OffertForm");
assert_eq!(e.action, "open");
assert_eq!(e.state_json, r#"{"id":7}"#);
// identity blank until an adapter stamps it.
assert_eq!(e.session_id, "");
assert_eq!(e.app, "");
assert_eq!(e.seq, 0);
}
#[test]
fn error_builder_is_error_kind_at_error_level() {
let e = SagaEvent::error("OffertForm", "validation failed: missing org_nr");
assert_eq!(e.kind, EventKind::Error);
assert_eq!(e.level, "error");
assert_eq!(e.component, "OffertForm");
assert_eq!(e.detail, "validation failed: missing org_nr");
}
#[test]
fn in_session_stamps_identity_onto_event() {
let ctx = SessionCtx::new("sess-1", "alice", "njord", "0.9.0", "abc123");
let e = SagaEvent::nav("/home", "Dashboard", "open", "{}").in_session(&ctx);
assert_eq!(e.session_id, "sess-1");
assert_eq!(e.user, "alice");
assert_eq!(e.app, "njord");
assert_eq!(e.app_version, "0.9.0");
assert_eq!(e.git_sha, "abc123");
// navigation payload untouched by the stamp.
assert_eq!(e.route, "/home");
assert_eq!(e.kind, EventKind::Nav);
}
#[test]
fn event_json_roundtrips() {
let ctx = SessionCtx::new("s", "u", "njord", "1.0", "sha");
let e = SagaEvent::click("/x", "Btn", "submit", r#"{"v":1}"#).in_session(&ctx);
let json = serde_json::to_string(&e).expect("serialize");
let back: SagaEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, e, "SagaEvent survives a warehouse JSON roundtrip");
// kind serializes as the stable lowercase label.
assert!(json.contains(r#""kind":"click""#), "kind label in json: {json}");
}
// ── PII redaction (mask-by-default) ──────────────────────────────────────
#[test]
fn redacted_masks_email_and_token_in_free_text() {
let ctx = SessionCtx::new("s", "alice@example.com", "njord", "1.0", "sha");
let mut e = SagaEvent::error("LoginForm", "login failed for alice@example.com")
.in_session(&ctx);
e.state_json = r#"{"authorization":"Bearer abc123deadbeefcafe","ok":false}"#.into();
let r = e.redacted();
assert!(!r.detail.contains("alice@example.com"), "email masked in detail: {}", r.detail);
assert!(r.detail.contains(redact::MASK));
assert!(!r.state_json.contains("Bearer abc123deadbeefcafe"), "token masked: {}", r.state_json);
assert!(!r.user.contains("alice@example.com"), "user email masked: {}", r.user);
// Structural fields are the journey's shape — untouched.
assert_eq!(r.component, "LoginForm");
assert_eq!(r.kind, EventKind::Error);
}
#[test]
fn redacted_masks_swedish_personnummer() {
// Every accepted form. The id-shaped rule cannot reach these: 10–12
// characters, no letter.
for pnr in [
"811218-9876",
"8112189876",
"19811218-9876",
"198112189876",
"640823-3234",
"990161-0007", // samordningsnummer — birth day + 60
"811218+9876", // the bearer has turned 100
] {
let e = SagaEvent::log("info", &format!("kund {pnr} bokade besiktning"));
let r = e.redacted();
assert!(!r.detail.contains(pnr), "personnummer {pnr} masked: {}", r.detail);
assert!(r.detail.contains(redact::MASK), "mask marker present for {pnr}");
// The surrounding sentence survives — only the number goes.
assert!(r.detail.contains("bokade besiktning"), "context kept for {pnr}");
}
}
/// **RED-when-broken: organisationsnummer are masked too.**
///
/// `is_personnummer` can never catch these — it requires digits 3–4 to be a
/// birth month (`01`–`12`), and an orgnr's group number is always ≥ 20 — so
/// before `is_organisationsnummer` existed every one of these flowed into a
/// recorded journey in the clear. Vectors are real, published company numbers
/// (their check digits are what the Luhn here computes, which is the point).
#[test]
fn redacted_masks_swedish_organisationsnummer() {
for orgnr in [
"556016-0680", // Ericsson
"5560125790", // Volvo, bare 10-digit form
"232100-0156", // a region (group 21)
"802477-3114", // an ideell förening (group 24)
"262000-1129", // a state authority (group 20)
"165560160680", // the 12-digit `16`-prefixed form
] {
let e = SagaEvent::log("info", &format!("faktura till {orgnr} skickad"));
let r = e.redacted();
assert!(!r.detail.contains(orgnr), "orgnr {orgnr} masked: {}", r.detail);
assert!(r.detail.contains(redact::MASK), "mask marker present for {orgnr}");
assert!(r.detail.contains("skickad"), "context kept for {orgnr}");
}
}
/// The orgnr shape must not become a back door that masks any 10-digit run —
/// over-masking guts the journey these events exist to record.
#[test]
fn organisationsnummer_check_does_not_swallow_ordinary_numbers() {
for benign in [
"5560160681", // right shape + group, WRONG check digit
"1160160680", // valid Luhn shape but group number < 20 and no valid month
"175560160680", // 12 digits with a `17` prefix — only `16` is an orgnr
"2026-07-21", // an ISO date must never read as a company
] {
let e = SagaEvent::log("info", benign);
let r = e.redacted();
assert_eq!(r.detail, benign, "not orgnr-shaped, left intact: {benign}");
}
}
#[test]
fn personnummer_check_does_not_swallow_ordinary_numbers() {
// A failed Luhn digit, an ISO date and a plain quantity must all survive
// — over-masking would gut the journey these events exist to record.
for benign in ["8506151234", "2026-07-21", "1234", "order 42 st"] {
let e = SagaEvent::log("info", benign);
let r = e.redacted();
assert_eq!(r.detail, benign, "not PII-shaped, left intact: {benign}");
}
}
#[test]
fn redaction_is_idempotent() {
let e = SagaEvent::log("error", "contact admin@example.com now");
let once = e.redacted();
let twice = once.redacted();
assert_eq!(once, twice, "masking an already-masked event is a no-op");
assert!(!once.detail.contains("admin@example.com"));
}
#[test]
fn allowlisted_field_survives_redaction() {
let e = SagaEvent::log("info", "user u@d.com opened offert");
// Opt `detail` in → it is NOT masked; other fields still default-masked.
let r = e.redacted_with_allowlist(&["detail"]);
assert!(r.detail.contains("u@d.com"), "allowlisted detail survives: {}", r.detail);
}
#[test]
fn secret_label_masks_the_short_credential_that_follows() {
// Regression: a short, non-id-shaped credential after a secret label used
// to leak (only the label word was masked). The label now scrubs the
// value that follows it, whether space- or `=`-separated.
assert_eq!(redact::mask("Authorization: Bearer abc.def"),
format!("Authorization: {} {}", redact::MASK, redact::MASK));
assert_eq!(redact::mask("password=hunter2"),
format!("{}={}", redact::MASK, redact::MASK));
assert_eq!(redact::mask("token deadbeef"),
format!("{} {}", redact::MASK, redact::MASK));
// Re-masking is still a no-op (the label chaining does not fire on MASK).
let once = redact::mask("Bearer abc.def");
assert_eq!(redact::mask(&once), once, "credential-follow masking is idempotent");
}
#[test]
fn ordinary_text_is_left_intact() {
let e = SagaEvent::nav("/offert/123", "OffertForm", "open", r#"{"count":3}"#);
let r = e.redacted();
// No PII shapes → identical.
assert_eq!(r.detail, e.detail);
assert_eq!(r.state_json, e.state_json, "plain small json unchanged");
assert_eq!(r.route, "/offert/123", "route is structural, never masked");
}
#[test]
fn mask_helper_preserves_layout_of_non_pii_words() {
let masked = redact::mask("hello world foo");
assert_eq!(masked, "hello world foo", "no PII → verbatim, layout preserved");
let masked = redact::mask("email a@b.com end");
assert_eq!(masked, format!("email {} end", redact::MASK));
}
}