From 74e1b8f37d318112d52e5a80f0f589f1b0cc7f34 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 14 Apr 2026 10:27:29 +0300 Subject: test: microservice coverage for task 63 Add table-driven HTTP and unit tests for report (all formats, negatives), upload/auth boundaries, upload helpers, readiness, Run and logging. Extend authkeys tests for Close, CreateKey validation, and post-close errors. Add CLI tests for defaultListenFromEnv and create-client-key with -auth-db only. Add mage CoverMicroservice for local/CI-style coverage measurement. Use context.Background and os.Chdir for Go 1.21-compatible tests. Made-with: Cursor --- internal/daemon/daemon_test.go | 244 +++++++++++++++++++++++++++++------------ internal/daemon/upload_test.go | 239 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 72 deletions(-) create mode 100644 internal/daemon/upload_test.go (limited to 'internal/daemon') diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index e92359b..143ec3a 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -162,102 +162,125 @@ func TestReadyzAuthDBDirNotWritable(t *testing.T) { } } -func TestReport(t *testing.T) { - fixtures := filepath.Join("..", "..", "fixtures") - h := Handler(fixtures) - srv := httptest.NewServer(h) - defer srv.Close() - res, err := http.Get(srv.URL + "/report?category=Host&metric=Boots&limit=3&output-format=Plaintext") - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("status %d", res.StatusCode) - } - if ct := res.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" { - t.Fatalf("Content-Type %q", ct) - } - b, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(b), "Host") { - t.Fatalf("expected report body, got %q", b) - } -} - -func TestReportQueryAliases(t *testing.T) { +func TestReportHTTPTable(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(Handler(fixtures)) defer srv.Close() - res, err := http.Get(srv.URL + "/report?Category=Host&Metric=Uptime&limit=2&OutputFormat=Markdown") - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("status %d", res.StatusCode) - } - if ct := res.Header.Get("Content-Type"); ct != "text/markdown; charset=utf-8" { - t.Fatalf("Content-Type %q", ct) - } - b, _ := io.ReadAll(res.Body) - body := string(b) - if !strings.Contains(body, "# Top") || !strings.Contains(body, "```") { - t.Fatalf("expected markdown heading and fence, got %q", b) + 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 TestReportHTMLContentType(t *testing.T) {
+func TestReportMethodNotAllowed(t *testing.T) {
 	fixtures := filepath.Join("..", "..", "fixtures")
 	srv := httptest.NewServer(Handler(fixtures))
 	defer srv.Close()
-	res, err := http.Get(srv.URL + "/report?OutputFormat=HTML&limit=2")
+	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/report?limit=2", nil)
+	res, err := http.DefaultClient.Do(req)
 	if err != nil {
 		t.Fatal(err)
 	}
-	defer res.Body.Close()
-	if res.StatusCode != http.StatusOK {
+	res.Body.Close()
+	if res.StatusCode != http.StatusMethodNotAllowed {
 		t.Fatalf("status %d", res.StatusCode)
 	}
-	if ct := res.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
-		t.Fatalf("Content-Type %q", ct)
-	}
-	b, _ := io.ReadAll(res.Body)
-	body := string(b)
-	if !strings.Contains(body, "") || !strings.Contains(body, "
") {
-		t.Fatalf("expected HTML body, got %q", body)
-	}
 }
 
-func TestReportGemtextContentType(t *testing.T) {
-	fixtures := filepath.Join("..", "..", "fixtures")
-	srv := httptest.NewServer(Handler(fixtures))
-	defer srv.Close()
-	res, err := http.Get(srv.URL + "/report?output-format=Gemtext&limit=2")
-	if err != nil {
+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)
 	}
