The callback now starts a session only when the verified groups claim
contains a group from MEALQUEST_OIDC_ALLOWED_GROUPS. When the ID token has
no groups claim, groups come from the userinfo endpoint, whose subject must
match the ID token. A refused sign-in gets a 403 HTML page ("This kitchen is
for the household only") with a sign-out button, and no session. The check
runs on every login, so an account removed from the group is refused at its
next one. auth.New requires at least one allowed group and a discovered
userinfo endpoint that passes the endpoint rule.
The RequireSession middleware wraps the /api/v1 and /media subrouters: no
live session gets 401 {"error":{"code":"unauthenticated",...}}, a lookup
failure 500, and a live session reaches the handler with the actor in the
context. It wraps the subrouters instead of using chi's Use because chi
skips middleware on a subrouter with no routes. /health, /auth and the web
build stay public.
auth.WithActor and auth.ActorFrom are the one way to read the signed-in
household member; handlers record Actor.Subject as updated_by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
5.9 KiB
Go
219 lines
5.9 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
fixtureIndex = "<!doctype html><title>fixture index</title>"
|
|
fixtureAsset = "console.log('fixture asset')"
|
|
notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
|
)
|
|
|
|
func newTestRouter(t *testing.T, version string) http.Handler {
|
|
t.Helper()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
assets := fstest.MapFS{
|
|
"index.html": {Data: []byte(fixtureIndex)},
|
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
|
}
|
|
r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
|
|
type stubAuth struct{}
|
|
|
|
func (stubAuth) Mount(r chi.Router) {
|
|
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
|
io.WriteString(w, "stub login")
|
|
})
|
|
}
|
|
|
|
func TestAuthRoutesMounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
|
|
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
|
|
t.Cleanup(srv.Close)
|
|
|
|
resp, err := http.Get(srv.URL + "/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var got map[string]string
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]string{"status": "ok", "version": "1.2.3"}
|
|
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
|
|
t.Errorf("body = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHealthRejectsOtherMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var got struct {
|
|
Error map[string]any `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]any{
|
|
"code": "method_not_allowed",
|
|
"message": "method not allowed",
|
|
"retryable": false,
|
|
}
|
|
if !maps.Equal(got.Error, want) {
|
|
t.Errorf("error body = %v, want %v", got.Error, want)
|
|
}
|
|
}
|
|
|
|
func TestReservedPathsNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
tests := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/api"},
|
|
{http.MethodGet, "/api/unknown"},
|
|
{http.MethodGet, "/api/v1/unknown"},
|
|
{http.MethodPost, "/api/v1/unknown"},
|
|
{http.MethodPost, "/api/unknown"},
|
|
{http.MethodGet, "/auth/unknown"},
|
|
{http.MethodGet, "/media/photo.jpg"},
|
|
{http.MethodGet, "/health/deep"},
|
|
{http.MethodGet, "/auth/../quests/42"},
|
|
{http.MethodGet, "/api%2F..%2Fquests/42"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Signed in, so /api/v1 and /media reach their routers.
|
|
req := httptest.NewRequest(tt.method, tt.path, nil)
|
|
req.AddCookie(validSessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
if got := rec.Body.String(); got != notFoundJSON {
|
|
t.Errorf("body = %s, want %s", got, notFoundJSON)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFrontend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
tests := []struct {
|
|
path string
|
|
wantBody string
|
|
wantCache string
|
|
}{
|
|
{"/", fixtureIndex, "no-cache"},
|
|
{"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
|
|
{"/quests/42", fixtureIndex, "no-cache"},
|
|
{"/quests/42/edit", fixtureIndex, "no-cache"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != tt.wantBody {
|
|
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
|
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHealthNotShadowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
|
|
|
if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
|
|
t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestFrontendRejectsOtherMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rec.Code)
|
|
}
|
|
if strings.Contains(rec.Body.String(), fixtureIndex) {
|
|
t.Errorf("body = %q, want no index.html", rec.Body.String())
|
|
}
|
|
}
|