vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add one Admin page with status, accounts, and the audit log
43e681d8a5d94567aa9530d32b2c89223c1a17f9 · Keenan Nemetz · 5d ago · parent af018ce8
Add one Admin page with status, accounts, and the audit log Instance administration was split between the Status and Accounts pages, and the audit log had no browser view at all. The new /admin page gathers the status tabs, the account roster, and a read-only audit viewer into one place with a tab for each, and /status, /runners, and /users redirect to the tab they named so old links keep working. The audit reader walks the file backwards a chunk at a time, returns the newest records first, and shows one extra record to know whether an older page exists. It skips a line it cannot parse rather than failing the page and refuses a symlink or a non-regular file, as startup already does. Refs #198 (01M6MK0NNZ2XR1D5TQKNWYT4HN).
internal/audit/read_test.go +183
| @@ -0,0 +1,183 @@ | |||
| 1 | package audit | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func writeLog(t *testing.T, path string, records []Record) { | ||
| 13 | t.Helper() | ||
| 14 | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) | ||
| 15 | if err != nil { | ||
| 16 | t.Fatal(err) | ||
| 17 | } | ||
| 18 | defer f.Close() | ||
| 19 | at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | ||
| 20 | for i, r := range records { | ||
| 21 | line := Format(r, at.Add(time.Duration(i)*time.Second)) | ||
| 22 | if _, err := f.WriteString(line); err != nil { | ||
| 23 | t.Fatal(err) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | func records(n int) []Record { | ||
| 29 | out := make([]Record, 0, n) | ||
| 30 | for i := 0; i < n; i++ { | ||
| 31 | out = append(out, Record{ | ||
| 32 | Action: fmt.Sprintf("action.%d", i), | ||
| 33 | Actor: fmt.Sprintf("actor-%d", i), | ||
| 34 | Subject: "demo", | ||
| 35 | Detail: fmt.Sprintf("detail %d", i), | ||
| 36 | }) | ||
| 37 | } | ||
| 38 | return out | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestParseRoundTripsFormat(t *testing.T) { | ||
| 42 | at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) | ||
| 43 | line := Format(Record{Action: "acl.update", Actor: "keenan", Subject: "vole", Detail: "role=admin"}, at) | ||
| 44 | e, err := Parse(strings.TrimSuffix(line, "\n")) | ||
| 45 | if err != nil { | ||
| 46 | t.Fatal(err) | ||
| 47 | } | ||
| 48 | if !e.Time.Equal(at) || e.Action != "acl.update" || e.Actor != "keenan" || | ||
| 49 | e.Subject != "vole" || e.Detail != "role=admin" { | ||
| 50 | t.Fatalf("Parse() = %+v", e) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | func TestParseRejectsBrokenLines(t *testing.T) { | ||
| 55 | for _, line := range []string{ | ||
| 56 | "", | ||
| 57 | "2026-01-01T00:00:00Z\tonly.three\tcolumns", | ||
| 58 | "not-a-time\taction\t-\t-\tdetail", | ||
| 59 | } { | ||
| 60 | if _, err := Parse(line); err == nil { | ||
| 61 | t.Errorf("Parse(%q) succeeded, want an error", line) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | func TestTailPagesNewestFirst(t *testing.T) { | ||
| 67 | path := filepath.Join(t.TempDir(), "audit.log") | ||
| 68 | writeLog(t, path, records(5)) | ||
| 69 | |||
| 70 | cases := []struct { | ||
| 71 | offset, limit int | ||
| 72 | wantActions []string | ||
| 73 | wantMore bool | ||
| 74 | }{ | ||
| 75 | {0, 2, []string{"action.4", "action.3"}, true}, | ||
| 76 | {2, 2, []string{"action.2", "action.1"}, true}, | ||
| 77 | {4, 2, []string{"action.0"}, false}, | ||
| 78 | {5, 2, nil, false}, | ||
| 79 | {0, 10, []string{"action.4", "action.3", "action.2", "action.1", "action.0"}, false}, | ||
| 80 | } | ||
| 81 | for _, c := range cases { | ||
| 82 | got, more, err := Tail(path, c.offset, c.limit) | ||
| 83 | if err != nil { | ||
| 84 | t.Fatalf("Tail(%d, %d): %v", c.offset, c.limit, err) | ||
| 85 | } | ||
| 86 | if more != c.wantMore { | ||
| 87 | t.Errorf("Tail(%d, %d) more = %v, want %v", c.offset, c.limit, more, c.wantMore) | ||
| 88 | } | ||
| 89 | var actions []string | ||
| 90 | for _, e := range got { | ||
| 91 | actions = append(actions, e.Action) | ||
| 92 | } | ||
| 93 | if strings.Join(actions, ",") != strings.Join(c.wantActions, ",") { | ||
| 94 | t.Errorf("Tail(%d, %d) = %v, want %v", c.offset, c.limit, actions, c.wantActions) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | // A line the reader cannot parse must not hide the records around it. | ||
| 100 | func TestTailSkipsMalformedLines(t *testing.T) { | ||
| 101 | path := filepath.Join(t.TempDir(), "audit.log") | ||
| 102 | writeLog(t, path, records(3)) | ||
| 103 | f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) | ||
| 104 | if err != nil { | ||
| 105 | t.Fatal(err) | ||
| 106 | } | ||
| 107 | if _, err := f.WriteString("garbage without columns\n"); err != nil { | ||
| 108 | t.Fatal(err) | ||
| 109 | } | ||
| 110 | f.Close() | ||
| 111 | |||
| 112 | got, _, err := Tail(path, 0, 10) | ||
| 113 | if err != nil { | ||
| 114 | t.Fatal(err) | ||
| 115 | } | ||
| 116 | if len(got) != 3 || got[0].Action != "action.2" || got[2].Action != "action.0" { | ||
| 117 | t.Fatalf("Tail() = %+v", got) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | // The writer always terminates a line, but a hand-edited or legacy file may not. | ||
| 122 | func TestTailReadsAnUnterminatedLastLine(t *testing.T) { | ||
| 123 | path := filepath.Join(t.TempDir(), "audit.log") | ||
| 124 | at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | ||
| 125 | line := strings.TrimSuffix(Format(Record{Action: "push.force", Actor: "keenan", Subject: "demo"}, at), "\n") | ||
| 126 | if err := os.WriteFile(path, []byte(line), 0o600); err != nil { | ||
| 127 | t.Fatal(err) | ||
| 128 | } | ||
| 129 | got, more, err := Tail(path, 0, 10) | ||
| 130 | if err != nil || more { | ||
| 131 | t.Fatalf("Tail() = %v, %v", more, err) | ||
| 132 | } | ||
| 133 | if len(got) != 1 || got[0].Action != "push.force" { | ||
| 134 | t.Fatalf("Tail() = %+v", got) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | func TestTailMissingFileIsEmpty(t *testing.T) { | ||
| 139 | got, more, err := Tail(filepath.Join(t.TempDir(), "missing.log"), 0, 10) | ||
| 140 | if err != nil || more || len(got) != 0 { | ||
| 141 | t.Fatalf("Tail() = %+v, %v, %v", got, more, err) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | // A record larger than one read chunk exercises the pending-line path, and a | ||
| 146 | // deep page proves the arithmetic holds across many chunks. | ||
| 147 | func TestTailPagesAcrossChunks(t *testing.T) { | ||
| 148 | path := filepath.Join(t.TempDir(), "audit.log") | ||
| 149 | const n = 2000 | ||
| 150 | writeLog(t, path, records(n)) | ||
| 151 | |||
| 152 | got, more, err := Tail(path, n-3, 2) | ||
| 153 | if err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | if !more { | ||
| 157 | t.Fatal("a page before the last record should report an older page") | ||
| 158 | } | ||
| 159 | if len(got) != 2 || got[0].Action != "action.2" || got[1].Action != "action.1" { | ||
| 160 | t.Fatalf("deep page = %+v", got) | ||
| 161 | } | ||
| 162 | |||
| 163 | last, more, err := Tail(path, n-1, 5) | ||
| 164 | if err != nil { | ||
| 165 | t.Fatal(err) | ||
| 166 | } | ||
| 167 | if more || len(last) != 1 || last[0].Action != "action.0" { | ||
| 168 | t.Fatalf("last page = %+v, more=%v, err=%v", last, more, err) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | func TestTailRefusesASymlink(t *testing.T) { | ||
| 173 | dir := t.TempDir() | ||
| 174 | target := filepath.Join(dir, "real.log") | ||
| 175 | writeLog(t, target, records(1)) | ||
| 176 | link := filepath.Join(dir, "audit.log") | ||
| 177 | if err := os.Symlink(target, link); err != nil { | ||
| 178 | t.Skipf("symlink unavailable: %v", err) | ||
| 179 | } | ||
| 180 | if _, _, err := Tail(link, 0, 10); err == nil { | ||
| 181 | t.Fatal("Tail() followed a symlink") | ||
| 182 | } | ||
| 183 | } | ||