Files
privatae-bastion/client-go/auditlog_test.go
T
Privatae d62da8adb4
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s
bastion 0.54.0
2026-10-21 22:29:10 +00:00

155 lines
5.3 KiB
Go

package main
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// The test binary never writes to the real system log: the suite's audit
// rows would otherwise land in the journal of whatever machine runs it.
func init() { auditSink = func([]byte) {} }
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
func auditSinkCapture(t *testing.T) func() []map[string]any {
t.Helper()
var mu sync.Mutex
var lines [][]byte
prev := auditSink
auditSink = func(line []byte) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, append([]byte(nil), line...))
}
t.Cleanup(func() { auditSink = prev })
return func() []map[string]any {
mu.Lock()
defer mu.Unlock()
var out []map[string]any
for _, l := range lines {
m := map[string]any{}
if err := json.Unmarshal(l, &m); err != nil {
t.Fatalf("system-log line is not JSON: %s", l)
}
out = append(out, m)
}
return out
}
}
func auditFileRows(t *testing.T, path string) []map[string]any {
t.Helper()
fh, err := os.Open(path)
if err != nil {
t.Fatalf("audit file %s: %v", path, err)
}
defer fh.Close()
var out []map[string]any
sc := bufio.NewScanner(fh)
for sc.Scan() {
m := map[string]any{}
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
t.Fatalf("audit row is not JSON: %s", sc.Text())
}
out = append(out, m)
}
return out
}
// auditSameRow: the system-log line is the file's row, every field equal,
// with `audit` and `pairing` added.
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
t.Helper()
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
}
for k, v := range file {
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
}
}
if len(logged) != len(file)+2 {
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
}
}
func jsonEq(a, b any) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
// it writes to its file to the system log as well, unchanged, naming the file
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
// terminals and root commands.
func TestAuditRowsReachTheSystemLog(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
got := auditSinkCapture(t)
cfgDir, err := configDir()
if err != nil {
t.Fatal(err)
}
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
pairingAudit("pairing_add", "other.host.example", "ok")
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
lines := got()
if len(lines) != 5 {
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
}
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
}
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
}
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
// costs the copy only; the row is still written to its file.
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
prev := auditSink
auditSink = func([]byte) {}
t.Cleanup(func() { auditSink = prev })
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
t.Fatalf("file rows = %d, want 1", len(rows))
}
}
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
// the digits the file has, not as a float.
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
t.Fatalf("system-log line %s does not carry the id exactly", line)
}
}