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) } } }