diff options
Diffstat (limited to 'internal/journal/read_test.go')
| -rw-r--r-- | internal/journal/read_test.go | 413 |
1 files changed, 413 insertions, 0 deletions
diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go new file mode 100644 index 0000000..3ffa14d --- /dev/null +++ b/internal/journal/read_test.go @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package journal + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunsListsNewestFirst(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + for _, r := range []struct { + id string + at time.Time + dirs []string + }{{"A", t0, []string{"dl"}}, {"B", t1, []string{"dl", "docs"}}} { + w.Append(Entry{Time: r.at, Run: r.id, Action: "run-start", Status: "ok"}) + for _, d := range r.dirs { + w.Append(Entry{Time: r.at, Run: r.id, Dir: d, File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) + } + w.Append(Entry{Time: r.at, Run: r.id, Action: "run-end", Status: "ok"}) + } + w.Close() + + runs, err := Runs(path, 0) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 || runs[0].ID != "B" || runs[1].ID != "A" { + t.Fatalf("runs = %+v; want B then A", runs) + } + if len(runs[0].Dirs) != 2 || runs[0].Dirs[0] != "dl" || runs[0].Dirs[1] != "docs" { + t.Errorf("run B dirs = %v, want [dl docs] in first-seen order", runs[0].Dirs) + } + if runs[0].Counts["move"] != 2 { + t.Errorf("run B move count = %d, want 2", runs[0].Counts["move"]) + } + if !runs[0].Start.Equal(t1) { + t.Errorf("run B start = %v, want %v", runs[0].Start, t1) + } + if runs, err = Runs(path, 1); err != nil || len(runs) != 1 || runs[0].ID != "B" { + t.Errorf("Runs(path, 1) = %+v, %v", runs, err) + } +} + +// TestTruncatedLastLineIsSkipped: a crash mid-write must not make the log +// unreadable - everything before the broken line still parses. +func TestTruncatedLastLineIsSkipped(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + w.Append(Entry{Time: time.Now(), Run: "A", Action: "run-start", Status: "ok"}) + w.Close() + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + f.WriteString("2026-09-11T10:02:03+02:00\tA\tdl\thalf-written") + f.Close() + + runs, err := Runs(path, 0) + if err != nil { + t.Fatalf("a truncated final line made the whole log unreadable: %v", err) + } + if len(runs) != 1 || runs[0].ID != "A" { + t.Errorf("runs = %+v; want the complete run A", runs) + } +} + +// TestRunsMarksAnUndoneRun: an undo run's run-start Detail names the run it +// reverses, in the exact format "undo of <run id>". Runs must mark that +// earlier run Undone, and must not mark the undo run itself. +func TestRunsMarksAnUndoneRun(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) + w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) + w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) + w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, + Action: "undo-move", Status: "ok", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) + w.Close() + + runs, err := Runs(path, 0) + if err != nil { + t.Fatal(err) + } + byID := map[string]Run{} + for _, r := range runs { + byID[r.ID] = r + } + if !byID["A"].Undone { + t.Errorf("run A = %+v, want Undone", byID["A"]) + } + if byID["B"].Undone { + t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"]) + } +} + +// TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined is fix wave item 2 +// (Important) / final-wave item 17: an undo run's run-start Detail alone +// used to be enough for Runs to mark the original run Undone, even when the +// undo run went on to decline every file (spec §9's "declined files are +// logged even though nothing happens to them", extended to undo) and +// reversed nothing at all. Reproduced by the reviewer via pty: `krino log` +// told the user a run had been undone when the file was still filed. Run B +// here carries the same run-start Detail as TestRunsMarksAnUndoneRun's, but +// every one of its file-scoped entries is "declined", never "ok" - the +// shape ApplyUndo logs when the front end's own review declines everything +// - so run A must come back exactly as untouched. +func TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) + w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) + w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) + w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, + Action: "undo-move", Status: "declined", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) + w.Close() + + runs, err := Runs(path, 0) + if err != nil { + t.Fatal(err) + } + byID := map[string]Run{} + for _, r := range runs { + byID[r.ID] = r + } + if byID["A"].Undone { + t.Errorf("run A = %+v, want NOT Undone - the undo run declined every file and reversed nothing", byID["A"]) + } + if byID["B"].Undone { + t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"]) + } +} + +// TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir is the coordinator's +// tightening of fix wave item 2: "at least one ok undo-* entry" is still +// too loose, by the same shape as the bug it fixes. A file's own chain +// stops after a failed file-affecting reversal, but a failed or refused +// undo-mkdir deliberately does not stop anything (internal/engine's +// isFileAffecting draws exactly this line, and undoFile's stop-on-failure +// check shares it) - so an undo-mkdir belonging to one file can still +// succeed even though every file-affecting reversal in the whole run +// failed. Here x.pdf's own undo-move fails, y.pdf's own undo-move also +// fails, and z.pdf's undo-mkdir - tidying up a directory that turned out +// empty, not restoring anything - is the run's only "ok" entry. Marking +// the original run Undone from that alone would be exactly Important 2's +// bug again, by a narrower route. +func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Hour) + w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) + w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) + w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "y.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"}) + w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) + w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, + Action: "undo-move", Status: "failed", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) + w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "y.pdf", Step: 1, + Action: "undo-move", Status: "failed", Src: "/b/y.pdf", Dst: "/a/y.pdf"}) + // z.pdf's own file-affecting reversal is unrelated to x.pdf/y.pdf's + // failures; only its cleanup mkdir is shown here, since that mkdir is + // the one entry this test is about - the run's only "ok" line. + w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "z.pdf", Step: 2, + Action: "undo-mkdir", Status: "ok", Src: "/a/Work"}) + w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) + w.Close() + + runs, err := Runs(path, 0) + if err != nil { + t.Fatal(err) + } + byID := map[string]Run{} + for _, r := range runs { + byID[r.ID] = r + } + if byID["A"].Undone { + t.Errorf("run A = %+v, want NOT Undone - the run's only ok entry is an undo-mkdir (tidiness, not a restoration), and every file-affecting reversal failed", byID["A"]) + } +} + +// TestEntriesReportsAMangledLine: a corrupt line that is not the log's +// final line must not be silently dropped by Entries the way Runs drops it +// - PlanUndo needs to know a step went missing so it can refuse the whole +// run rather than half-undo a file (spec §10). +func TestEntriesReportsAMangledLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + // Mangle line 2 (the move step) in place: corrupt its Step column so it + // fails to parse, without touching the line or column count of the + // file otherwise - the point is a bad line in the middle, not at EOF. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("fixture has %d lines, want 3", len(lines)) + } + fields := strings.Split(lines[1], "\t") + fields[4] = "not-a-number" // the step column + lines[1] = strings.Join(fields, "\t") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err == nil { + t.Fatal("Entries did not report the mangled line") + } + if !strings.Contains(err.Error(), "line 2") { + t.Errorf("error %q does not name line 2", err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want the 2 surviving (run-start, run-end): %+v", len(got), got) + } + if got[0].Action != "run-start" || got[1].Action != "run-end" { + t.Errorf("entries = %+v", got) + } +} + +// TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow: when a +// line's own Run column is destroyed, Entries cannot attribute it by +// content - but if it falls inside runID's own window (between its +// run-start and run-end), that possibility alone must be enough to refuse +// rather than silently return an incomplete chain (spec §10). +func TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + // Insert a line with no tabs at all - its Run column is unrecoverable - + // between the move step and run-end, i.e. inside A's window. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("fixture has %d lines, want 3", len(lines)) + } + inserted := make([]string, 0, len(lines)+1) + inserted = append(inserted, lines[:2]...) + inserted = append(inserted, "totally-mangled-no-tabs-here") + inserted = append(inserted, lines[2:]...) + if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err == nil { + t.Fatal("Entries did not fail closed on unattributable corruption inside the run's window") + } + if !strings.Contains(err.Error(), "line 3") { + t.Errorf("error %q does not name line 3", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries, want the 3 surviving (run-start, move, run-end): %+v", len(got), got) + } +} + +// TestEntriesIgnoresUnattributableCorruptionOutsideWindow: the same +// corruption shape, placed after A's run-end inside a later run B's own +// window, must not poison A - the window scoping keeps it out. +func TestEntriesIgnoresUnattributableCorruptionOutsideWindow(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + // Insert the same unattributable corruption, now inside B's window + // (between B's run-start and run-end), not A's. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 5 { + t.Fatalf("fixture has %d lines, want 5", len(lines)) + } + inserted := make([]string, 0, len(lines)+1) + inserted = append(inserted, lines[:4]...) + inserted = append(inserted, "totally-mangled-no-tabs-here") + inserted = append(inserted, lines[4:]...) + if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err != nil { + t.Fatalf("corruption outside A's window poisoned A: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries, want A's 3: %+v", len(got), got) + } +} + +// TestEntriesFailsClosedOnMissingRunStart: run-start is not an optional +// marker - every run Apply writes begins with one, so its absence, once +// other entries for the run did parse, means either corruption or a log +// truncated at the front. Either way the chain cannot be trusted, even +// though the window logic alone sees nothing wrong (it never opens without +// a parsed run-start, so it never flags anything inside the gap). +func TestEntriesFailsClosedOnMissingRunStart(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + // Replace A's run-start line (line 1) with a line with no tabs at all - + // unrecoverable, like the round-1 fixtures. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("fixture has %d lines, want 3", len(lines)) + } + lines[0] = "totally-mangled-no-tabs-here" + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err == nil { + t.Fatal("Entries did not fail closed on a missing run-start") + } + if !strings.Contains(err.Error(), "run-start") { + t.Errorf("error %q does not name the missing run-start", err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want the 2 surviving (move, run-end): %+v", len(got), got) + } + if got[0].Action != "move" || got[1].Action != "run-end" { + t.Errorf("entries = %+v", got) + } +} |