-	defer res.Body.Close()
-	if res.StatusCode != http.StatusOK {
-		t.Fatalf("status %d", res.StatusCode)
-	}
-	if ct := res.Header.Get("Content-Type"); ct != "text/gemini; charset=utf-8" {
-		t.Fatalf("Content-Type %q", ct)
+	if err := os.WriteFile(filepath.Join(dir, "dup.y.records"), []byte(line), 0o644); err != nil {
+		t.Fatal(err)
 	}
-}
-
-func TestReportBadQuery(t *testing.T) {
-	srv := httptest.NewServer(Handler(t.TempDir()))
+	srv := httptest.NewServer(Handler(dir))
 	defer srv.Close()
-	res, err := http.Get(srv.URL + "/report?category=Nope")
+	res, err := http.Get(srv.URL + "/report?limit=2")
 	if err != nil {
 		t.Fatal(err)
 	}
 	res.Body.Close()
-	if res.StatusCode != http.StatusBadRequest {
-		t.Fatalf("status %d", res.StatusCode)
+	if res.StatusCode != http.StatusInternalServerError {
+		t.Fatalf("status %d want 500", res.StatusCode)
 	}
 }
 
@@ -296,6 +319,83 @@ func TestRunWritesDaemonListenToLogOutput(t *testing.T) {
 	<-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 bytes.Buffer
+	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})
diff --git a/internal/daemon/upload_test.go b/internal/daemon/upload_test.go
new file mode 100644
index 0000000..5a3755f
--- /dev/null
+++ b/internal/daemon/upload_test.go
@@ -0,0 +1,239 @@
+package daemon
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"testing/iotest"
+)
+
+func TestParseBearer(t *testing.T) {
+	tests := []struct {
+		name    string
+		header  string
+		wantTok string
+		wantOK  bool
+	}{
+		{name: "valid", header: "Bearer abc.def", wantTok: "abc.def", wantOK: true},
+		{name: "valid case", header: "bearer xyz", wantTok: "xyz", wantOK: true},
+		{name: "extra space", header: "Bearer   tok  ", wantTok: "tok", wantOK: true},
+		{name: "empty", header: "", wantOK: false},
+		{name: "whitespace only", header: "   ", wantOK: false},
+		{name: "too short", header: "Bear", wantOK: false},
+		{name: "wrong scheme", header: "Basic xxx", wantOK: false},
+		{name: "bearer no token", header: "Bearer ", wantOK: false},
+		{name: "bearer only spaces", header: "Bearer    ", wantOK: false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tok, ok := parseBearer(tt.header)
+			if ok != tt.wantOK || tok != tt.wantTok {
+				t.Fatalf("parseBearer(%q) = (%q, %v) want (%q, %v)", tt.header, tok, ok, tt.wantTok, tt.wantOK)
+			}
+		})
+	}
+}
+
+func TestParseUploadPath(t *testing.T) {
+	tests := []struct {
+		name     string
+		path     string
+		wantHost string
+		wantKind string
+		wantOK   bool
+	}{
+		{name: "ok txt", path: "/upload/host1/txt", wantHost: "host1", wantKind: "txt", wantOK: true},
+		{name: "ok nested kind", path: "/upload/my-host/cur.txt", wantHost: "my-host", wantKind: "cur.txt", wantOK: true},
+		{name: "no prefix", path: "/x/upload/h/k", wantOK: false},
+		{name: "empty rest", path: "/upload/", wantOK: false},
+		{name: "dotdot", path: "/upload/../x/txt", wantOK: false},
+		{name: "no slash", path: "/upload/only", wantOK: false},
+		{name: "slash end", path: "/upload/host/", wantOK: false},
+		{name: "bad host", path: "/upload/bad host/txt", wantOK: false},
+		{name: "kind slash", path: "/upload/h/a/b", wantOK: false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			h, k, ok := parseUploadPath(tt.path)
+			if ok != tt.wantOK {
+				t.Fatalf("ok=%v want %v (host=%q kind=%q)", ok, tt.wantOK, h, k)
+			}
+			if tt.wantOK && (h != tt.wantHost || k != tt.wantKind) {
+				t.Fatalf("host=%q kind=%q want %q %q", h, k, tt.wantHost, tt.wantKind)
+			}
+		})
+	}
+}
+
+func TestSafeHostSegment(t *testing.T) {
+	tests := []struct {
+		s    string
+		want bool
+	}{
+		{"a", true},
+		{"Host-1._x", true},
+		{"", false},
+		{"bad space", false},
+		{"bad:colon", false},
+		{strings.Repeat("a", 254), false},
+		{strings.Repeat("a", 253), true},
+	}
+	for _, tt := range tests {
+		if got := safeHostSegment(tt.s); got != tt.want {
+			t.Fatalf("safeHostSegment(%q)=%v want %v", tt.s, got, tt.want)
+		}
+	}
+}
+
+func TestFileUnderDir(t *testing.T) {
+	dir := t.TempDir()
+	inside := filepath.Join(dir, "f.txt")
+	if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	outside := filepath.Join(t.TempDir(), "escape.txt")
+	if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	tests := []struct {
+		name string
+		dir  string
+		file string
+		want bool
+	}{
+		{"inside", dir, inside, true},
+		{"same as dir", dir, dir, false},
+		{"parent escape", dir, outside, false},
+		{"dotdot file", dir, filepath.Join(dir, "..", filepath.Base(outside)), false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := fileUnderDir(tt.dir, tt.file); got != tt.want {
+				t.Fatalf("fileUnderDir(%q,%q)=%v want %v", tt.dir, tt.file, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestWriteUploadBodyTooLarge(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "big.txt")
+	body := bytes.Repeat([]byte{'z'}, maxUploadBytes+1)
+	err := writeUploadBody(path, bytes.NewReader(body))
+	if err == nil || !strings.Contains(err.Error(), "too large") {
+		t.Fatalf("expected body too large, got %v", err)
+	}
+	if _, err := os.Stat(path); err == nil {
+		t.Fatal("final file should not exist")
+	}
+}
+
+func TestWriteUploadBodyReadError(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "e.txt")
+	err := writeUploadBody(path, iotest.ErrReader(errors.New("read boom")))
+	if err == nil || !strings.Contains(err.Error(), "write") {
+		t.Fatalf("expected write wrap error, got %v", err)
+	}
+}
+
+func TestWriteUploadBodyCreateFails(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.Chmod(dir, 0o555); err != nil {
+		t.Fatal(err)
+	}
+	defer func() { _ = os.Chmod(dir, 0o755) }()
+	path := filepath.Join(dir, "nope.txt")
+	err := writeUploadBody(path, strings.NewReader("a"))
+	if err == nil || !strings.Contains(err.Error(), "create temp") {
+		t.Fatalf("expected create temp error, got %v", err)
+	}
+}
+
+func TestWriteUploadBodyRenameFails(t *testing.T) {
+	dir := t.TempDir()
+	target := filepath.Join(dir, "block")
+	if err := os.Mkdir(target, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	path := filepath.Join(dir, "block")
+	err := writeUploadBody(path, strings.NewReader("x"))
+	if err == nil || !strings.Contains(err.Error(), "rename") {
+		t.Fatalf("expected rename error, got %v", err)
+	}
+}
+
+func TestOpenAuthStoreBadPath(t *testing.T) {
+	ctx := context.Background()
+	_, err := openAuthStore(ctx, t.TempDir(), "/dev/null/impossible/goprecords-auth.db")
+	if err == nil {
+		t.Fatal("expected error opening auth store under /dev/null")
+	}
+}
+
+func TestUploadMethodNotAllowedTable(t *testing.T) {
+	statsDir := t.TempDir()
+	srv := httptest.NewServer(Handler(statsDir))
+	defer srv.Close()
+	for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
+		t.Run(method, func(t *testing.T) {
+			req, _ := http.NewRequest(method, srv.URL+"/upload/h/txt", 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 TestUploadAuthBearerNegativeTable(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()
+	url := srv.URL + "/upload/myhost/txt"
+	tests := []struct {
+		name   string
+		hdr    string
+		status int
+	}{
+		{"no auth", "", http.StatusUnauthorized},
+		{"basic", "Basic x", http.StatusUnauthorized},
+		{"bearer empty", "Bearer ", http.StatusUnauthorized},
+		{"wrong token", "Bearer not-the-token", http.StatusForbidden},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader("data"))
+			if tt.hdr != "" {
+				req.Header.Set("Authorization", tt.hdr)
+			}
+			res, err := http.DefaultClient.Do(req)
+			if err != nil {
+				t.Fatal(err)
+			}
+			res.Body.Close()
+			if res.StatusCode != tt.status {
+				t.Fatalf("status %d want %d", res.StatusCode, tt.status)
+			}
+		})
+	}
+}
-- 
cgit v1.2.3