Files
mg4/server/logs_test.go
T
nilsandClaude Opus 5.5 eebe840b6a App 2.41.0, server 2.9.0: recordings reach the server
A finished signal recording goes to an outbox and is sent gzipped with
the car's token (PUT /v1/logs/{name}); the service retries every 2
minutes while the car is offline. The server keeps the newest 200 on a
new volume (LOGS_DIR); reading takes a token of its own (LOGS_TOKEN).
fetch-logs in the dev shell fetches new ones into logs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 10:22:40 +01:00

213 lines
6.7 KiB
Go

package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const testLogsToken = "logs-read-token-0123456789abcdef"
func newLogsAPI(t *testing.T) (*API, string) {
t.Helper()
api, _ := newTestAPI()
dir := t.TempDir()
api.Logs = NewLogs(dir, testLogsToken)
return api, dir
}
func gzipped(t *testing.T, text string) []byte {
t.Helper()
var b bytes.Buffer
zw := gzip.NewWriter(&b)
if _, err := zw.Write([]byte(text)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func putLog(t *testing.T, h http.Handler, name, token string, body []byte, encoding string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("PUT", "/v1/logs/"+name, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
if encoding != "" {
req.Header.Set("Content-Encoding", encoding)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestTheCarSendsALogAndTheOwnerReadsIt(t *testing.T) {
api, _ := newLogsAPI(t)
h := api.Routes()
const name = "signal-recording-20261027-085240.txt"
text := "Signal recording, 37 captures\nlistening to 108 assistance properties\n"
if rec := putLog(t, h, name, testToken, gzipped(t, text), "gzip"); rec.Code != http.StatusNoContent {
t.Fatalf("put: %d %s", rec.Code, rec.Body)
}
rec := do(t, h, "GET", "/v1/logs", testLogsToken, "")
if rec.Code != 200 {
t.Fatalf("list: %d %s", rec.Code, rec.Body)
}
var list struct {
Logs []logEntry `json:"logs"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Logs) != 1 || list.Logs[0].Name != name ||
list.Logs[0].Bytes != int64(len(text)) {
t.Fatalf("list: %v %s", err, rec.Body)
}
rec = do(t, h, "GET", "/v1/logs/"+name, testLogsToken, "")
if rec.Code != 200 || rec.Body.String() != text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("get: %d %q", rec.Code, rec.Body)
}
// Sent again (a retry), it replaces itself.
if rec := putLog(t, h, name, testToken, []byte("again\n"), ""); rec.Code != http.StatusNoContent {
t.Fatalf("put again: %d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/logs/"+name, testLogsToken, ""); rec.Body.String() != "again\n" {
t.Fatalf("replaced: %q", rec.Body)
}
}
func TestLogTokensDoNotCross(t *testing.T) {
api, _ := newLogsAPI(t)
h := api.Routes()
name := "signal-recording-1.txt"
if rec := putLog(t, h, name, testLogsToken, []byte("x"), ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("the read token wrote: %d", rec.Code)
}
if rec := putLog(t, h, name, testToken, []byte("x"), ""); rec.Code != http.StatusNoContent {
t.Fatalf("put: %d", rec.Code)
}
for _, path := range []string{"/v1/logs", "/v1/logs/" + name} {
for _, token := range []string{"", testToken, testLogsToken + "x"} {
if rec := do(t, h, "GET", path, token, ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("GET %s with %q: %d", path, token, rec.Code)
}
}
}
}
func TestReadingIsOffWithoutALogsToken(t *testing.T) {
api, _ := newLogsAPI(t)
api.Logs.ReadToken = ""
h := api.Routes()
if rec := putLog(t, h, "a.txt", testToken, []byte("x"), ""); rec.Code != http.StatusNoContent {
t.Fatalf("put: %d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/logs", "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("list: %d", rec.Code)
}
}
func TestBadLogsAreRefused(t *testing.T) {
api, dir := newLogsAPI(t)
h := api.Routes()
for _, c := range []struct {
name, encoding string
body []byte
want int
}{
{"..%2Fescape.txt", "", []byte("x"), http.StatusBadRequest},
{"Upper.txt", "", []byte("x"), http.StatusBadRequest},
{"no-suffix", "", []byte("x"), http.StatusBadRequest},
{".hidden.txt", "", []byte("x"), http.StatusBadRequest},
{"a.txt", "gzip", []byte("not gzip"), http.StatusBadRequest},
{"a.txt", "br", []byte("x"), http.StatusUnsupportedMediaType},
{"a.txt", "", []byte{0xff, 0xfe, 0x00}, http.StatusBadRequest},
{"a.txt", "", make([]byte, maxLogWireBytes+1), http.StatusRequestEntityTooLarge},
{"a.txt", "gzip", gzipped(t, strings.Repeat("a", maxLogBytes+1)), http.StatusRequestEntityTooLarge},
} {
if rec := putLog(t, h, c.name, testToken, c.body, c.encoding); rec.Code != c.want {
t.Errorf("%s %s: %d, want %d (%s)", c.name, c.encoding, rec.Code, c.want, rec.Body)
}
}
if files, _ := os.ReadDir(dir); len(files) != 0 {
t.Fatalf("stored: %v", files)
}
if rec := do(t, h, "GET", "/v1/logs/missing.txt", testLogsToken, ""); rec.Code != http.StatusNotFound {
t.Fatalf("missing: %d", rec.Code)
}
}
func TestOldAndSurplusLogsArePruned(t *testing.T) {
dir := t.TempDir()
l := NewLogs(dir, testLogsToken)
// The new file gets the real time as its own, so the others are placed before it.
now := time.Now()
old := filepath.Join(dir, "old.txt")
if err := os.WriteFile(old, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(old, now.Add(-maxLogAge-time.Hour), now.Add(-maxLogAge-time.Hour)); err != nil {
t.Fatal(err)
}
for i := 0; i < maxLogFiles+5; i++ {
p := filepath.Join(dir, fmt.Sprintf("f-%03d.txt", i))
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
at := now.Add(-time.Duration(maxLogFiles+5-i) * time.Minute)
if err := os.Chtimes(p, at, at); err != nil {
t.Fatal(err)
}
}
if err := l.save("new.txt", []byte("y")); err != nil {
t.Fatal(err)
}
entries, _ := l.entries()
if len(entries) != maxLogFiles || entries[0].Name != "new.txt" {
t.Fatalf("%d kept, first %s", len(entries), entries[0].Name)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatal("the old one is still there")
}
if _, err := os.Stat(filepath.Join(dir, "f-000.txt")); !os.IsNotExist(err) {
t.Fatal("the oldest surplus one is still there")
}
}
func TestLogsConfig(t *testing.T) {
base := map[string]string{"INFLUX_URL": "http://i", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", "INGEST_TOKEN": testToken}
env := func(extra map[string]string) func(string) string {
return func(k string) string {
if v, ok := extra[k]; ok {
return v
}
return base[k]
}
}
for _, c := range []struct {
extra map[string]string
ok bool
}{
{map[string]string{}, true},
{map[string]string{"LOGS_DIR": "/l"}, true},
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": testLogsToken}, true},
{map[string]string{"LOGS_TOKEN": testLogsToken}, false},
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": "short"}, false},
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": testToken}, false},
} {
if _, err := loadConfig(env(c.extra)); (err == nil) != c.ok {
t.Errorf("%v: %v", c.extra, err)
}
}
}