diff options
Diffstat (limited to 'internal/game/journal_damage_regression_test.go')
| -rw-r--r-- | internal/game/journal_damage_regression_test.go | 123 |
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 +} |
