Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }