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 }