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 --- Magefile.go | 10 ++ internal/authkeys/store_test.go | 77 +++++++++++++ internal/cli/cli_test.go | 32 +++++- internal/daemon/daemon_test.go | 244 ++++++++++++++++++++++++++++------------ internal/daemon/upload_test.go | 239 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 529 insertions(+), 73 deletions(-) create mode 100644 internal/daemon/upload_test.go diff --git a/Magefile.go b/Magefile.go index 07bfe1c..ac2f23e 100644 --- a/Magefile.go +++ b/Magefile.go @@ -28,6 +28,16 @@ func Test() error { return sh.RunV("go", "test", "./...") } +// CoverMicroservice runs tests with coverage for daemon, authkeys, and cli, then prints per-function coverage (go tool cover -func). +func CoverMicroservice() error { + profile := filepath.Join(os.TempDir(), "goprecords-microservice.cover") + if err := sh.RunV("go", "test", "-coverprofile", profile, "-covermode", "count", + "./internal/daemon/...", "./internal/authkeys/...", "./internal/cli/..."); err != nil { + return err + } + return sh.RunV("go", "tool", "cover", "-func", profile) +} + // Install builds and installs the binary to GOPATH/bin. func Install() error { mg.Deps(Build) 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{"", "
"},
+		},
+		{
+			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