diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go
index 2206bec..a2275f8 100644
--- a/coordinator/cmd/coordinator/main.go
+++ b/coordinator/cmd/coordinator/main.go
@@ -110,7 +110,7 @@ func run() error {
// pool.Ping backs /health: readiness means the database answers, not just
// that the process is alive.
- api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, pool.Ping)
+ api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, pool.Ping)
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
// Shutdown order matters, and defers alone cannot express it (they run
diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go
index 37f8d41..3d62e02 100644
--- a/coordinator/internal/infra/config.go
+++ b/coordinator/internal/infra/config.go
@@ -32,6 +32,11 @@ type Config struct {
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
// userservice's JWT_SECRET.
JWTSecret string
+ // Base URL of the userservice, e.g. http://userservice:8081. When set
+ // together with JWTSecret, the operator UI authenticates via userservice
+ // login/registration (cookie session) instead of the static UI_AUTH_TOKEN
+ // basic auth. Empty keeps the basic-auth UI.
+ UserserviceURL string
// Minimum log level: debug, info, warn, error.
LogLevel string
@@ -87,6 +92,7 @@ func LoadConfig() (Config, error) {
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
UIToken: os.Getenv("UI_AUTH_TOKEN"),
JWTSecret: os.Getenv("JWT_SECRET"),
+ UserserviceURL: os.Getenv("USERSERVICE_URL"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go
index b4e5a64..ecd44a3 100644
--- a/coordinator/internal/transport/http/server.go
+++ b/coordinator/internal/transport/http/server.go
@@ -7,6 +7,7 @@ import (
"context"
"log/slog"
"net/http"
+ "strings"
"time"
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
@@ -44,13 +45,18 @@ type Server struct {
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
// only the shared service token — the pre-userservice behaviour.
verifier *tokenpkg.Verifier
+ // userserviceURL is the base URL the UI proxies login/registration to. Empty
+ // keeps the static basic-auth UI.
+ userserviceURL string
+ // httpClient makes the login/register calls to the userservice.
+ httpClient *http.Client
// ready probes downstream dependencies (the database) for /health. Kept as
// a func so the transport layer never imports pgx.
ready func(context.Context) error
}
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
- maxUploadBytes int64, jwtSecret string, ready func(context.Context) error) *Server {
+ maxUploadBytes int64, jwtSecret, userserviceURL string, ready func(context.Context) error) *Server {
return &Server{
uc: uc,
log: log,
@@ -58,10 +64,19 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
heartbeatInterval: heartbeatInterval,
maxUploadBytes: maxUploadBytes,
verifier: tokenpkg.NewVerifier(jwtSecret),
+ userserviceURL: strings.TrimRight(userserviceURL, "/"),
+ httpClient: &http.Client{Timeout: 10 * time.Second},
ready: ready,
}
}
+// uiSessionMode reports whether the operator UI authenticates via userservice
+// login (cookie session) rather than the static basic-auth token. It needs both
+// a verifier (to check the JWT locally) and a userservice URL (to issue it).
+func (s *Server) uiSessionMode() bool {
+ return s.verifier != nil && s.userserviceURL != ""
+}
+
// Handler builds the router. Go 1.22's ServeMux matches on method and path
// wildcards, so no third-party router is needed.
func (s *Server) Handler(token string, uiToken ...string) http.Handler {
@@ -82,19 +97,51 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
- if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil {
+
+ hasBasicAuth := len(uiToken) > 0 && uiToken[0] != ""
+ if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) {
ui := http.NewServeMux()
- ui.HandleFunc("GET /ui", s.handleUIHome)
- ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob)
- ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob)
- ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON)
- ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON)
- ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
- ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
- ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
- ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview)
- mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
- mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
+
+ // The operator application routes, all requiring an authenticated caller.
+ app := []struct {
+ pattern string
+ handler http.HandlerFunc
+ }{
+ {"GET /ui", s.handleUIHome},
+ {"GET /ui/jobs/new", s.handleUINewJob},
+ {"GET /ui/jobs/{job_id}", s.handleUIJob},
+ {"GET /ui/api/overview", s.handleUIOverviewJSON},
+ {"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
+ {"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
+ {"POST /ui/api/jobs/upload", s.handleUploadDataset},
+ {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload},
+ {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview},
+ }
+
+ if s.uiSessionMode() {
+ // Public auth pages — reachable without a session so a user can log in.
+ ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
+ ui.HandleFunc("POST /ui/login", s.handleUILogin)
+ ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
+ ui.HandleFunc("POST /ui/register", s.handleUIRegister)
+ ui.HandleFunc("POST /ui/logout", s.handleUILogout)
+ gate := withUISession(s.verifier)
+ for _, rt := range app {
+ ui.Handle(rt.pattern, gate(rt.handler))
+ }
+ } else {
+ for _, rt := range app {
+ ui.HandleFunc(rt.pattern, rt.handler)
+ }
+ }
+
+ common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)}
+ if !s.uiSessionMode() {
+ common = append(common, withBasicAuth(uiToken[0]))
+ }
+ common = append(common, withSameOrigin)
+ mux.Handle("/ui", chain(ui, common...))
+ mux.Handle("/ui/", chain(ui, common...))
} else {
// More specific than the protected catch-all: UI absence is not an auth
// failure and does not disclose that a UI feature is configured elsewhere.
diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go
index e9463b3..220083c 100644
--- a/coordinator/internal/transport/http/server_test.go
+++ b/coordinator/internal/transport/http/server_test.go
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
if err != nil {
t.Fatalf("register test worker: %v", err)
}
- srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", ready)
+ srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", ready)
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
t.Cleanup(ts.Close)
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html
new file mode 100644
index 0000000..9482e91
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/login.html
@@ -0,0 +1,27 @@
+{{define "login.html"}}
+
+
+
+
+
+ Sign in · SciMesh
+
+
+
+
+ SciMesh
+ Sign in
+
+ {{if .Error}}{{.Error}}
{{end}}
+ No account? Register
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/templates/register.html b/coordinator/internal/transport/http/templates/register.html
new file mode 100644
index 0000000..e05e2fe
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/register.html
@@ -0,0 +1,28 @@
+{{define "register.html"}}
+
+
+
+
+
+ Register · SciMesh
+
+
+
+
+ SciMesh
+ Create account
+
+ {{if .Error}}{{.Error}}
{{end}}
+ Already have an account? Sign in
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go
new file mode 100644
index 0000000..4741126
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_auth.go
@@ -0,0 +1,171 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
+)
+
+// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so
+// page scripts cannot read the token, and scoped to /ui so it never rides along
+// with worker API calls.
+const sessionCookie = "scimesh_session"
+
+// withUISession gates the operator UI on a valid userservice session cookie.
+// A missing or invalid token redirects to the login page rather than returning
+// 401, because the caller here is a browser, not an API client. On success it
+// stamps the requester so downstream handlers can scope views by owner.
+func withUISession(v tokenVerifier) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil || c.Value == "" {
+ redirectToLogin(w, r)
+ return
+ }
+ claims, err := v.Verify(c.Value)
+ if err != nil {
+ // Expired or tampered: drop the stale cookie and re-authenticate.
+ clearSessionCookie(w, r)
+ redirectToLogin(w, r)
+ return
+ }
+ ctx := authctx.With(r.Context(), authctx.Requester{
+ UserID: claims.UserID,
+ Role: claims.Role,
+ Verified: claims.Verified,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the
+// UI auth testable with a stub.
+type tokenVerifier interface {
+ Verify(raw string) (tokenpkg.Claims, error)
+}
+
+func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
+ s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
+}
+
+func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
+ s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")})
+}
+
+// handleUILogin exchanges the submitted credentials for a userservice token and
+// stores it in the session cookie. The coordinator never sees or stores the
+// password beyond forwarding it once.
+func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
+ email, password := r.FormValue("email"), r.FormValue("password")
+
+ status, body, err := s.callUserservice(r.Context(), "/login", email, password)
+ if err != nil {
+ s.log.Error("userservice login call", "err", err)
+ http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ if status != http.StatusOK {
+ http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther)
+ return
+ }
+
+ var resp struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" {
+ http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ setSessionCookie(w, r, resp.Token)
+ http.Redirect(w, r, "/ui", http.StatusSeeOther)
+}
+
+// handleUIRegister creates an account through the userservice, then sends the
+// user to the login page. The new account is a plain user until an admin
+// promotes or verifies it.
+func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
+ email, password := r.FormValue("email"), r.FormValue("password")
+
+ status, _, err := s.callUserservice(r.Context(), "/register", email, password)
+ if err != nil {
+ s.log.Error("userservice register call", "err", err)
+ http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ switch status {
+ case http.StatusCreated:
+ http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther)
+ case http.StatusConflict:
+ http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther)
+ default:
+ http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther)
+ }
+}
+
+func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) {
+ clearSessionCookie(w, r)
+ http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
+}
+
+// callUserservice POSTs credentials to the userservice and returns its status
+// and body. It is the only runtime dependency on the userservice — login and
+// registration; token verification stays local.
+func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) {
+ payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Cap the response; login/register bodies are tiny.
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return 0, nil, err
+ }
+ return resp.StatusCode, body, nil
+}
+
+func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
+ // Secure is set under TLS; a local demo runs plain HTTP, where forcing
+ // Secure would stop the browser from ever sending the cookie back.
+ http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
+ Name: sessionCookie,
+ Value: token,
+ Path: "/ui",
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Now().Add(24 * time.Hour),
+ })
+}
+
+func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
+ Name: sessionCookie,
+ Value: "",
+ Path: "/ui",
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ })
+}
+
+func redirectToLogin(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
+}
diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go
new file mode 100644
index 0000000..081ad37
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_auth_internal_test.go
@@ -0,0 +1,175 @@
+package http
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
+)
+
+// newReq builds a request carrying a context, which http.NewRequestWithContext
+// provides on go1.22 (httptest.NewRequestWithContext needs go1.23).
+func newReq(method, target string, body io.Reader) *http.Request {
+ req, err := http.NewRequestWithContext(context.Background(), method, target, body)
+ if err != nil {
+ panic(err)
+ }
+ return req
+}
+
+type stubVerifier struct {
+ claims tokenpkg.Claims
+ err error
+}
+
+func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err }
+
+func TestWithUISessionRedirectsWithoutCookie(t *testing.T) {
+ h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("handler must not run without a session")
+ }))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil))
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("got %d, want 303", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "/ui/login" {
+ t.Errorf("redirect = %q, want /ui/login", loc)
+ }
+}
+
+func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) {
+ id := uuid.New()
+ verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}}
+
+ var gotReq authctx.Requester
+ var ok bool
+ h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ gotReq, ok = authctx.From(r.Context())
+ }))
+
+ req := newReq(http.MethodGet, "/ui", nil)
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified {
+ t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id)
+ }
+}
+
+func TestWithUISessionClearsInvalidCookie(t *testing.T) {
+ h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("handler must not run with an invalid token")
+ }))
+ req := newReq(http.MethodGet, "/ui", nil)
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("got %d, want 303", rec.Code)
+ }
+ if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 {
+ t.Error("stale cookie must be cleared (MaxAge < 0)")
+ }
+}
+
+// newLoginServer builds a Server whose userservice calls hit stub.
+func newLoginServer(stub *httptest.Server) *Server {
+ return &Server{
+ log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ userserviceURL: strings.TrimRight(stub.URL, "/"),
+ httpClient: stub.Client(),
+ }
+}
+
+func postForm(path string, form url.Values) *http.Request {
+ req := newReq(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return req
+}
+
+func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/login" {
+ t.Errorf("unexpected path %q", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`))
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
+
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
+ t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
+ t.Errorf("session cookie not set: %+v", cookies)
+ }
+ if !cookies[0].HttpOnly {
+ t.Error("session cookie must be httpOnly")
+ }
+}
+
+func TestHandleUILoginRejectsBadCredentials(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}}))
+
+ if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") {
+ t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location"))
+ }
+ if len(rec.Result().Cookies()) != 0 {
+ t.Error("no cookie must be set on failed login")
+ }
+}
+
+func TestHandleUIRegisterConflict(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}}))
+
+ if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") {
+ t.Errorf("register conflict redirect = %q", got)
+ }
+}
+
+func TestHandleUILogoutClearsCookie(t *testing.T) {
+ s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ rec := httptest.NewRecorder()
+ s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil))
+
+ if rec.Header().Get("Location") != "/ui/login" {
+ t.Errorf("logout redirect = %q", rec.Header().Get("Location"))
+ }
+ c := rec.Result().Cookies()
+ if len(c) == 0 || c[0].MaxAge >= 0 {
+ t.Error("logout must clear the session cookie")
+ }
+}