Files
member-console/internal/auth/auth_session_person_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

225 lines
8.8 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"database/sql"
"errors"
"net/http"
"net/http/httptest"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
)
// These tests cover the session-person check in Middleware (acceptance-fixes
// round 4, 2026-09-03): an authenticated session whose person row no longer
// exists must end at the next request and bounce to /login, instead of
// living on until a write fails on fk_grants_granted_by_person_id deep in a
// handler. They run through the real scs store (in-memory) so the cookie
// round-trips and "the session ended" is observed the way a browser would.
// personDirectory is the identity querier the check needs: GetPersonByID
// answers for the ids it was given and sql.ErrNoRows for the rest, which is
// what a sqlc :one query returns for a missing row. Embedding the interface
// leaves every other method nil; the middleware must call none of them.
type personDirectory struct {
identity.Querier
ids map[string]bool
}
func (d personDirectory) GetPersonByID(_ context.Context, id string) (identity.Person, error) {
if d.ids[id] {
return identity.Person{PersonID: id, Status: "active"}, nil
}
return identity.Person{}, sql.ErrNoRows
}
// failingDirectory answers every lookup with an error that is not "no such
// row": the database is unreachable, say.
type failingDirectory struct{ identity.Querier }
func (failingDirectory) GetPersonByID(context.Context, string) (identity.Person, error) {
return identity.Person{}, errors.New("connection refused")
}
// signIn runs one request through LoadAndSave that marks the session
// authenticated for personID, the way CallbackHandler does, and returns the
// session cookie it set.
func signIn(t *testing.T, cfg *Config, personID string) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyAuthenticated, true)
cfg.SessionManager.Put(r.Context(), sessionKeyPersonID, personID)
})).ServeHTTP(rec, req)
for _, ck := range rec.Result().Cookies() {
if ck.Name == cfg.SessionManager.Cookie.Name {
return ck
}
}
t.Fatal("sign-in set no session cookie")
return nil
}
// sessionCookie returns the session cookie a response set, or nil.
func sessionCookie(cfg *Config, rec *httptest.ResponseRecorder) *http.Cookie {
for _, ck := range rec.Result().Cookies() {
if ck.Name == cfg.SessionManager.Cookie.Name {
return ck
}
}
return nil
}
// sessionState reads what a cookie's session holds, through the store, the
// way the next request would see it.
func sessionState(cfg *Config, ck *http.Cookie) (authenticated bool, personID, returnTo string) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.AddCookie(ck)
cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authenticated = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated)
personID = cfg.SessionManager.GetString(r.Context(), sessionKeyPersonID)
returnTo = cfg.SessionManager.GetString(r.Context(), sessionKeyReturnTo)
})).ServeHTTP(httptest.NewRecorder(), req)
return authenticated, personID, returnTo
}
func TestMiddleware_SessionPerson_Present_Proceeds(t *testing.T) {
cfg := newTestConfig()
cfg.IdentityQ = personDirectory{ids: map[string]bool{"p1": true}}
ck := signIn(t, cfg, "p1")
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/operator/persons", nil)
req.AddCookie(ck)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
if !nextCalled || rec.Code != http.StatusOK {
t.Fatalf("expected the request to reach the handler with 200; next called = %v, status = %d", nextCalled, rec.Code)
}
if authenticated, personID, _ := sessionState(cfg, ck); !authenticated || personID != "p1" {
t.Fatalf("expected the session to survive intact; authenticated = %v, person = %q", authenticated, personID)
}
}
func TestMiddleware_SessionPerson_Gone_EndsSessionAndRedirects(t *testing.T) {
cfg := newTestConfig()
cfg.IdentityQ = personDirectory{ids: map[string]bool{"p2": true}}
ck := signIn(t, cfg, "p1") // p1 is not in the directory: the row is gone
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("expected the middleware to end the session and bounce, not call next")
})
req := httptest.NewRequest(http.MethodGet, "/operator/persons?tab=billing", nil)
req.AddCookie(ck)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Fatalf("expected 302 to /login, got %d %q", rec.Code, rec.Header().Get("Location"))
}
// The old token is retired: a request still carrying it finds no
// session at all, not a cleared one.
if authenticated, personID, _ := sessionState(cfg, ck); authenticated || personID != "" {
t.Fatalf("expected the old session token to be dead; authenticated = %v, person = %q", authenticated, personID)
}
// The response issued a fresh, unauthenticated session that remembers
// where the request was headed (ACC-9), so sign-in lands back there.
fresh := sessionCookie(cfg, rec)
if fresh == nil {
t.Fatal("expected the bounce to set a fresh session cookie")
}
if fresh.Value == ck.Value {
t.Fatal("expected the bounce to issue a new session token, got the old one back")
}
authenticated, personID, returnTo := sessionState(cfg, fresh)
if authenticated || personID != "" {
t.Fatalf("expected the fresh session to be unauthenticated with no person; authenticated = %v, person = %q", authenticated, personID)
}
if returnTo != "/operator/persons?tab=billing" {
t.Fatalf("expected the fresh session to remember the destination, got return_to = %q", returnTo)
}
}
func TestMiddleware_SessionPerson_Gone_HTMXRequest_RespondsWithHXRedirect(t *testing.T) {
cfg := newTestConfig()
cfg.IdentityQ = personDirectory{ids: map[string]bool{}}
ck := signIn(t, cfg, "p1")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("expected the middleware to end the session and bounce, not call next")
})
req := httptest.NewRequest(http.MethodPost, "/partials/operator/plan-ladders/abc/tiers/reorder", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(ck)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if got := rec.Header().Get("HX-Redirect"); got != "/login" {
t.Fatalf("expected HX-Redirect %q, got %q", "/login", got)
}
if authenticated, _, _ := sessionState(cfg, ck); authenticated {
t.Fatal("expected the old session to be ended")
}
}
// A lookup that fails for any reason other than "no such row" must not
// sign everyone out: the request proceeds and the handler fails on its own
// terms if the database really is unreachable.
func TestMiddleware_SessionPerson_LookupError_Proceeds(t *testing.T) {
cfg := newTestConfig()
cfg.IdentityQ = failingDirectory{}
ck := signIn(t, cfg, "p1")
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/operator/persons", nil)
req.AddCookie(ck)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
if !nextCalled || rec.Code != http.StatusOK {
t.Fatalf("expected the request to proceed on a lookup error; next called = %v, status = %d", nextCalled, rec.Code)
}
if authenticated, _, _ := sessionState(cfg, ck); !authenticated {
t.Fatal("expected the session to survive a lookup error")
}
}
// An authenticated session that names no person at all is broken the same
// way and ends the same way.
func TestMiddleware_SessionPerson_Empty_EndsSession(t *testing.T) {
cfg := newTestConfig()
cfg.IdentityQ = personDirectory{ids: map[string]bool{"p1": true}}
ck := signIn(t, cfg, "")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("expected the middleware to end the session and bounce, not call next")
})
req := httptest.NewRequest(http.MethodGet, "/operator/persons", nil)
req.AddCookie(ck)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Fatalf("expected 302 to /login, got %d %q", rec.Code, rec.Header().Get("Location"))
}
}