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
213 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|