Files
mcdsl/web/web_test.go
Kyle Isom aba90a1de5 Add web package: session cookies, auth middleware, templates
- SetSessionCookie/ClearSessionCookie/GetSessionToken with
  HttpOnly, Secure, SameSite=Strict
- RequireAuth middleware: validates token, redirects to login,
  sets TokenInfo in context
- RenderTemplate: layout + page block pattern with FuncMap merge
- 9 tests with mock MCIAS and fstest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:30:52 -07:00

242 lines
6.0 KiB
Go

package web
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"git.wntrmute.dev/kyle/mcdsl/auth"
)
func TestSetSessionCookie(t *testing.T) {
rec := httptest.NewRecorder()
SetSessionCookie(rec, "my_token", "abc123")
cookies := rec.Result().Cookies()
var found *http.Cookie
for _, c := range cookies {
if c.Name == "my_token" {
found = c
break
}
}
if found == nil {
t.Fatal("cookie not set")
}
if found.Value != "abc123" {
t.Fatalf("value = %q, want %q", found.Value, "abc123")
}
if !found.HttpOnly {
t.Fatal("not HttpOnly")
}
if !found.Secure {
t.Fatal("not Secure")
}
if found.SameSite != http.SameSiteStrictMode {
t.Fatal("not SameSite=Strict")
}
}
func TestClearSessionCookie(t *testing.T) {
rec := httptest.NewRecorder()
ClearSessionCookie(rec, "my_token")
cookies := rec.Result().Cookies()
var found *http.Cookie
for _, c := range cookies {
if c.Name == "my_token" {
found = c
break
}
}
if found == nil {
t.Fatal("cookie not set")
}
if found.MaxAge != -1 {
t.Fatalf("MaxAge = %d, want -1", found.MaxAge)
}
if found.Value != "" {
t.Fatalf("Value = %q, want empty", found.Value)
}
}
func TestGetSessionToken(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "tok", Value: "mytoken"})
got := GetSessionToken(req, "tok")
if got != "mytoken" {
t.Fatalf("got %q, want %q", got, "mytoken")
}
}
func TestGetSessionTokenMissing(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
got := GetSessionToken(req, "tok")
if got != "" {
t.Fatalf("got %q, want empty", got)
}
}
// mockMCIAS returns a test HTTP server for auth testing.
func mockMCIAS(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad", http.StatusBadRequest)
return
}
if req.Token == "valid-token" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "testuser",
"roles": []string{"user"},
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false})
})
return httptest.NewServer(mux)
}
func newTestAuth(t *testing.T, serverURL string) *auth.Authenticator {
t.Helper()
a, err := auth.New(auth.Config{ServerURL: serverURL}, slog.Default())
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return a
}
func TestRequireAuthRedirectsWhenMissing(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := newTestAuth(t, srv.URL)
handler := RequireAuth(a, "session", "/login")(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("handler should not be called")
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want %q", loc, "/login")
}
}
func TestRequireAuthRedirectsWhenInvalid(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := newTestAuth(t, srv.URL)
handler := RequireAuth(a, "session", "/login")(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("handler should not be called")
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "bad-token"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
}
}
func TestRequireAuthPassesValid(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := newTestAuth(t, srv.URL)
var gotInfo *auth.TokenInfo
handler := RequireAuth(a, "session", "/login")(
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotInfo = auth.TokenInfoFromContext(r.Context())
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "valid-token"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if gotInfo == nil {
t.Fatal("TokenInfo not in context")
}
if gotInfo.Username != "testuser" {
t.Fatalf("Username = %q, want %q", gotInfo.Username, "testuser")
}
}
func TestRenderTemplate(t *testing.T) {
testFS := fstest.MapFS{
"templates/layout.html": &fstest.MapFile{
Data: []byte(`{{define "layout"}}<!DOCTYPE html><html>{{template "content" .}}</html>{{end}}`),
},
"templates/page.html": &fstest.MapFile{
Data: []byte(`{{define "content"}}<h1>{{.Title}}</h1>{{end}}`),
},
}
rec := httptest.NewRecorder()
RenderTemplate(rec, fs.FS(testFS), "page.html", map[string]string{"Title": "Hello"})
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q", ct)
}
if body != "<!DOCTYPE html><html><h1>Hello</h1></html>" {
t.Fatalf("body = %q", body)
}
}
func TestRenderTemplateWithFuncMap(t *testing.T) {
testFS := fstest.MapFS{
"templates/layout.html": &fstest.MapFile{
Data: []byte(`{{define "layout"}}{{upper .Name}}{{end}}`),
},
"templates/page.html": &fstest.MapFile{
Data: []byte(`{{define "content"}}unused{{end}}`),
},
}
rec := httptest.NewRecorder()
RenderTemplate(rec, fs.FS(testFS), "page.html",
map[string]string{"Name": "hello"},
map[string]any{
"upper": func(s string) string {
return "HELLO"
},
},
)
if rec.Body.String() != "HELLO" {
t.Fatalf("body = %q, want %q", rec.Body.String(), "HELLO")
}
}