package main import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "golang.org/x/crypto/bcrypt" "golang.org/x/time/rate" ) // bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default. func bcryptHash(t *testing.T, pw string) string { t.Helper() h, err := bcrypt.GenerateFromPassword([]byte(pw), 10) if err != nil { t.Fatalf("GenerateFromPassword: %v", err) } return string(h) } func TestLoadHtpasswdAndCheck(t *testing.T) { // alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B // emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so // swapping the prefix on a valid hash yields a valid hash for the same // password. bob keeps the $2a$ form. aliceHash := bcryptHash(t, "s3cret") if !strings.HasPrefix(aliceHash, "$2a$") { t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4]) } aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$") bobHash := bcryptHash(t, "hunter2") file := filepath.Join(t.TempDir(), "htpasswd") content := strings.Join([]string{ "# a comment", "", "alice:" + aliceHashY, "bob:" + bobHash, "legacy:$apr1$abcd$0123456789", // MD5 — must be skipped }, "\n") + "\n" if err := os.WriteFile(file, []byte(content), 0600); err != nil { t.Fatal(err) } a, err := LoadHtpasswd(file) if err != nil { t.Fatalf("LoadHtpasswd: %v", err) } if _, ok := a.creds["legacy"]; ok { t.Error("MD5 entry should have been skipped") } cases := []struct { user, pass string want bool }{ {"alice", "s3cret", true}, // $2y$ accepted {"bob", "hunter2", true}, // $2a$ accepted {"alice", "wrong", false}, // wrong password {"bob", "s3cret", false}, // right password, wrong user {"nobody", "s3cret", false}, // unknown user {"legacy", "whatever", false}, } for _, c := range cases { if got := a.check(c.user, c.pass); got != c.want { t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want) } } } func TestBasicAuthMiddleware(t *testing.T) { a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}} a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10) var reached bool // Burst of 2 failures before throttling, so the 429 path is reachable. limiter := rate.NewLimiter(rate.Every(time.Hour), 2) h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) { reached = true w.WriteHeader(http.StatusCreated) }) do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder { reached = false req := httptest.NewRequest(http.MethodPost, "/upload", nil) if withCreds { req.SetBasicAuth(user, pass) } rec := httptest.NewRecorder() h(rec, req) return rec } // Good credentials pass through. if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached { t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached) } // No credentials → 401 with a challenge. rec := do("", "", false) if rec.Code != http.StatusUnauthorized { t.Fatalf("missing creds: code=%d, want 401", rec.Code) } if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") { t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate")) } if reached { t.Error("handler reached despite missing creds") } // Wrong password burns the limiter (burst 2: one above + this one), then // the next failure is throttled to 429. if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized { t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code) } if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests { t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code) } // A valid request still succeeds while failures are throttled — the // limiter only gates failures. if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated { t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code) } } func TestLoadHtpasswdErrors(t *testing.T) { if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil { t.Error("expected error for missing file") } empty := filepath.Join(t.TempDir(), "htpasswd") if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil { t.Fatal(err) } if _, err := LoadHtpasswd(empty); err == nil { t.Error("expected error when no bcrypt entries are usable") } }