crates/monetize-server/src/backup/tests.rs
//! A backup nobody has restored is not a backup, so the central test is a ROUND
//! TRIP that asserts the ledger folds identically — not that bytes match, which
//! would prove only that a file copy works.
use std::path::PathBuf;
use super::*;
use monetize::testing::{purchase, Answer, Fail, MockCloud, MockPay, MockProduct, NOW};
use monetize::{Signer, Transaction};
use monetize_pay::{Currency, Money, PaidSignup};
use crate::signup::{Recorded, SignupState};
struct Rig {
dir: PathBuf,
ledger: Ledger,
usage: UsageStore,
signups: SignupStore,
reconcile: crate::reconcile::ReconcileStore,
nonpay: crate::nonpay::NonpayJournal,
signer: Signer,
pay: MockPay,
cloud: MockCloud,
product: MockProduct,
}
impl Rig {
fn new(name: &str) -> Rig {
Rig::seeded(name, [0xA5; 32])
}
/// A rig with a chosen signing seed, so two rigs can differ by key.
fn seeded(name: &str, seed: [u8; 32]) -> Rig {
let dir =
std::env::temp_dir().join(format!("monetize-backup-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cloud = MockCloud::new();
cloud.answer_all(Answer::Provisioned);
Rig {
dir,
ledger: Ledger::in_memory().unwrap(),
usage: UsageStore::in_memory().unwrap(),
signups: SignupStore::in_memory().unwrap(),
reconcile: crate::reconcile::ReconcileStore::in_memory().unwrap(),
nonpay: crate::nonpay::NonpayJournal::in_memory(),
// From a KNOWN seed, so the secrets test can assert those exact bytes
// are absent rather than trusting that a generated key would be.
signer: Signer::from_bytes(&seed),
pay: MockPay::new(),
cloud,
product: MockProduct::gunnar(),
}
}
fn tx(&self) -> Transaction<'_> {
Transaction {
pay: monetize_pay::Vendor::of(&self.pay),
cloud: &self.cloud,
product: self.product.served(),
ledger: &self.ledger,
signer: &self.signer,
now_unix_ms: NOW,
}
}
fn stores(&self) -> Stores<'_> {
Stores {
ledger: &self.ledger,
usage: &self.usage,
signups: &self.signups,
reconcile: &self.reconcile,
nonpay: &self.nonpay,
}
}
fn pubkey(&self) -> String {
self.signer
.public_key()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// Some real history: three tenants bought, one of them torn down, plus
/// usage readings — so the export has open sets, closed attempts,
/// entitlements and a Reclaimed row in it.
fn with_history(&self) {
for t in ["alice", "bob", "team/sub"] {
self.tx().run(&purchase(t)).unwrap();
let mut meters = std::collections::BTreeMap::new();
meters.insert("pack_bytes".to_string(), 4096u64);
meters.insert("repos".to_string(), 2u64);
self.usage
.put(
"gunnar",
t,
&monetize_product::Usage {
meters,
measured_at_unix_ms: NOW,
},
)
.unwrap();
}
self.tx()
.release_tenant(
"gunnar",
&monetize_product::TenantId("bob".into()),
"tenant removed",
)
.unwrap();
// AND ONE ATTEMPT LEFT OPEN. Without it the export has no `open` set, and
// a restore that never rebuilt one would look identical — which is
// exactly what a deliberate red proved about the first version of this
// fixture. `Unavailable` at capture stalls the attempt with the money
// still reserved, which is the real shape reconciliation resumes from.
*self.pay.fail_capture.lock().unwrap() = Some(Fail::Unavailable);
let stalled = self.tx().run(&purchase("olle"));
assert!(
stalled.is_err(),
"the fixture needs a stalled attempt: {stalled:?}"
);
*self.pay.fail_capture.lock().unwrap() = None;
assert_eq!(
self.ledger.unfinished().unwrap().len(),
1,
"exactly one open attempt"
);
// AND THREE PAID SIGNUPS, one in each state. The store is keyed
// (vendor, session) and holds no ledger reference, so none of the
// assertions above would notice if it were dropped from the backup
// entirely — which is what it did until this fixture said so.
self.signup(
"cs_created",
"bob",
SignupState::Created {
namespace: "bob".into(),
username: "bob".into(),
at_unix_ms: NOW as i64,
},
);
self.signup(
"cs_refused",
"Bob!",
SignupState::Refused {
reason: "username \"Bob!\" contains '!'".into(),
at_unix_ms: NOW as i64,
refusal: crate::signup::Refusal::Name,
words: None,
owed: crate::signup::Owed::RenameOrRefund,
},
);
self.signup("cs_pending", "olle", SignupState::Pending);
}
/// Record one signup and put it in `state`, the way `signup::settle` does.
fn signup(&self, session: &str, username: &str, state: SignupState) {
let s = a_signup(session, username);
assert_eq!(
self.signups.record_pending(&s, NOW as i64).unwrap(),
Recorded::Fresh
);
if !state.is_pending() {
assert!(self.signups.set("mockpay", session, state).unwrap());
}
}
}
/// The shape a vendor's verified webhook hands the engine.
fn a_signup(session: &str, username: &str) -> PaidSignup {
PaidSignup {
vendor: "mockpay".into(),
session: session.into(),
reference: "pi_1".into(),
email: "bob@example.com".into(),
username: username.into(),
product: "gunnar".into(),
plan: "gunnar/10gb-1m".into(),
amount: Money {
minor: 10_000,
currency: Currency::Sek,
},
// No tax registration when this fixture was written: the vendor took the
// plan price and nothing on top, so gross and net are the same number.
tax: Money {
minor: 0,
currency: Currency::Sek,
},
paid_at_unix_ms: NOW as i64,
customer_country: None,
}
}
#[test]
fn a_backup_restores_to_a_ledger_that_folds_identically() {
let a = Rig::new("roundtrip");
a.with_history();
let before_rows = a.ledger.export_rows().unwrap();
let before_open = a.ledger.unfinished().unwrap();
let before_ents = a.ledger.entitlements("gunnar").unwrap();
let before_held: Vec<_> = ["alice", "bob", "team/sub"]
.iter()
.map(|t| a.ledger.held_resources("gunnar", t).unwrap())
.collect();
let before_total = a
.ledger
.total_provisioned_minor(monetize_pay::Currency::Eur)
.unwrap();
assert!(
!before_rows.is_empty() && !before_ents.is_empty(),
"the fixture must have real history"
);
assert_eq!(
before_open.len(),
1,
"and an OPEN attempt, or the open-set rebuild is untested"
);
let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
assert_eq!(m.ledger_rows, before_rows.len());
assert_eq!(m.usage_readings, 3);
assert_eq!(m.signups, 3);
assert!(m.contains_no_secrets);
// A DIFFERENT, EMPTY server — the same signing key, because that is the
// honest restore (see BackupError::KeyMismatch).
let b = Rig::new("roundtrip-target");
let restored = import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
assert_eq!(restored, m);
// THE ASSERTION THAT MATTERS: everything derived from the ledger agrees.
assert_eq!(
b.ledger.export_rows().unwrap(),
before_rows,
"the rows came back verbatim, seqs included"
);
assert_eq!(
b.ledger.unfinished().unwrap(),
before_open,
"the open set was rebuilt"
);
assert_eq!(
b.ledger.entitlements("gunnar").unwrap(),
before_ents,
"the verdicts were rebuilt"
);
assert_eq!(
b.ledger
.total_provisioned_minor(monetize_pay::Currency::Eur)
.unwrap(),
before_total,
"the spend total is recomputable"
);
for (i, t) in ["alice", "bob", "team/sub"].iter().enumerate() {
assert_eq!(
b.ledger.held_resources("gunnar", t).unwrap(),
before_held[i],
"held resources for {t}"
);
}
// Including the torn-down tenant: its Reclaimed row survived, so a restore
// does not resurrect iron that was given back.
assert!(
b.ledger.held_resources("gunnar", "bob").unwrap().is_empty(),
"bob was reclaimed before the backup"
);
assert_eq!(b.usage.export_all().unwrap(), a.usage.export_all().unwrap());
assert_eq!(
b.signups.export_all().unwrap(),
a.signups.export_all().unwrap(),
"the signup store came back whole"
);
}
/// **THE DRILL: a restored signup store still refuses the vendor's retry.**
///
/// The round trip above proves the rows came back. That is not the property
/// that matters here. This store IS the idempotency key of the money path: what
/// a restore has to preserve is the ANSWER `record_pending` gives when the
/// vendor re-delivers a webhook it already delivered — `Recorded::Already`,
/// carrying the outcome the first delivery reached. If a restore lost that (or
/// restored the row with its state reset to PENDING, which a naive
/// re-recording would do), the next Stripe retry would run the sink again and
/// create a second account for one payment, and no assertion about row counts
/// would have noticed.
///
/// So: fulfil a signup, back it up, restore into an EMPTY server, and let the
/// vendor retry against the restored store.
#[test]
fn a_restored_signup_store_still_answers_already_for_a_session_it_had_fulfilled() {
let a = Rig::new("signup-drill");
a.with_history();
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
let b = Rig::new("signup-drill-target");
let m = import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
assert_eq!(m.signups, 3);
// The vendor retries the fulfilled one, an hour after the restore.
let later = NOW as i64 + 3_600_000;
let retry = b
.signups
.record_pending(&a_signup("cs_created", "bob"), later)
.unwrap();
let Recorded::Already(rec) = retry else {
panic!("the restored store forgot a fulfilled session: {retry:?}")
};
assert_eq!(
rec.state,
SignupState::Created { namespace: "bob".into(), username: "bob".into(), at_unix_ms: NOW as i64 },
"the restore reset a decided signup to pending, which is a second account waiting to happen"
);
assert_eq!(
rec.recorded_at_unix_ms, NOW as i64,
"the retry must not restamp the original delivery"
);
// A refusal is just as final after a restore.
let Recorded::Already(rec) = b
.signups
.record_pending(&a_signup("cs_refused", "Bob!"), later)
.unwrap()
else {
panic!("the refusal did not survive")
};
assert!(matches!(rec.state, SignupState::Refused { .. }));
// And the one that was still PENDING is still pending — it is the only one
// the sink may be asked about again.
let Recorded::Already(rec) = b
.signups
.record_pending(&a_signup("cs_pending", "olle"), later)
.unwrap()
else {
panic!("the pending row did not survive")
};
assert!(rec.state.is_pending());
// A session NOBODY ever paid for is still fresh: a restore does not invent rows.
assert_eq!(
b.signups
.record_pending(&a_signup("cs_new", "olle"), later)
.unwrap(),
Recorded::Fresh
);
// The e-mail came back, because a PENDING signup cannot be retried against
// the sink without it. It is the one personal datum in the backup and the
// module doc says so out loud.
assert_eq!(
b.signups
.get("mockpay", "cs_pending")
.unwrap()
.unwrap()
.signup
.email,
"bob@example.com"
);
}
/// A `signups.jsonl` short of the manifest is the truncation this module refuses.
#[test]
fn a_short_signups_file_is_refused() {
let c = Rig::new("short-signups");
c.with_history();
export(&c.dir, &c.stores(), &c.pubkey()).unwrap();
let text = std::fs::read_to_string(c.dir.join(SIGNUPS)).unwrap();
let mut lines: Vec<&str> = text.lines().collect();
lines.pop();
std::fs::write(c.dir.join(SIGNUPS), lines.join("\n") + "\n").unwrap();
let d = Rig::new("short-signups-target");
let err = import(&c.dir, &d.stores(), &c.pubkey(), false)
.expect_err("a short signups file must be refused");
assert!(
err.to_string().contains("signups") && err.to_string().contains("manifest says"),
"{err}"
);
}
#[test]
fn an_unchanged_export_is_byte_identical_so_a_git_backup_has_nothing_to_commit() {
let r = Rig::new("determinism");
r.with_history();
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
let first: Vec<(String, Vec<u8>)> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
.iter()
.map(|f| (f.to_string(), std::fs::read(r.dir.join(f)).unwrap()))
.collect();
let stamps: Vec<_> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
.iter()
.map(|f| {
std::fs::metadata(r.dir.join(f))
.unwrap()
.modified()
.unwrap()
})
.collect();
std::thread::sleep(std::time::Duration::from_millis(20));
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
let second: Vec<(String, Vec<u8>)> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
.iter()
.map(|f| (f.to_string(), std::fs::read(r.dir.join(f)).unwrap()))
.collect();
assert_eq!(
first, second,
"the same state must export to the same bytes"
);
// And the files were not even rewritten, so mtimes are untouched.
let after: Vec<_> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
.iter()
.map(|f| {
std::fs::metadata(r.dir.join(f))
.unwrap()
.modified()
.unwrap()
})
.collect();
assert_eq!(
stamps, after,
"an unchanged export must not touch the files at all"
);
// New history DOES change it, or the determinism above would be the useless
// kind — a function that always writes the same thing.
r.tx().run(&purchase("olle")).unwrap();
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
assert_ne!(
std::fs::read(r.dir.join(LEDGER)).unwrap(),
first[1].1,
"a new purchase must show up"
);
}
#[test]
fn refuse_twin_no_secret_reaches_the_backup() {
let r = Rig::new("secrets");
r.with_history();
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
// The PRIVATE key bytes, in every encoding a leak would plausibly take.
let secret: [u8; 32] = [0xA5; 32]; // the seed Rig::new uses
let hex: String = secret.iter().map(|b| format!("{b:02x}")).collect();
for f in [MANIFEST, LEDGER, USAGE, SIGNUPS] {
let bytes = std::fs::read(r.dir.join(f)).unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains(&hex), "{f} contains the signing key in hex");
assert!(
!bytes.windows(secret.len()).any(|w| w == secret),
"{f} contains the raw signing key bytes"
);
}
// The PUBLIC half is present on purpose, and is what makes a restore honest.
let manifest = std::fs::read_to_string(r.dir.join(MANIFEST)).unwrap();
assert!(
manifest.contains(&r.pubkey()),
"the public key belongs in the manifest"
);
assert!(
manifest.contains("NO SECRETS"),
"the file must say so in its own words"
);
}
#[test]
fn refuse_twin_a_restore_under_a_different_signing_key_is_refused_by_default() {
let a = Rig::new("keymismatch");
a.with_history();
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
let b = Rig::seeded("keymismatch-target", [0x5A; 32]); // a DIFFERENT signer
assert_ne!(a.pubkey(), b.pubkey());
let err = import(&a.dir, &b.stores(), &b.pubkey(), false)
.expect_err("a key mismatch must be refused");
let msg = err.to_string();
assert!(
msg.contains(&a.pubkey()) && msg.contains(&b.pubkey()),
"both keys must be named: {msg}"
);
assert!(
msg.contains("cannot reproduce") || msg.contains("cannot re-sign"),
"and the consequence: {msg}"
);
assert!(
b.ledger.export_rows().unwrap().is_empty(),
"a refused restore must write nothing"
);
// The override is a decision, and it works.
import(&a.dir, &b.stores(), &b.pubkey(), true).expect("the override restores");
assert!(!b.ledger.export_rows().unwrap().is_empty());
}
#[test]
fn refuse_twin_a_truncated_backup_and_a_restore_over_live_history_are_both_refused() {
let a = Rig::new("truncated");
a.with_history();
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
// Lose the last line — the failure most likely to pass unnoticed, because
// every remaining line still parses.
let text = std::fs::read_to_string(a.dir.join(LEDGER)).unwrap();
let mut lines: Vec<&str> = text.lines().collect();
lines.pop();
std::fs::write(a.dir.join(LEDGER), lines.join("\n") + "\n").unwrap();
let b = Rig::new("truncated-target");
let err =
import(&a.dir, &b.stores(), &a.pubkey(), false).expect_err("a short file must be refused");
assert!(
err.to_string().contains("truncated") || err.to_string().contains("manifest says"),
"{err}"
);
assert!(
b.ledger.export_rows().unwrap().is_empty(),
"nothing written"
);
// And a restore into a ledger that ALREADY holds rows: interleaving a
// restore with live history would produce a ledger that was never true.
let c = Rig::new("occupied");
c.with_history();
export(&c.dir, &c.stores(), &c.pubkey()).unwrap();
let err = import(&c.dir, &c.stores(), &c.pubkey(), false)
.expect_err("a non-empty ledger must be refused");
assert!(err.to_string().contains("already holds rows"), "{err}");
}
#[test]
fn a_stale_plans_file_from_before_the_catalogue_went_is_not_carried_forward() {
let r = Rig::new("stale-plans");
std::fs::write(r.dir.join("plans.toml"), "[[plan]]\nid = \"free\"\n").unwrap();
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
assert!(
!r.dir.join("plans.toml").exists(),
"a backup of today's state must not hand a restore a catalogue nothing reads"
);
}
/// **THE LOST PAYMENT A RESTORE USED TO MAKE PERMANENT.**
///
/// `crate::reconcile` is the only thing that finds a payment monetize never
/// recorded — the 2026-09-14 incident's shape: the box's clock ran two hours
/// fast, every webhook for a real 125.00 SEK charge was refused, and nothing
/// but a journald line existed. It finds them by asking each vendor from its
/// LAST PASS minus a margin, and from `now − 48 h` when there is no last pass.
///
/// Those stamps live in `reconcile.redb`, and that file was left out of this
/// backup on the argument (the module's own header) that a needs-operator row
/// *"is a fact the vendor will answer again on the next pass after a restore,
/// from a window that trails the restored last-pass stamp"*. A restore does
/// not restore a stamp that is not in the backup. So:
///
/// the disk dies on Monday · the box is rebuilt and restored on Friday ·
/// the first pass has no stamp and asks Stripe from Friday − 48 h ·
/// every session paid Monday to Wednesday whose webhook never landed is
/// older than that and is NEVER ASKED ABOUT AGAIN, by anything.
///
/// The customer paid, has no account, and no row anywhere says so. A window
/// that is too WIDE costs a few idempotent re-reads; one that is too narrow
/// costs the payment. RED before this lane: `last_pass` came back `None`.
#[test]
fn the_vendor_pass_stamps_survive_a_restore_so_no_payment_falls_behind_the_window() {
use crate::reconcile::{since_for, FIRST_WINDOW_MS};
const HOUR: i64 = 60 * 60 * 1000;
let a = Rig::new("reconcile-passes");
a.with_history();
// The last pass that finished before the disk died, on Monday.
let died = NOW as i64;
a.reconcile.set_last_pass("mockpay", died).unwrap();
a.reconcile
.set_last_pass("invoice", died - 6 * HOUR)
.unwrap();
let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
assert_eq!(m.vendor_passes, 2);
// Friday: an empty, rebuilt box.
let b = Rig::new("reconcile-passes-target");
let restored_on = died + 4 * 24 * HOUR;
assert_eq!(
b.reconcile.last_pass("mockpay").unwrap(),
None,
"the fixture must restore into an EMPTY store"
);
import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
assert_eq!(b.reconcile.last_pass("mockpay").unwrap(), Some(died));
assert_eq!(
b.reconcile.last_pass("invoice").unwrap(),
Some(died - 6 * HOUR)
);
// THE ASSERTION THAT IS ABOUT MONEY: the first pass after the restore
// reaches back past the outage, and the old behaviour did not.
let paid_on_tuesday = died + 24 * HOUR;
let restored_window = since_for(b.reconcile.last_pass("mockpay").unwrap(), restored_on);
assert!(
restored_window <= paid_on_tuesday,
"the restored window must cover the outage"
);
assert!(
since_for(None, restored_on) > paid_on_tuesday,
"the fixture's outage must be longer than the first window ({FIRST_WINDOW_MS} ms), or this test proves nothing"
);
// **A restore never moves a window FORWARD.** A box that has already run a
// pass of its own since the restore keeps the earlier of the two stamps:
// asking too far back is idempotent, asking not far enough loses a payment.
let c = Rig::new("reconcile-passes-later");
c.reconcile
.set_last_pass("mockpay", died + 10 * 24 * HOUR)
.unwrap();
import(&a.dir, &c.stores(), &a.pubkey(), false).unwrap();
assert_eq!(
c.reconcile.last_pass("mockpay").unwrap(),
Some(died),
"the EARLIER stamp wins"
);
}
/// ★ **A refund survives a restore** (g-refund, the OPEN item of g-refund-verb):
/// the `refunds` table of signups.redb was not in `--export/--import`, so a
/// restored box came up with the refunded signup back on the owed-back queue —
/// an alarm for money already given back, and a second refund one click away.
#[test]
fn a_refund_survives_a_restore_and_the_owed_back_queue_stays_settled() {
let a = Rig::new("refund-drill");
a.with_history();
let done = crate::signup::SignupRefund::Done {
refund_id: "cs_refused:refund:10000".into(),
amount: Money {
minor: 10_000,
currency: Currency::Sek,
},
reason: "name taken".into(),
requested_at_unix_ms: NOW as i64,
at_unix_ms: NOW as i64 + 1,
forced: false,
};
a.signups
.put_refund("mockpay", "cs_refused", &done)
.unwrap();
assert!(
a.signups.owed_back().unwrap().is_empty(),
"the refunded row is settled before the backup"
);
let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
assert_eq!(m.signup_refunds, 1);
let b = Rig::new("refund-drill-target");
import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
assert_eq!(
b.signups.refund_of("mockpay", "cs_refused").unwrap(),
Some(done)
);
assert!(
b.signups.owed_back().unwrap().is_empty(),
"a restore must not put a refunded signup back on the queue"
);
}
/// ★ **WHAT THE LEDGER HOLDS OUTSIDE ITS STEP ROWS SURVIVES A RESTORE.** A
/// refund's take-back, an operator's comp and a ladder suspension are all
/// written to the entitlements table (the suspension also to the pending
/// pushes) and to no step row; a restore that re-derives entitlements from the
/// `EntitlementPushed` rows alone gives the refunded customer the room back,
/// drops the comp and un-suspends the tenant. Compared field by field.
#[test]
fn entitlements_and_pending_pushes_survive_a_restore_field_by_field() {
use monetize_product::{EntitlementFact, State, TenantId};
let a = Rig::new("entitlements");
a.with_history();
// A refund, and its take-back: the room is gone from the fact.
let alice = a
.ledger
.entitlement("gunnar", "alice")
.unwrap()
.expect("alice bought");
assert!(!alice.caps.is_empty());
let currency = a
.ledger
.sales()
.unwrap()
.into_iter()
.find(|s| s.reference == alice.plan)
.expect("alice's sale")
.price
.currency;
a.ledger
.append(
NOW + 1,
monetize::Step::Reversed {
reference: alice.plan.clone(),
kind: monetize_pay::ReversalKind::Refunded,
id: "re_alice".into(),
amount: Money {
minor: 100,
currency,
},
reason: "goodwill".into(),
by: "operator".into(),
},
)
.unwrap();
let taken = a.signer.sign_fact_at(
EntitlementFact {
caps: Default::default(),
source: format!("refund:{}", alice.plan),
..alice.clone()
},
NOW + 2,
);
a.ledger.set_entitlement("gunnar", &taken).unwrap();
// An operator's comp for a tenant who never bought.
let comp = a.signer.sign_fact_at(
EntitlementFact {
tenant: TenantId("comped".into()),
plan: "comp: a friend of the house".into(),
state: State::Paid,
paid_until_unix_ms: Some(NOW + 90 * 86_400_000),
caps: [("pack_bytes".to_string(), 1u64 << 30)].into(),
source: "operator".into(),
..alice.clone()
},
NOW + 3,
);
a.ledger.set_entitlement("gunnar", &comp).unwrap();
// A ladder suspension the product has not acknowledged yet.
let sub = a
.ledger
.entitlement("gunnar", "team/sub")
.unwrap()
.expect("team/sub bought");
let suspended = a.signer.sign_fact_at(
EntitlementFact {
state: State::Suspended,
source: "poller".into(),
..sub.clone()
},
NOW + 4,
);
let pending = monetize::ledger::PendingPush {
fact: suspended,
since_unix_ms: NOW + 4,
attempts: 2,
last_error: "gunnar did not answer".into(),
};
assert!(a
.ledger
.replace_entitlement_pending("gunnar", Some(&sub), &pending)
.unwrap());
let before_ents = a.ledger.entitlements("gunnar").unwrap();
let before_pushes = a.ledger.pending_pushes().unwrap();
assert_eq!(before_pushes.len(), 1);
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
let b = Rig::new("entitlements-target");
import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
let after_ents = b.ledger.entitlements("gunnar").unwrap();
assert_eq!(after_ents.len(), before_ents.len(), "{after_ents:?}");
for (was, is) in before_ents.iter().zip(&after_ents) {
let t = &was.tenant.0;
assert_eq!(is.tenant, was.tenant);
assert_eq!(is.plan, was.plan, "plan of {t}");
assert_eq!(is.state, was.state, "state of {t}");
assert_eq!(
is.paid_until_unix_ms, was.paid_until_unix_ms,
"paid_until of {t}"
);
assert_eq!(is.caps, was.caps, "caps of {t}");
assert_eq!(is.source, was.source, "source of {t}");
assert_eq!(is.issued_unix_ms, was.issued_unix_ms, "issued of {t}");
assert_eq!(is.signature, was.signature, "signature of {t}");
assert_eq!(
is.issued_signature, was.issued_signature,
"issued signature of {t}"
);
}
assert!(
b.ledger
.entitlement("gunnar", "alice")
.unwrap()
.unwrap()
.caps
.is_empty(),
"the refunded customer did not get the room back"
);
assert_eq!(
b.ledger.entitlement("gunnar", "comped").unwrap().as_ref(),
Some(&comp),
"the comp survived"
);
assert_eq!(
b.ledger
.entitlement("gunnar", "team/sub")
.unwrap()
.unwrap()
.state,
State::Suspended,
"the suspension survived"
);
let after_pushes = b.ledger.pending_pushes().unwrap();
assert_eq!(after_pushes.len(), before_pushes.len());
for ((wp, was), (ip, is)) in before_pushes.iter().zip(&after_pushes) {
assert_eq!(ip, wp);
assert_eq!(is.fact, was.fact);
assert_eq!(is.since_unix_ms, was.since_unix_ms);
assert_eq!(is.attempts, was.attempts);
assert_eq!(is.last_error, was.last_error);
}
}
/// ★ Review #18: an export that dies mid-file leaves the previous backup's file
/// whole, never half of the new one, and no temp file behind it.
#[test]
fn an_export_that_crashes_mid_file_leaves_the_last_backup_whole() {
let r = Rig::new("crash-mid-file");
r.with_history();
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
let before = std::fs::read(r.dir.join(LEDGER)).unwrap();
r.tx().run(&purchase("olle")).unwrap();
CRASH_AFTER.with(|c| c.set(Some(10)));
assert!(export(&r.dir, &r.stores(), &r.pubkey()).is_err());
assert_eq!(
std::fs::read(r.dir.join(LEDGER)).unwrap(),
before,
"the crash left half a ledger where a whole one was"
);
let left: Vec<String> = std::fs::read_dir(&r.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(left.is_empty(), "temp files left behind: {left:?}");
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
assert_ne!(
std::fs::read(r.dir.join(LEDGER)).unwrap(),
before,
"the next export writes the new state"
);
}
/// ★ Review #19: a `nonpay.jsonl` that lost a line is refused like every other
/// short file, never folded in as if it were whole.
#[test]
fn a_truncated_nonpay_file_is_refused_by_the_manifest() {
let a = Rig::new("nonpay-short");
a.with_history();
a.nonpay
.append(
1,
"gunnar",
"bob",
100,
crate::nonpay::Event::Scheduled {
delete_on_unix_ms: 200,
},
)
.unwrap();
a.nonpay
.append(
2,
"gunnar",
"olle",
100,
crate::nonpay::Event::Scheduled {
delete_on_unix_ms: 300,
},
)
.unwrap();
assert_eq!(
export(&a.dir, &a.stores(), &a.pubkey())
.unwrap()
.nonpay_rows,
2
);
let text = std::fs::read_to_string(a.dir.join(NONPAY)).unwrap();
std::fs::write(
a.dir.join(NONPAY),
text.lines().next().unwrap().to_owned() + "\n",
)
.unwrap();
let b = Rig::new("nonpay-short-target");
let err = import(&a.dir, &b.stores(), &a.pubkey(), false)
.expect_err("a short nonpay.jsonl must be refused");
assert!(
err.to_string().contains(NONPAY) && err.to_string().contains("manifest says"),
"{err}"
);
assert!(
b.nonpay.entries().is_empty() && b.ledger.export_rows().unwrap().is_empty(),
"nothing written"
);
}
/// ★ Review #19: a restore that is refused LATE (a bad line in the last file)
/// writes nothing. Every file is read and checked before any store is touched.
#[test]
fn a_restore_refused_late_leaves_every_store_empty() {
let a = Rig::new("late-refusal");
a.with_history();
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
let passes = a.dir.join(PASSES);
let mut text = std::fs::read_to_string(&passes).unwrap();
text.push_str("{not json\n");
std::fs::write(&passes, text).unwrap();
let b = Rig::new("late-refusal-target");
assert!(import(&a.dir, &b.stores(), &a.pubkey(), false).is_err());
assert!(
b.ledger.export_rows().unwrap().is_empty(),
"the ledger was written before the refusal"
);
assert!(
b.usage.export_all().unwrap().is_empty(),
"usage was written before the refusal"
);
assert!(
b.signups.export_all().unwrap().is_empty(),
"signups were written before the refusal"
);
}
/// ★ Review #19: a file edited in place keeps its line count, so only the
/// manifest's hash of it can tell. It is refused.
#[test]
fn a_file_edited_in_place_is_refused_by_its_hash() {
let a = Rig::new("edited");
a.with_history();
export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
let usage = a.dir.join(USAGE);
let text = std::fs::read_to_string(&usage).unwrap();
assert!(text.contains("4096"));
std::fs::write(&usage, text.replacen("4096", "4097", 1)).unwrap();
let b = Rig::new("edited-target");
let err = import(&a.dir, &b.stores(), &a.pubkey(), false)
.expect_err("an edited file must be refused");
assert!(err.to_string().contains(USAGE), "{err}");
assert!(
b.ledger.export_rows().unwrap().is_empty(),
"nothing written"
);
}
/// ★ Review #19 (gap G17): the server backs itself up on a timer. One tick
/// writes a whole backup into the configured `backup_dir`.
#[test]
fn a_scheduled_tick_writes_a_whole_backup_into_backup_dir() {
let r = Rig::new("scheduled");
let mut state = crate::state::AppState::in_memory(crate::registry::Registry::new());
state.backup_dir = Some(r.dir.join("tick"));
let m = scheduled_export(&state).expect("a tick with a backup_dir writes");
assert!(r.dir.join("tick").join(MANIFEST).exists());
assert_eq!(m.ledger_rows, 0);
state.backup_dir = None;
assert!(
scheduled_export(&state).is_err(),
"no backup_dir is said, not skipped in silence"
);
}
/// ★ A22-O W11 (F-T-08): **exports into one backup directory never interleave** — the
/// backup timer and an operator's `Ledger.Export` at the same moment. They shared each
/// file's temp name (`<file>.tmp`): one export's rename took the other's half-written temp,
/// or found it gone, and a manifest could name hashes of files the other wrote. One export
/// runs at a time per directory; every one of them succeeds and the last is whole (four
/// at once here, 300 each, so the race is met on every run).
#[test]
fn exports_into_one_directory_at_once_all_succeed_and_the_backup_is_whole() {
let r = Rig::new("two-at-once");
r.with_history();
let failures: Vec<String> = std::thread::scope(|s| {
let runs: Vec<_> = (0..4u64)
.map(|who| {
let r = &r;
s.spawn(move || {
let mut failed = Vec::new();
for i in 0..300u64 {
// A new reading each time: every export writes its files again.
let meters = std::collections::BTreeMap::from([(
"pack_bytes".to_string(),
4096 + who * 1_000 + i,
)]);
r.usage
.put(
"gunnar",
"alice",
&monetize_product::Usage {
meters,
measured_at_unix_ms: NOW,
},
)
.unwrap();
if let Err(e) = export(&r.dir, &r.stores(), &r.pubkey()) {
failed.push(e.to_string());
}
}
failed
})
})
.collect();
runs.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
assert!(
failures.is_empty(),
"{} export(s) failed while another ran: {:?}",
failures.len(),
failures.iter().take(3).collect::<Vec<_>>()
);
export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
let back = Rig::new("two-at-once-restored");
import(&r.dir, &back.stores(), &r.pubkey(), false).expect("the last backup reads back whole");
let left: Vec<String> = std::fs::read_dir(&r.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(left.is_empty(), "temp files left behind: {left:?}");
}