Files
cairnobs/enterprise/internal/loginhandler/loginhandler_test.go
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

602 lines
23 KiB
Go

// Uses coreos/go-oidc's own oidctest package (a real fake OIDC IdP --
// serves discovery + JWKS, signs real RS256 ID tokens) plus a small
// local /token handler (oidctest doesn't implement the OAuth2 code
// exchange itself, only ID token signing/verification) to exercise the
// FULL login flow -- login redirect, a real signed-and-verified ID
// token round trip, user upsert, tenant/role resolution, and session
// cookie issuance -- with real cryptographic verification, not mocked.
package loginhandler
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/cairnobs/cairnobs/enterprise/internal/oidc"
"github.com/cairnobs/cairnobs/enterprise/internal/rbacstore"
"github.com/cairnobs/cairnobs/enterprise/internal/session"
)
const testClientID = "cairnobs-test-client"
const testKeyID = "test-key-1"
// fakeUserStore is an in-memory stand-in for *rbacstore.Store, keyed by
// SSO subject -- enough to drive resolveIdentity's logic without a real
// Postgres.
type fakeUserStore struct {
usersBySubject map[string]*rbacstore.User
memberships map[string][]rbacstore.Membership // by user ID
tenantDisplayNames map[string]string // by tenant ID, defaults to the ID itself if unset
}
func newFakeUserStore() *fakeUserStore {
return &fakeUserStore{
usersBySubject: map[string]*rbacstore.User{},
memberships: map[string][]rbacstore.Membership{},
tenantDisplayNames: map[string]string{},
}
}
func (f *fakeUserStore) UpsertUserBySSO(_ context.Context, ssoSubject, email, displayName string) (*rbacstore.User, error) {
if u, ok := f.usersBySubject[ssoSubject]; ok {
u.Email, u.DisplayName = email, displayName
return u, nil
}
u := &rbacstore.User{ID: "user-" + ssoSubject, Email: email, DisplayName: displayName, SSOSubject: ssoSubject}
f.usersBySubject[ssoSubject] = u
return u, nil
}
func (f *fakeUserStore) ListMembershipsForUser(_ context.Context, userID string) ([]rbacstore.Membership, error) {
return f.memberships[userID], nil
}
func (f *fakeUserStore) ListMembershipsWithTenantForUser(_ context.Context, userID string) ([]rbacstore.MembershipWithTenant, error) {
memberships := f.memberships[userID]
out := make([]rbacstore.MembershipWithTenant, 0, len(memberships))
for _, m := range memberships {
displayName := f.tenantDisplayNames[m.TenantID]
if displayName == "" {
displayName = m.TenantID
}
out = append(out, rbacstore.MembershipWithTenant{TenantID: m.TenantID, TenantDisplayName: displayName, Role: m.Role})
}
return out, nil
}
// testIdP bundles a real oidctest.Server (discovery + JWKS) with a local
// /token handler, and knows how to mint a validly-signed ID token for a
// given subject/email -- everything a test needs to drive a real login
// round trip.
type testIdP struct {
srv *httptest.Server
priv *rsa.PrivateKey
nextIDToken string
}
func newTestIdP(t *testing.T) *testIdP {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generating RSA key: %v", err)
}
idp := &testIdP{priv: priv}
oidcSrv := &oidctest.Server{
PublicKeys: []oidctest.PublicKey{{PublicKey: priv.Public(), KeyID: testKeyID, Algorithm: "RS256"}},
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-access-token",
"id_token": idp.nextIDToken,
"token_type": "Bearer",
})
})
mux.Handle("/", oidcSrv)
idp.srv = httptest.NewServer(mux)
oidcSrv.SetIssuer(idp.srv.URL)
t.Cleanup(idp.srv.Close)
return idp
}
// setNextIDToken configures what /token returns on the next exchange --
// a real RS256-signed JWT, verified for real by oidc.Provider.Exchange.
func (idp *testIdP) setNextIDToken(t *testing.T, subject, email string, emailVerified bool, expiry time.Time) {
t.Helper()
claims := fmt.Sprintf(`{
"iss": %q, "aud": %q, "sub": %q, "email": %q, "email_verified": %v,
"iat": %d, "exp": %d
}`, idp.srv.URL, testClientID, subject, email, emailVerified, time.Now().Unix(), expiry.Unix())
idp.nextIDToken = oidctest.SignIDToken(idp.priv, testKeyID, "RS256", claims)
}
func newTestOIDCProvider(t *testing.T, idp *testIdP) *oidc.Provider {
t.Helper()
p, err := oidc.New(context.Background(), oidc.Config{
IssuerURL: idp.srv.URL, ClientID: testClientID, ClientSecret: "secret",
RedirectURL: "http://cairnobs-test/auth/oidc/callback",
})
if err != nil {
t.Fatalf("oidc.New: %v", err)
}
return p
}
func newTestSessionManager(t *testing.T) *session.Manager {
t.Helper()
m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!"))
if err != nil {
t.Fatalf("session.NewManager: %v", err)
}
return m
}
func TestHandleLoginRedirectsAndSetsStateCookie(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc == "" {
t.Fatal("expected a Location header redirecting to the IdP")
}
cookies := rec.Result().Cookies()
var stateCookie *http.Cookie
for _, c := range cookies {
if c.Name == oidcStateCookieName {
stateCookie = c
}
}
if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("expected a non-empty state cookie to be set")
}
if !stateCookie.HttpOnly {
t.Fatal("expected the state cookie to be HttpOnly")
}
}
// fullLoginFlow drives handleLogin then handleCallback end to end,
// exactly the way a browser + IdP round trip would, and returns the
// final response so callers can assert on it.
func fullLoginFlow(t *testing.T, h *Handler, idp *testIdP) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
loginRec := httptest.NewRecorder()
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
var stateCookie *http.Cookie
for _, c := range loginRec.Result().Cookies() {
if c.Name == oidcStateCookieName {
stateCookie = c
}
}
if stateCookie == nil {
t.Fatal("no state cookie from /auth/oidc/login")
}
callbackReq := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+stateCookie.Value+"&code=test-code", nil)
callbackReq.AddCookie(stateCookie)
callbackRec := httptest.NewRecorder()
mux.ServeHTTP(callbackRec, callbackReq)
return callbackRec
}
func TestFullLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
idp := newTestIdP(t)
store := newFakeUserStore()
store.memberships["user-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-user-1", Role: rbacstore.RoleEditor}}
sessionManager := newTestSessionManager(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, sessionManager, store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-1", "[email protected]", true, time.Now().Add(time.Hour))
rec := fullLoginFlow(t, h, idp)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "http://web/" {
t.Fatalf("Location = %q, want http://web/", loc)
}
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "cairnobs_session" {
sessionCookie = c
}
}
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected a cairnobs_session cookie to be set")
}
claims, err := sessionManager.Validate(sessionCookie.Value)
if err != nil {
t.Fatalf("validating issued session: %v", err)
}
if claims.TenantID != "acme" || claims.Role != "editor" || claims.UserID != "user-user-1" {
t.Fatalf("unexpected session claims: %+v", claims)
}
}
func TestFullLoginFlowRefusesNoMembership(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-2", "[email protected]", true, time.Now().Add(time.Hour))
rec := fullLoginFlow(t, h, idp)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
// TestFullLoginFlowStartsTenantSelectionForMultipleMemberships is the
// regression test for the real tenant-picker protocol (see this
// package's doc comment): an identity with more than one
// tenant_memberships row must get a pending-login cookie and a redirect
// to selectTenantRedirectURL, not a flat refusal and not a session
// cookie for either tenant guessed at.
func TestFullLoginFlowStartsTenantSelectionForMultipleMemberships(t *testing.T) {
idp := newTestIdP(t)
store := newFakeUserStore()
store.memberships["user-user-3"] = []rbacstore.Membership{
{TenantID: "acme", UserID: "user-user-3", Role: rbacstore.RoleViewer},
{TenantID: "globex", UserID: "user-user-3", Role: rbacstore.RoleAdmin},
}
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-3", "[email protected]", true, time.Now().Add(time.Hour))
rec := fullLoginFlow(t, h, idp)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "http://web/select-tenant" {
t.Fatalf("Location = %q, want http://web/select-tenant", loc)
}
var pendingCookie, sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case pendingLoginCookieName:
pendingCookie = c
case "cairnobs_session":
sessionCookie = c
}
}
if pendingCookie == nil || pendingCookie.Value == "" {
t.Fatal("expected a non-empty pending-login cookie")
}
if sessionCookie != nil {
t.Fatal("must not issue a real session cookie before a tenant is actually chosen")
}
}
// startTenantSelection drives a full OIDC login for an identity that
// resolves to more than one membership, returning the mux (so callers
// can drive GET /auth/memberships and POST /auth/select-tenant
// afterward, exactly like a picker page would) and the pending-login
// cookie the login flow set.
func startTenantSelection(t *testing.T, h *Handler, idp *testIdP) (*http.ServeMux, *http.Cookie) {
t.Helper()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
loginRec := httptest.NewRecorder()
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
var stateCookie *http.Cookie
for _, c := range loginRec.Result().Cookies() {
if c.Name == oidcStateCookieName {
stateCookie = c
}
}
if stateCookie == nil {
t.Fatal("no state cookie from /auth/oidc/login")
}
callbackReq := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+stateCookie.Value+"&code=test-code", nil)
callbackReq.AddCookie(stateCookie)
callbackRec := httptest.NewRecorder()
mux.ServeHTTP(callbackRec, callbackReq)
if callbackRec.Code != http.StatusFound {
t.Fatalf("callback status = %d, want 302; body=%s", callbackRec.Code, callbackRec.Body.String())
}
var pendingCookie *http.Cookie
for _, c := range callbackRec.Result().Cookies() {
if c.Name == pendingLoginCookieName {
pendingCookie = c
}
}
if pendingCookie == nil {
t.Fatal("expected a pending-login cookie")
}
return mux, pendingCookie
}
func newMultiMembershipStore() *fakeUserStore {
store := newFakeUserStore()
store.memberships["user-user-multi"] = []rbacstore.Membership{
{TenantID: "acme", UserID: "user-user-multi", Role: rbacstore.RoleViewer},
{TenantID: "globex", UserID: "user-user-multi", Role: rbacstore.RoleAdmin},
}
store.tenantDisplayNames["acme"] = "Acme Corp"
store.tenantDisplayNames["globex"] = "Globex Corporation"
return store
}
func TestListMembershipsReturnsTenantOptions(t *testing.T) {
idp := newTestIdP(t)
store := newMultiMembershipStore()
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-multi", "[email protected]", true, time.Now().Add(time.Hour))
mux, pendingCookie := startTenantSelection(t, h, idp)
req := httptest.NewRequest(http.MethodGet, "/auth/memberships", nil)
req.AddCookie(pendingCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var options []membershipOption
if err := json.Unmarshal(rec.Body.Bytes(), &options); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(options) != 2 {
t.Fatalf("got %d options, want 2: %+v", len(options), options)
}
byTenant := map[string]membershipOption{}
for _, o := range options {
byTenant[o.TenantID] = o
}
if byTenant["acme"].TenantDisplayName != "Acme Corp" || byTenant["acme"].Role != "viewer" {
t.Fatalf("unexpected acme option: %+v", byTenant["acme"])
}
if byTenant["globex"].TenantDisplayName != "Globex Corporation" || byTenant["globex"].Role != "admin" {
t.Fatalf("unexpected globex option: %+v", byTenant["globex"])
}
}
func TestListMembershipsRejectsMissingPendingCookie(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/memberships", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestSelectTenantIssuesSessionForChosenTenant(t *testing.T) {
idp := newTestIdP(t)
store := newMultiMembershipStore()
sessionManager := newTestSessionManager(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, sessionManager, store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-multi", "[email protected]", true, time.Now().Add(time.Hour))
mux, pendingCookie := startTenantSelection(t, h, idp)
req := httptest.NewRequest(http.MethodPost, "/auth/select-tenant", strings.NewReader(`{"tenant_id": "globex"}`))
req.AddCookie(pendingCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var sessionCookie, clearedPendingCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case "cairnobs_session":
sessionCookie = c
case pendingLoginCookieName:
clearedPendingCookie = c
}
}
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected a cairnobs_session cookie to be set")
}
if clearedPendingCookie == nil || clearedPendingCookie.MaxAge >= 0 {
t.Fatalf("expected the pending-login cookie to be cleared (MaxAge < 0), got %+v", clearedPendingCookie)
}
claims, err := sessionManager.Validate(sessionCookie.Value)
if err != nil {
t.Fatalf("validating issued session: %v", err)
}
if claims.TenantID != "globex" || claims.Role != "admin" || claims.UserID != "user-user-multi" {
t.Fatalf("unexpected session claims: %+v", claims)
}
var body selectTenantResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if body.RedirectURL != "http://web/" {
t.Fatalf("RedirectURL = %q, want http://web/", body.RedirectURL)
}
}
// TestSelectTenantRejectsTenantOutsideMembership is the regression test
// for handleSelectTenant re-deriving role server-side rather than
// trusting the request: a client claiming a tenant_id the pending
// identity doesn't actually belong to must be refused, not silently
// granted whatever role happened to be in the request.
func TestSelectTenantRejectsTenantOutsideMembership(t *testing.T) {
idp := newTestIdP(t)
store := newMultiMembershipStore()
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-multi", "[email protected]", true, time.Now().Add(time.Hour))
mux, pendingCookie := startTenantSelection(t, h, idp)
req := httptest.NewRequest(http.MethodPost, "/auth/select-tenant", strings.NewReader(`{"tenant_id": "not-a-real-membership"}`))
req.AddCookie(pendingCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "cairnobs_session" {
t.Fatal("must not issue a session cookie for a tenant outside the identity's memberships")
}
}
}
func TestSelectTenantRejectsMissingBody(t *testing.T) {
idp := newTestIdP(t)
store := newMultiMembershipStore()
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), store, "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-multi", "[email protected]", true, time.Now().Add(time.Hour))
mux, pendingCookie := startTenantSelection(t, h, idp)
req := httptest.NewRequest(http.MethodPost, "/auth/select-tenant", strings.NewReader(`{}`))
req.AddCookie(pendingCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestSelectTenantRejectsMissingPendingCookie(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/select-tenant", strings.NewReader(`{"tenant_id": "acme"}`)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// TestSelectTenantRejectsSessionCookieAsPendingCookie is the regression
// test for PendingLoginClaims being a distinct type from Claims: a real
// session token must not work as a pending-login cookie, even though
// both are signed by the same key.
func TestSelectTenantRejectsSessionCookieAsPendingCookie(t *testing.T) {
idp := newTestIdP(t)
store := newFakeUserStore()
store.memberships["user-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-user-1", Role: rbacstore.RoleEditor}}
sessionManager := newTestSessionManager(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, sessionManager, store, "http://web/", "http://web/select-tenant")
realSessionToken, err := sessionManager.IssueUserSession("acme", "user-user-1", "editor")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/auth/select-tenant", strings.NewReader(`{"tenant_id": "acme"}`))
req.AddCookie(&http.Cookie{Name: pendingLoginCookieName, Value: realSessionToken})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (a real session token must not validate as a pending login)", rec.Code)
}
}
func TestCallbackRejectsStateMismatch(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=wrong&code=test-code", nil)
req.AddCookie(&http.Cookie{Name: oidcStateCookieName, Value: "correct"})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestCallbackRejectsMissingStateCookie(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=whatever&code=test-code", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestCallbackRejectsExpiredIDToken(t *testing.T) {
idp := newTestIdP(t)
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
idp.setNextIDToken(t, "user-4", "[email protected]", true, time.Now().Add(-time.Hour)) // already expired
rec := fullLoginFlow(t, h, idp)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
}
func TestRegisterRoutesNoOpWhenOIDCNotConfigured(t *testing.T) {
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (no routes should be registered when oidc is nil)", rec.Code)
}
}
// TestRegisterRoutesNoOpWithTypedNilProviderVariable is the regression
// test for Go's typed-nil-interface trap: enterprise-auth's main.go
// holds a `var provider *oidc.Provider` that stays nil when OIDC isn't
// configured, then passes that *variable* (not a nil literal) into New.
// If New ever goes back to assigning that pointer straight into the
// oidcProvider interface field, this test starts failing -- the
// interface would become non-nil (type=*oidc.Provider, value=nil) even
// though the variable itself is nil, and RegisterRoutes' `h.oidc == nil`
// check would stop working. TestRegisterRoutesNoOpWhenOIDCNotConfigured
// above doesn't catch this: passing a nil literal directly never hits
// the trap, only passing a nil-valued typed variable does.
func TestRegisterRoutesNoOpWithTypedNilProviderVariable(t *testing.T) {
var provider *oidc.Provider // stays nil -- exactly main.go's shape when OIDC_ISSUER_URL is unset
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), provider, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (a typed-nil *oidc.Provider must still result in oidc routes being disabled)", rec.Code)
}
}