package auth import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // newWebTicketHandler wires a Handler with BOTH the public routes (via // RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how // main.go mounts them in two separate groups (public vs RequireAuth). func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) { t.Helper() svc, _, _ := newService(t, cfg) h := NewHandler(svc) r := chi.NewRouter() h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange r.Group(func(protected chi.Router) { protected.Use(RequireAuth(svc.tokens)) protected.Post("/auth/web-ticket", h.WebTicket) }) return svc, r } // issueAccessToken mints a real, valid access token for a fresh user id/uuid // pair — bypassing full register/login, since the ticket flow only cares that // RequireAuth's middleware injects a valid uid/uuid into the request context. func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) { t.Helper() userID = 42 userUUID = uuid.NewString() pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID) if err != nil { t.Fatalf("issue access token: %v", err) } return pair.AccessToken, userID, userUUID } func doAuthed(t *testing.T, h http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer if body != nil { _ = json.NewEncoder(&buf).Encode(body) } req := httptest.NewRequest(method, path, &buf) req.RemoteAddr = "203.0.113.5:1234" if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // TestWebTicket_IssueAndExchange covers the full happy path: an authed app // mints a ticket, the (unauthenticated) web side exchanges it for a token // pair shaped exactly like a normal /auth/login response. func TestWebTicket_IssueAndExchange(t *testing.T) { svc, h := newWebTicketHandler(t, ServiceConfig{}) token, wantUID, wantUUID := issueAccessToken(t, svc) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil) if rec.Code != http.StatusOK { t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body) } var issued webTicketResponse if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { t.Fatalf("decode issue response: %v", err) } if issued.Ticket == "" { t.Fatal("empty ticket") } if issued.ExpiresIn != 60 { t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn) } rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "", map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}}) if rec.Code != http.StatusOK { t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body) } var pair tokenPairResponse if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil { t.Fatalf("decode exchange response: %v", err) } if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 { t.Fatalf("bad token pair: %+v", pair) } // The exchanged access token must authenticate as the SAME user the app // ticket was issued for. claims, err := svc.tokens.ParseAccess(pair.AccessToken) if err != nil { t.Fatalf("parse exchanged access token: %v", err) } if claims.UID != wantUID || claims.Subject != wantUUID { t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s", claims.UID, claims.Subject, wantUID, wantUUID) } } // TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same // ticket (replay) must fail 401, even though the first succeeded. func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) { svc, h := newWebTicketHandler(t, ServiceConfig{}) token, _, _ := issueAccessToken(t, svc) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil) var issued webTicketResponse _ = json.Unmarshal(rec.Body.Bytes(), &issued) rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "", map[string]any{"ticket": issued.Ticket}) if rec.Code != http.StatusOK { t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body) } rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "", map[string]any{"ticket": issued.Ticket}) if rec.Code != http.StatusUnauthorized { t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body) } } // TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of // this package does (service_test.go TestService_CodeExpired): delete the // Redis key directly rather than fast-forwarding a clock. func TestWebTicket_Expired_Rejected(t *testing.T) { svc, h := newWebTicketHandler(t, ServiceConfig{}) token, _, _ := issueAccessToken(t, svc) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil) var issued webTicketResponse _ = json.Unmarshal(rec.Body.Bytes(), &issued) svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket)) rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "", map[string]any{"ticket": issued.Ticket}) if rec.Code != http.StatusUnauthorized { t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body) } } // TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued. func TestWebTicket_Bogus_Rejected(t *testing.T) { _, h := newWebTicketHandler(t, ServiceConfig{}) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "", map[string]any{"ticket": "not-a-real-ticket"}) if rec.Code != http.StatusUnauthorized { t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body) } } // TestWebTicket_RequiresAuth: minting a ticket without a bearer token is // rejected by RequireAuth before it ever reaches the handler. func TestWebTicket_RequiresAuth(t *testing.T) { _, h := newWebTicketHandler(t, ServiceConfig{}) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code) } } // TestWebTicket_RateLimited: a second ticket request within the same second // for the same user is rejected 429 (rebuff-abuse backstop; the endpoint // already requires login). func TestWebTicket_RateLimited(t *testing.T) { svc, h := newWebTicketHandler(t, ServiceConfig{}) token, _, _ := issueAccessToken(t, svc) rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil) if rec.Code != http.StatusOK { t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body) } rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body) } }