aboutsummaryrefslogtreecommitdiff
path: root/internal/game/journal_damage_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/journal_damage_regression_test.go')
-rw-r--r--internal/game/journal_damage_regression_test.go123
1 files changed, 123 insertions, 0 deletions
diff --git a/internal/game/journal_damage_regression_test.go b/internal/game/journal_damage_regression_test.go
new file mode 100644
index 0000000..d8577fe
--- /dev/null
+++ b/internal/game/journal_damage_regression_test.go
@@ -0,0 +1,123 @@
+package game
+
+// Journal-damage regressions: a malformed record followed by other records
+// must abort the fold (the journal is preserved for inspection — records
+// beyond the damage may be owed money), an open failure is never "an empty
+// healthy journal", and compaction can never run over an untrusted fold.
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMalformedJournalRecordAbortsFold: a malformed line followed by a valid
+// record makes loadFolded fail — skipping the line and continuing would let
+// compensation and compaction run over records that may owe money.
+func TestMalformedJournalRecordAbortsFold(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ original := []byte("{\"id\":\"damaged\",\"kind\":\"payout\"\n{\"id\":\"valid\",\"kind\":\"payout\",\"status\":\"open\",\"credit\":20}\n")
+ if err := os.WriteFile(j.path, original, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := j.loadFolded(); err == nil {
+ t.Fatal("malformed mid-file record silently accepted")
+ }
+ // Compaction must abort and preserve the journal verbatim.
+ j.compact()
+ after, err := os.ReadFile(j.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(original) {
+ t.Errorf("compaction rewrote a damaged journal: %s", after)
+ }
+}
+
+// TestTornFinalJournalLineIsTolerated: a torn LAST line (crash mid-append)
+// is skipped — every valid record before it still folds.
+func TestTornFinalJournalLineIsTolerated(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ data := []byte("{\"id\":\"a\",\"kind\":\"wager\",\"status\":\"open\"}\n{\"id\":\"b\",\"kind\":\"payo")
+ if err := os.WriteFile(j.path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ folded, err := j.loadFolded()
+ if err != nil {
+ t.Fatalf("torn final line rejected a healthy fold: %v", err)
+ }
+ if len(folded) != 1 || folded[0].rec.ID != "a" {
+ t.Fatalf("unexpected fold: %+v", folded)
+ }
+}
+
+// TestJournalOpenErrorIsNotAnEmptyFold: an open failure (permissions, a
+// symlink loop) must be an error, never "no journal" — recovery would
+// otherwise treat owed money as never-charged.
+func TestJournalOpenErrorIsNotAnEmptyFold(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ if err := os.Symlink(j.path, j.path); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := j.loadFolded(); err == nil {
+ t.Error("ELOOP open failure reported as an empty healthy journal")
+ }
+}
+
+// TestOpenFailureAbortsRecovery: recoverEconomy over a journal that cannot
+// be fully read (malformed record with later records present) must abort
+// without paying owed money or compacting.
+func TestOpenFailureAbortsRecovery(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ // Corrupt the journal mid-file (a damaged record followed by an
+ // owed payout — appends after a torn merged line), then attempt
+ // recovery.
+ f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if _, werr := f.WriteString("{\"id\":\"damaged\",\"kind\":\"payout\"\n"); werr != nil {
+ t.Error(werr)
+ }
+ f.Close()
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
+ t.Errorf("owed payout could not be journaled: %v", err)
+ return
+ }
+ h.g.recoverEconomy()
+ // The damaged fold aborted recovery: no compensation ran and no
+ // compaction erased the records.
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 100 {
+ t.Errorf("recovery compensated over a damaged journal: credits=%d, want 100 (preserved for inspection)", loaded.Credits)
+ }
+ data, err := os.ReadFile(h.g.Economy.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !containsBytes(data, "kind\":\"payout\"") {
+ t.Error("recovery compacted a damaged journal, erasing records preserved for inspection")
+ }
+ })
+}
+
+func containsBytes(data []byte, s string) bool {
+ for i := 0; i+len(s) <= len(data); i++ {
+ if string(data[i:i+len(s)]) == s {
+ return true
+ }
+ }
+ return false
+}