diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 10:27:29 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 10:27:29 +0300 |
| commit | 74e1b8f37d318112d52e5a80f0f589f1b0cc7f34 (patch) | |
| tree | c8dbdd3740b6e9f7f14620f549decafcefae5a02 /internal | |
| parent | 254426152804be2a439a071d17478976089d2643 (diff) | |
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
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/authkeys/store_test.go | 77 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 32 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 244 | ||||
| -rw-r--r-- | internal/daemon/upload_test.go | 239 |
4 files changed, 519 insertions, 73 deletions
diff --git a/internal/authkeys/store_test.go b/internal/authkeys/store_test.go index 8331a5b..b8da623 100644 --- a/internal/authkeys/store_test.go +++ b/internal/authkeys/store_test.go @@ -3,6 +3,7 @@ package authkeys import ( "context" "path/filepath" + "strings" "testing" ) @@ -63,3 +64,79 @@ func TestDefaultPath(t *testing.T) { t.Fatalf("got %q", p) } } + +func TestCloseNilStore(t *testing.T) { + var s *Store + if err := s.Close(); err != nil { + t.Fatalf("Close nil: %v", err) + } +} + +func TestCloseNilDB(t *testing.T) { + s := &Store{} + if err := s.Close(); err != nil { + t.Fatalf("Close nil db: %v", err) + } +} + +func TestCreateKeyEmptyHostname(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "auth.db") + s, err := OpenStore(ctx, path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.EnsureSchema(ctx); err != nil { + t.Fatal(err) + } + _, err = s.CreateKey(ctx, "") + if err == nil || !strings.Contains(err.Error(), "hostname") { + t.Fatalf("expected empty hostname error, got %v", err) + } +} + +func TestVerifyUnknownHost(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "auth.db") + s, err := OpenStore(ctx, path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.EnsureSchema(ctx); err != nil { + t.Fatal(err) + } + ok, err := s.Verify(ctx, "nohost", "any") + if err != nil || ok { + t.Fatalf("Verify unknown host: ok=%v err=%v", ok, err) + } +} + +func TestOpsAfterClose(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "auth.db") + s, err := OpenStore(ctx, path) + if err != nil { + t.Fatal(err) + } + if err := s.EnsureSchema(ctx); err != nil { + s.Close() + t.Fatal(err) + } + if _, err := s.CreateKey(ctx, "h"); err != nil { + s.Close() + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + _, err = s.KeyCount(ctx) + if err == nil { + t.Fatal("KeyCount after close expected error") + } + _, err = s.Verify(ctx, "h", "x") + if err == nil { + t.Fatal("Verify after close expected error") + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ff7b046..a89fa50 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -118,7 +118,14 @@ func TestStableImportAndQuery(t *testing.T) { func TestStableIntegrationTestSubcommand(t *testing.T) { root := moduleRoot(t) - t.Chdir(root) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(cwd) }() if err := Execute([]string{"test"}); err != nil { t.Fatal(err) } @@ -174,3 +181,26 @@ func TestCreateClientKeyWritesToken(t *testing.T) { t.Fatalf("token too short %q", tok) } } + +func TestDefaultListenFromEnv(t *testing.T) { + t.Setenv("GOPRECORDS_LISTEN", ":7777") + if defaultListenFromEnv() != ":7777" { + t.Fatalf("got %q", defaultListenFromEnv()) + } + t.Setenv("GOPRECORDS_LISTEN", "") + if defaultListenFromEnv() != ":8080" { + t.Fatalf("default got %q", defaultListenFromEnv()) + } +} + +func TestCreateClientKeyWithAuthDBOnly(t *testing.T) { + db := filepath.Join(t.TempDir(), "keys.db") + out := captureStdout(t, func() { + if err := Execute([]string{"--create-client-key", "hostonly", "-auth-db", db}); err != nil { + t.Fatal(err) + } + }) + if len(strings.TrimSpace(out)) < 20 { + t.Fatalf("token too short %q", out) + } +} 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{"<!DOCTYPE html>", "<pre>"}, + }, + { + 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, "<!DOCTYPE html>") || !strings.Contains(body, "<pre>") { - 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) + } + }) + } +} |
