package daemon import ( "bytes" "context" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) type syncBuffer struct { mu sync.Mutex b bytes.Buffer } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() return s.b.String() } func testHandler(t *testing.T, statsDir string) http.Handler { t.Helper() h, err := NewHandler(statsDir) if err != nil { t.Fatal(err) } return h } func TestNewHandlerOpenAuthStoreFails(t *testing.T) { dir := t.TempDir() if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(dir, 0o755) }() _, err := NewHandler(dir) if err == nil { t.Fatal("expected error when auth db cannot be created") } } func TestHealth(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/health") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestHealthMethodNotAllowed(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/health", nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status %d", res.StatusCode) } } func TestLivez(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/livez") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestRootHTML(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(testHandler(t, fixtures)) defer srv.Close() res, err := http.Get(srv.URL + "/") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } ct := res.Header.Get("Content-Type") if !strings.HasPrefix(ct, "text/html") { t.Fatalf("content type %q", ct) } body, _ := io.ReadAll(res.Body) if !strings.Contains(string(body), "") { t.Fatalf("expected html body, got %q", string(body)) } } func TestRootCacheReuseAndExpiry(t *testing.T) { cache := &htmlCache{} nowTime := time.Unix(1000, 0) now := func() time.Time { return nowTime } renderCalls := 0 h := rootWithCachedRenderer(cache, now, 10*time.Minute, func(context.Context) ([]byte, error) { renderCalls++ return []byte(fmt.Sprintf("%d", renderCalls)), nil }) req := httptest.NewRequest(http.MethodGet, "http://example/", nil) w := httptest.NewRecorder() h(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d", w.Code) } first := w.Body.String() if renderCalls != 1 { t.Fatalf("render calls %d want 1", renderCalls) } w2 := httptest.NewRecorder() h(w2, req) if w2.Code != http.StatusOK { t.Fatalf("status %d", w2.Code) } if w2.Body.String() != first { t.Fatalf("cached body mismatch: %q != %q", w2.Body.String(), first) } if renderCalls != 1 { t.Fatalf("render calls %d want 1", renderCalls) } nowTime = nowTime.Add(11 * time.Minute) w3 := httptest.NewRecorder() h(w3, req) if w3.Code != http.StatusOK { t.Fatalf("status %d", w3.Code) } if renderCalls != 2 { t.Fatalf("render calls %d want 2", renderCalls) } } func TestRootNotFoundForUnknownPath(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(testHandler(t, fixtures)) defer srv.Close() res, err := http.Get(srv.URL + "/no-such-route") if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusNotFound { t.Fatalf("status %d", res.StatusCode) } } func TestReadyzOK(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/readyz") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestReadyzMethodNotAllowed(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/readyz", nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status %d", res.StatusCode) } } func TestReadyzMissingStatsDir(t *testing.T) { statsDir := filepath.Join(t.TempDir(), "absent") srv := httptest.NewServer(readiness(statsDir, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzStatsDirNotDirectory(t *testing.T) { f := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { t.Fatal(err) } srv := httptest.NewServer(readiness(f, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzStatsDirNotWritable(t *testing.T) { dir := t.TempDir() if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(dir, 0o755) }() srv := httptest.NewServer(readiness(dir, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzAuthDBDirNotWritable(t *testing.T) { statsDir := t.TempDir() authDir := t.TempDir() authDB := filepath.Join(authDir, "auth.db") if err := os.Chmod(authDir, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(authDir, 0o755) }() srv := httptest.NewServer(readiness(statsDir, authDB)) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReportHTTPTable(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(testHandler(t, fixtures)) defer srv.Close() tests := []struct { name string query string wantCode int wantCTPfx string bodyNeedle []string }{ { name: "plaintext", query: "category=Host&metric=Boots&limit=3&output-format=Plaintext", wantCode: http.StatusOK, wantCTPfx: "text/plain", bodyNeedle: []string{"Host"}, }, { name: "markdown aliases", query: "Category=Host&Metric=Uptime&limit=2&OutputFormat=Markdown", wantCode: http.StatusOK, wantCTPfx: "text/markdown", bodyNeedle: []string{"# Top", "```"}, }, { name: "html", query: "OutputFormat=HTML&limit=2", wantCode: http.StatusOK, wantCTPfx: "text/html", bodyNeedle: []string{"", ""}, }, { name: "gemtext", query: "output-format=Gemtext&limit=2", wantCode: http.StatusOK, wantCTPfx: "text/gemini", }, { name: "bad category", query: "category=Nope", wantCode: http.StatusBadRequest, }, { name: "invalid limit", query: "limit=notnum", wantCode: http.StatusBadRequest, }, { name: "invalid all bool", query: "all=nope", wantCode: http.StatusBadRequest, }, { name: "downtime on non host", query: "category=Kernel&metric=Downtime&limit=2", wantCode: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { res, err := http.Get(srv.URL + "/report?" + tt.query) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != tt.wantCode { t.Fatalf("status %d want %d", res.StatusCode, tt.wantCode) } if tt.wantCTPfx != "" { ct := res.Header.Get("Content-Type") if !strings.HasPrefix(ct, tt.wantCTPfx) { t.Fatalf("Content-Type %q want prefix %q", ct, tt.wantCTPfx) } } b, _ := io.ReadAll(res.Body) body := string(b) for _, sub := range tt.bodyNeedle { if !strings.Contains(body, sub) { t.Fatalf("body missing %q: %q", sub, body) } } }) } } func TestReportMethodNotAllowed(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(testHandler(t, fixtures)) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/report?limit=2", nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status %d", res.StatusCode) } } func TestReportAggregateFailure(t *testing.T) { dir := t.TempDir() line := "1:1:Linux 5.13.14-200.fc34.x86_64\n" if err := os.WriteFile(filepath.Join(dir, "dup.x.records"), []byte(line), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "dup.y.records"), []byte(line), 0o644); err != nil { t.Fatal(err) } srv := httptest.NewServer(testHandler(t, dir)) defer srv.Close() res, err := http.Get(srv.URL + "/report?limit=2") if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusInternalServerError { t.Fatalf("status %d want 500", res.StatusCode) } } func TestRunEmptyStatsDir(t *testing.T) { err := Run(context.Background(), Config{StatsDir: "", Addr: ":0"}) if err == nil { t.Fatal("expected error") } } func TestRunEmptyAddr(t *testing.T) { err := Run(context.Background(), Config{StatsDir: t.TempDir(), Addr: ""}) if err == nil { t.Fatal("expected error") } } func TestNewDaemonHTTPServerTimeouts(t *testing.T) { s := newDaemonHTTPServer("127.0.0.1:0", http.NotFoundHandler(), nil) if g, w := s.ReadHeaderTimeout, 10*time.Second; g != w { t.Fatalf("ReadHeaderTimeout %v want %v", g, w) } if g, w := s.ReadTimeout, 2*time.Minute; g != w { t.Fatalf("ReadTimeout %v want %v", g, w) } if g, w := s.WriteTimeout, 2*time.Minute; g != w { t.Fatalf("WriteTimeout %v want %v", g, w) } if g, w := s.IdleTimeout, 2*time.Minute; g != w { t.Fatalf("IdleTimeout %v want %v", g, w) } } func TestRunWritesDaemonListenToLogOutput(t *testing.T) { var buf syncBuffer ctx, cancel := context.WithCancel(context.Background()) cfg := Config{StatsDir: t.TempDir(), Addr: "127.0.0.1:0", LogOutput: &buf} done := make(chan struct{}) go func() { _ = Run(ctx, cfg) close(done) }() deadline := time.After(2 * time.Second) for !strings.Contains(buf.String(), "daemon_listen") { select { case <-deadline: t.Fatalf("timeout waiting for daemon_listen, got %q", buf.String()) case <-time.After(5 * time.Millisecond): } } cancel() <-done } func TestRunUsesStdoutWhenLogOutputNil(t *testing.T) { old := os.Stdout pr, pw, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdout = pw var logBuf syncBuffer copyDone := make(chan struct{}) go func() { _, _ = io.Copy(&logBuf, pr) close(copyDone) }() ctx, cancel := context.WithCancel(context.Background()) cfg := Config{StatsDir: t.TempDir(), Addr: "127.0.0.1:0", LogOutput: nil} runDone := make(chan struct{}) go func() { _ = Run(ctx, cfg) close(runDone) }() deadline := time.After(2 * time.Second) for !strings.Contains(logBuf.String(), "daemon_listen") { select { case <-deadline: cancel() _ = pw.Close() <-runDone <-copyDone _ = pr.Close() os.Stdout = old t.Fatalf("timeout, got %q", logBuf.String()) case <-time.After(5 * time.Millisecond): } } cancel() <-runDone os.Stdout = old if err := pw.Close(); err != nil { t.Fatal(err) } <-copyDone if err := pr.Close(); err != nil { t.Fatal(err) } } func TestRunInvalidListenAddress(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := Run(ctx, Config{StatsDir: t.TempDir(), Addr: ":999999999"}) if err == nil { t.Fatal("expected listen error") } if !strings.Contains(err.Error(), "listen") { t.Fatalf("expected listen in error: %v", err) } } func TestAccessLogImplicitOKStatus(t *testing.T) { var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) mux := http.NewServeMux() mux.HandleFunc("/nohdr", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) srv := httptest.NewServer(withAccessLog(log, mux)) defer srv.Close() res, err := http.Get(srv.URL + "/nohdr") if err != nil { t.Fatal(err) } res.Body.Close() if !strings.Contains(buf.String(), "status=200") { t.Fatalf("log %q", buf.String()) } } func TestAccessLogLineToWriter(t *testing.T) { var buf bytes.Buffer h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) log := slog.New(h) statsDir := t.TempDir() store, err := openAuthStore(context.Background(), statsDir, "") if err != nil { t.Fatal(err) } defer store.Close() srv := httptest.NewServer(withAccessLog(log, routes(statsDir, "", store))) defer srv.Close() res, err := http.Get(srv.URL + "/health") if err != nil { t.Fatal(err) } res.Body.Close() body := buf.String() if !strings.Contains(body, "http_request") || !strings.Contains(body, "method=GET") { t.Fatalf("expected http_request line with method=GET, got %q", body) } if !strings.Contains(body, "path=/health") || !strings.Contains(body, "status=200") { t.Fatalf("expected path and status in log, got %q", body) } } func TestUploadOpenWhenNoKeys(t *testing.T) { statsDir := t.TempDir() srv := httptest.NewServer(testHandler(t, statsDir)) defer srv.Close() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/txt", strings.NewReader("hello")) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusNoContent { t.Fatalf("status %d", res.StatusCode) } b, err := os.ReadFile(filepath.Join(statsDir, "myhost.txt")) if err != nil { t.Fatal(err) } if string(b) != "hello" { t.Fatalf("file %q", b) } } func TestUploadRequiresBearerWhenKeysExist(t *testing.T) { statsDir := t.TempDir() ctx := context.Background() store, err := openAuthStore(ctx, statsDir, "") if err != nil { t.Fatal(err) } defer store.Close() if _, err := store.CreateKey(ctx, "myhost"); err != nil { t.Fatal(err) } srv := httptest.NewServer(routes(statsDir, "", store)) defer srv.Close() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/txt", strings.NewReader("x")) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusUnauthorized { t.Fatalf("status %d want 401", res.StatusCode) } } func TestUploadWithValidBearer(t *testing.T) { statsDir := t.TempDir() ctx := context.Background() store, err := openAuthStore(ctx, statsDir, "") if err != nil { t.Fatal(err) } defer store.Close() tok, err := store.CreateKey(ctx, "myhost") if err != nil { t.Fatal(err) } srv := httptest.NewServer(routes(statsDir, "", store)) defer srv.Close() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/os.txt", strings.NewReader("os")) req.Header.Set("Authorization", "Bearer "+tok) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusNoContent { t.Fatalf("status %d", res.StatusCode) } } func TestUploadWrongHostForbidden(t *testing.T) { statsDir := t.TempDir() ctx := context.Background() store, err := openAuthStore(ctx, statsDir, "") if err != nil { t.Fatal(err) } defer store.Close() tok, err := store.CreateKey(ctx, "myhost") if err != nil { t.Fatal(err) } srv := httptest.NewServer(routes(statsDir, "", store)) defer srv.Close() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/other/txt", strings.NewReader("x")) req.Header.Set("Authorization", "Bearer "+tok) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Fatalf("status %d want 403", res.StatusCode) } } func TestUploadBadKind(t *testing.T) { statsDir := t.TempDir() srv := httptest.NewServer(testHandler(t, statsDir)) defer srv.Close() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/nope", strings.NewReader("x")) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusBadRequest { t.Fatalf("status %d", res.StatusCode) } } func TestUploadAllKindsWriteExpectedFiles(t *testing.T) { statsDir := t.TempDir() srv := httptest.NewServer(testHandler(t, statsDir)) defer srv.Close() cases := []struct { kind string wantName string body string }{ {"txt", "myhost.txt", "a"}, {"cur.txt", "myhost.cur.txt", "b"}, {"records", "myhost.records", "c"}, {"os.txt", "myhost.os.txt", "d"}, {"cpuinfo.txt", "myhost.cpuinfo.txt", "e"}, } for _, tc := range cases { t.Run(tc.kind, func(t *testing.T) { url := srv.URL + "/upload/myhost/" + tc.kind req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader(tc.body)) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusNoContent { t.Fatalf("status %d", res.StatusCode) } b, err := os.ReadFile(filepath.Join(statsDir, tc.wantName)) if err != nil { t.Fatal(err) } if string(b) != tc.body { t.Fatalf("file %s: got %q want %q", tc.wantName, b, tc.body) } }) } }