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.
225 lines
8.8 KiB
Go
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"))
|
|
}
|
|
}
|