zddc-server now issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). No external IDP, no JWKS rotation. Self-service flow: sign in via the browser, visit /.tokens, click "Create token," paste the resulting plaintext into a 0600 file, and pass --bearer-file <path> to whatever calls back into the server. Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token with email/created/expires/description. Filename is the *hash* of the plaintext, never the plaintext itself — a leak of the tokens directory exposes hashes, not credentials. Mode 0600 / 0700, atomic writes via temp+rename. Already shielded from public serving by the existing dot-prefix guards in dispatch and fs.ListDirectory. ACLMiddleware now recognises Authorization: Bearer <token>. On valid token, sets the request email from the token file and falls through to the existing ACL chain. On any failure (unknown / expired / store unavailable / Bearer with no validator), returns 401 — no silent fallback to anonymous, so a misconfigured client fails loudly. JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke) backs a small inline HTML self-service page at /.tokens. Users can only see and revoke their own tokens; cross-user revoke returns 404 to avoid leaking ownership. --no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this instance. On master: anyone reads everything (dev / trusted-LAN / public-read deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. Implemented as a swap to policy.AllowAllDecider; all existing handlers keep calling AllowFromChain unchanged. Distinct from --insecure, which only relaxes the no-root-.zddc startup check. WARN-level startup log when --no-auth is active so accidental enablement is visible. 33 new tests covering token storage, validation/expiry/revocation, the JSON API end-to-end, the HTML page, and the middleware-Bearer integration including the case-insensitive prefix and expired-token paths. Full suite + go vet clean. Doc updates: zddc/README.md "Authentication" rewritten to cover both auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a "Bearer tokens" subsection flagging the dot-prefix-shielding pre- condition; ARCHITECTURE.md adds "Bearer token issuance" and "--no-auth" subsections under "Server security model" with the hash-as-filename rationale and dispatch-shielding regression- sensitivity called out; CLAUDE.md adds a one-line summary of the new auth topology so future agents pick it up by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
458 lines
15 KiB
Go
458 lines
15 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)
|
|
}
|
|
}
|