feat(coordinator): UI login/register via userservice (cookie session)

When JWT_SECRET + USERSERVICE_URL are set, the operator UI authenticates
through userservice login/registration instead of the static UI_AUTH_TOKEN:
- /ui/login, /ui/register, /ui/logout pages proxy to the userservice
- successful login stores the JWT in an httpOnly, /ui-scoped cookie
- withUISession verifies the cookie locally and stamps the requester
- unset -> falls back to basic auth, so the team's existing flow is unchanged

Tests cover the session gate, cookie set/clear, and the login/register proxy.
This commit is contained in:
Efremenko Arhip
2026-07-26 19:54:39 +03:00
parent a7e949a0a7
commit 33f629f387
8 changed files with 469 additions and 15 deletions
+1 -1
View File
@@ -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
+6
View File
@@ -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"),
+60 -13
View File
@@ -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.
@@ -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()}
@@ -0,0 +1,27 @@
{{define "login.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
</head>
<body>
<main class="card">
<p class="eyebrow">SciMesh</p>
<h1>Sign in</h1>
<form method="post" action="/ui/login">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="button" type="submit">Sign in</button>
</form>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<p class="alt">No account? <a href="/ui/register">Register</a></p>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,28 @@
{{define "register.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Register · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.hint{margin:5px 0 0;color:#92a9c6;font-size:.85rem}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
</head>
<body>
<main class="card">
<p class="eyebrow">SciMesh</p>
<h1>Create account</h1>
<form method="post" action="/ui/register">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="72" required>
<p class="hint">At least 8 characters.</p>
<button class="button" type="submit">Register</button>
</form>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<p class="alt">Already have an account? <a href="/ui/login">Sign in</a></p>
</main>
</body>
</html>
{{end}}
@@ -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)
}
@@ -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")
}
}