ZDDC/zddc/internal/handler/tokenhandler_test.go
ZDDC 0c6396d246 docs+test: document the apiActions / server-injected-table primitive
Capture the mechanism the tokens + profile consolidation now rests on:
AGENTS.md gains a "Server-injected collections (apiActions)" section under
the Tables system (pre-assembled #table-context + the create/deleteRow/
rowNav layer, with server-side per-role gating), and the ARCHITECTURE ADR
marks step 2 done (/.tokens + /.profile render via the engine) and flags
that the remaining folds (archive/landing/transmittal) are feature-rich
PLUGIN migrations — not quick tables-fications.

Adds TestBuildTokensTableContext locking the contract: only the caller's
own tokens become rows, each row carries its id for the delete action, and
apiActions wires create (one-time secret) + per-row delete to /.api/tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:55:20 -05:00

499 lines
17 KiB
Go

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)
}
}
// TestBuildTokensTableContext locks the server-injected token table contract:
// only the caller's own tokens become rows, each row carries its id (for the
// delete action), and apiActions wires create (with the one-time secret) +
// per-row delete to /.api/tokens.
func TestBuildTokensTableContext(t *testing.T) {
store := newTestTokenStore(t)
if _, _, err := store.Generate("alice@example.com", "Field laptop", time.Time{}); err != nil {
t.Fatalf("Generate alice: %v", err)
}
if _, _, err := store.Generate("mallory@example.com", "other", time.Time{}); err != nil {
t.Fatalf("Generate mallory: %v", err)
}
ctx := buildTokensTableContext(store, "alice@example.com")
if ctx["title"] != "API tokens" {
t.Errorf("title = %v", ctx["title"])
}
rows, ok := ctx["rows"].([]map[string]interface{})
if !ok || len(rows) != 1 {
t.Fatalf("rows = %#v, want exactly alice's one token", ctx["rows"])
}
data, _ := rows[0]["data"].(map[string]interface{})
if data["description"] != "Field laptop" {
t.Errorf("row description = %v", data["description"])
}
if id, _ := rows[0]["url"].(string); id == "" {
t.Errorf("row missing url (token id needed for the delete action)")
}
api, _ := ctx["apiActions"].(map[string]interface{})
create, _ := api["create"].(map[string]interface{})
if create["url"] != TokensAPIPathPrefix || create["secretField"] != "token" {
t.Errorf("apiActions.create = %#v, want url=%s secretField=token", create, TokensAPIPathPrefix)
}
del, _ := api["deleteRow"].(map[string]interface{})
if del["urlTemplate"] != TokensAPIPathPrefix+"/{id}" {
t.Errorf("apiActions.deleteRow.urlTemplate = %v", del["urlTemplate"])
}
}