package handler import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/auth" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" ) // newTestTokenStore returns an isolated auth.Store under a TempDir. func newTestTokenStore(t *testing.T) *auth.Store { t.Helper() root := t.TempDir() store, err := auth.NewStore(root) if err != nil { t.Fatalf("auth.NewStore: %v", err) } return store } // authedReq builds an HTTP request with the authenticated email set in // the request context (mimicking what ACLMiddleware would do upstream // of the handler under test). func authedReq(method, path, email string, body []byte) *http.Request { var r *http.Request if body != nil { r = httptest.NewRequest(method, path, bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") } else { r = httptest.NewRequest(method, path, nil) } if email != "" { r = r.WithContext(WithEmail(r.Context(), email)) } return r } func TestServeTokensAPI_Anonymous401(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/.api/tokens", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusUnauthorized { t.Errorf("anonymous GET /.api/tokens = %d, want 401", rec.Code) } } func TestServeTokensAPI_NoStore503(t *testing.T) { rec := httptest.NewRecorder() r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil) ServeTokensAPI(config.Config{}, nil, rec, r) if rec.Code != http.StatusServiceUnavailable { t.Errorf("nil store = %d, want 503", rec.Code) } } func TestServeTokensAPI_ListEmpty(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusOK { t.Errorf("GET = %d, want 200", rec.Code) } if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { t.Errorf("Content-Type = %q", got) } var list []tokenAPIView if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { t.Fatalf("unmarshal: %v body=%q", err, rec.Body.String()) } if len(list) != 0 { t.Errorf("expected empty list, got %d entries", len(list)) } } func TestServeTokensAPI_CreateAndList(t *testing.T) { store := newTestTokenStore(t) body := []byte(`{"description":"laptop"}`) rec := httptest.NewRecorder() r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusCreated { t.Fatalf("POST = %d, want 201; body=%q", rec.Code, rec.Body.String()) } var resp tokenCreateResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal POST resp: %v", err) } if resp.Token == "" { t.Fatal("plaintext token missing from response") } if resp.ID == "" { t.Fatal("ID missing") } if resp.Email != "alice@example.com" { t.Errorf("Email = %q", resp.Email) } if resp.Description != "laptop" { t.Errorf("Description = %q", resp.Description) } // The token must validate as a Bearer. tok, err := store.Validate(resp.Token) if err != nil { t.Fatalf("Validate the new plaintext: %v", err) } if tok.ID() != resp.ID { t.Errorf("validated ID %q != response ID %q", tok.ID(), resp.ID) } // Subsequent list contains the token (without plaintext). rec2 := httptest.NewRecorder() r2 := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil) ServeTokensAPI(config.Config{}, store, rec2, r2) if rec2.Code != http.StatusOK { t.Fatalf("GET = %d", rec2.Code) } var list []tokenAPIView _ = json.Unmarshal(rec2.Body.Bytes(), &list) if len(list) != 1 { t.Fatalf("list = %d entries, want 1", len(list)) } if list[0].ID != resp.ID { t.Errorf("listed ID mismatch") } // Plaintext token MUST NOT appear in any list response. if strings.Contains(rec2.Body.String(), resp.Token) { t.Error("plaintext token leaked in list response") } } func TestServeTokensAPI_CreateRejectsPastExpiry(t *testing.T) { store := newTestTokenStore(t) past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) body := []byte(`{"expires":"` + past + `"}`) rec := httptest.NewRecorder() r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusBadRequest { t.Errorf("past-expiry create = %d, want 400", rec.Code) } } func TestServeTokensAPI_CreateRejectsLongDescription(t *testing.T) { store := newTestTokenStore(t) huge := strings.Repeat("x", MaxTokenDescription+1) body, _ := json.Marshal(tokenCreateRequest{Description: huge}) rec := httptest.NewRecorder() r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusBadRequest { t.Errorf("long description = %d, want 400", rec.Code) } } func TestServeTokensAPI_RevokeOwnToken(t *testing.T) { store := newTestTokenStore(t) plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{}) if err != nil { t.Fatalf("seed Generate: %v", err) } rec := httptest.NewRecorder() r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "alice@example.com", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusNoContent { t.Errorf("DELETE = %d, want 204; body=%q", rec.Code, rec.Body.String()) } // Token no longer validates. if _, err := store.Validate(plaintext); err == nil { t.Error("token still validates after revoke") } } func TestServeTokensAPI_RevokeOtherUsers404(t *testing.T) { store := newTestTokenStore(t) _, tok, err := store.Generate("alice@example.com", "", time.Time{}) if err != nil { t.Fatalf("seed: %v", err) } rec := httptest.NewRecorder() r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "bob@example.com", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusNotFound { t.Errorf("DELETE other user's = %d, want 404", rec.Code) } } func TestServeTokensAPI_RevokeMissing404(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := authedReq(http.MethodDelete, "/.api/tokens/deadbeef", "alice@example.com", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusNotFound { t.Errorf("DELETE nonexistent = %d, want 404", rec.Code) } } func TestServeTokensAPI_RejectsBadMethods(t *testing.T) { store := newTestTokenStore(t) cases := []struct { path string method string }{ {"/.api/tokens", http.MethodPut}, {"/.api/tokens", http.MethodDelete}, {"/.api/tokens/abc12345", http.MethodGet}, {"/.api/tokens/abc12345", http.MethodPost}, } for _, c := range cases { rec := httptest.NewRecorder() r := authedReq(c.method, c.path, "alice@example.com", nil) ServeTokensAPI(config.Config{}, store, rec, r) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("%s %s = %d, want 405", c.method, c.path, rec.Code) } } } func TestServeTokensPage_Authenticated(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil) ServeTokensPage(config.Config{}, store, rec, r) if rec.Code != http.StatusOK { t.Fatalf("GET /.tokens = %d", rec.Code) } if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "text/html") { t.Errorf("Content-Type = %q", got) } body := rec.Body.String() if !strings.Contains(body, "alice@example.com") { t.Error("page does not show authenticated email") } if !strings.Contains(body, "/.api/tokens") { t.Error("page does not reference the API endpoint") } } func TestServeTokensPage_Anonymous401(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/.tokens", nil) ServeTokensPage(config.Config{}, store, rec, r) if rec.Code != http.StatusUnauthorized { t.Errorf("anonymous = %d, want 401", rec.Code) } } func TestServeTokensPage_NoStoreShowsWarning(t *testing.T) { rec := httptest.NewRecorder() r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil) ServeTokensPage(config.Config{}, nil, rec, r) if rec.Code != http.StatusOK { t.Fatalf("authenticated with nil store = %d", rec.Code) } if !strings.Contains(rec.Body.String(), "unavailable") { t.Error("expected warning about token store unavailability") } } func TestServeTokensPage_RejectsNonGET(t *testing.T) { store := newTestTokenStore(t) rec := httptest.NewRecorder() r := authedReq(http.MethodPost, "/.tokens", "alice@example.com", nil) ServeTokensPage(config.Config{}, store, rec, r) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("POST = %d, want 405", rec.Code) } } func TestACLMiddleware_BearerSetsEmailFromToken(t *testing.T) { store := newTestTokenStore(t) plaintext, _, err := store.Generate("bearer-user@example.com", "", time.Time{}) if err != nil { t.Fatalf("seed: %v", err) } cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} var seenEmail string chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenEmail = EmailFromContext(r) w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer "+plaintext) rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if seenEmail != "bearer-user@example.com" { t.Errorf("seen email = %q, want bearer-user@example.com", seenEmail) } } func TestACLMiddleware_InvalidBearerRejected(t *testing.T) { store := newTestTokenStore(t) cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("inner handler should not run on invalid bearer") })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer not-a-real-token") // And a valid header email — Bearer must still take precedence and fail. req.Header.Set("X-Auth-Request-Email", "alice@example.com") rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("invalid bearer = %d, want 401", rec.Code) } } func TestACLMiddleware_BearerWithoutStoreRejected(t *testing.T) { cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} chain := ACLMiddleware(cfg, nil, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("inner handler should not run when bearer present and store absent") })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer something") rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("bearer w/ nil store = %d, want 401", rec.Code) } } func TestACLMiddleware_NoBearerFallsBackToHeader(t *testing.T) { store := newTestTokenStore(t) cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} var seen string chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = EmailFromContext(r) w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Auth-Request-Email", "alice@example.com") rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if seen != "alice@example.com" { t.Errorf("email = %q, want alice@example.com (from header)", seen) } } func TestACLMiddleware_BearerCaseInsensitive(t *testing.T) { store := newTestTokenStore(t) plaintext, _, err := store.Generate("alice@example.com", "", time.Time{}) if err != nil { t.Fatalf("seed: %v", err) } cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} for _, prefix := range []string{"Bearer ", "bearer ", "BEARER ", "BeArEr "} { var seen string chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = EmailFromContext(r) w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", prefix+plaintext) rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("prefix %q: status %d", prefix, rec.Code) } if seen != "alice@example.com" { t.Errorf("prefix %q: email %q, want alice@example.com", prefix, seen) } } } func TestACLMiddleware_ExpiredBearerRejected(t *testing.T) { store := newTestTokenStore(t) plaintext, _, err := store.Generate("alice@example.com", "", time.Now().Add(-time.Hour)) if err != nil { t.Fatalf("seed: %v", err) } cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("inner handler should not run on expired bearer") })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer "+plaintext) rec := httptest.NewRecorder() chain.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("expired bearer = %d, want 401", rec.Code) } } // TestEndToEndCreateAuthenticateRevoke walks the full happy path: an // authenticated user creates a token via the API, uses it as a Bearer // for a separate request, then revokes it and confirms the next Bearer // fails. func TestEndToEndCreateAuthenticateRevoke(t *testing.T) { store := newTestTokenStore(t) cfg := config.Config{EmailHeader: "X-Auth-Request-Email"} // 1. Create token via API as alice (authenticated via context). rec := httptest.NewRecorder() r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", []byte(`{"description":"e2e"}`)) ServeTokensAPI(cfg, store, rec, r) if rec.Code != http.StatusCreated { t.Fatalf("create: %d", rec.Code) } var resp tokenCreateResponse _ = json.Unmarshal(rec.Body.Bytes(), &resp) // 2. Use the token as a Bearer through ACLMiddleware. var seen string bearerHandler := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = EmailFromContext(r) w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer "+resp.Token) rec2 := httptest.NewRecorder() bearerHandler.ServeHTTP(rec2, req) if rec2.Code != http.StatusOK || seen != "alice@example.com" { t.Fatalf("authenticate: code=%d email=%q", rec2.Code, seen) } // 3. Revoke via API as alice. rec3 := httptest.NewRecorder() r3 := authedReq(http.MethodDelete, "/.api/tokens/"+resp.ID, "alice@example.com", nil) ServeTokensAPI(cfg, store, rec3, r3) if rec3.Code != http.StatusNoContent { t.Fatalf("revoke: %d", rec3.Code) } // 4. Bearer no longer authenticates. req4 := httptest.NewRequest(http.MethodGet, "/", nil) req4.Header.Set("Authorization", "Bearer "+resp.Token) rec4 := httptest.NewRecorder() bearerHandler.ServeHTTP(rec4, req4) if rec4.Code != http.StatusUnauthorized { t.Errorf("post-revoke Bearer = %d, want 401", rec4.Code) } } // Sanity: WithEmail-from-context helper works as expected when used // directly without the middleware (test seam). func TestWithEmail(t *testing.T) { ctx := WithEmail(context.Background(), "carol@example.com") r := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) if got := EmailFromContext(r); got != "carol@example.com" { t.Errorf("EmailFromContext = %q", got) } }