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