feat(web): registration gate + per-request user-id seam (ADR-012)
Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex session required) wraps registrationGate, which resolves the authenticated subject -> tapir user_id once per request via the new web.Identity port and stashes it. A subject with no tapir user is redirected to GET /register (display name + accept-terms); POST /register calls RegisterUser then redirects to /. /register is inside the auth guard but exempt from the gate (/auth/* and /healthz too). Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id from the request context. The list/detail/action handlers now scope by it, replacing the single configured App.UserID (removed). App gains an Identity field; *store.Store satisfies both Store and Identity. cmd/tapir wires Identity: st and drops UserID. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnregisteredSubjectRedirectedToRegister(t *testing.T) {
|
||||
app := newAppAs(t, "unregistered-sub")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
require.Equal(t, "/register", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestRegisterPageReachableWhenUnregistered(t *testing.T) {
|
||||
app := newAppAs(t, "unregistered-sub")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "/register is exempt from the gate")
|
||||
require.Contains(t, body(t, rec), "Complete your registration")
|
||||
}
|
||||
|
||||
func TestRegisteredSubjectPassesThrough(t *testing.T) {
|
||||
app := newApp(t) // stubSubject
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, body(t, rec), "<html", "registered subject gets the app, not a redirect")
|
||||
}
|
||||
|
||||
func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const sub = "brand-new-subject"
|
||||
app := newAppAs(t, sub)
|
||||
p := rawPool(t)
|
||||
truncateAll(t, p)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||
strings.NewReader("display_name=Newbie&accept_terms=yes"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := do(t, app, req)
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||
|
||||
// Exactly one identity row for the subject, and its user exists.
|
||||
var idents int
|
||||
var newID string
|
||||
require.NoError(t, p.QueryRow(ctx,
|
||||
`SELECT count(*), coalesce(max(user_id::text), '') FROM user_identities WHERE dex_subject = $1`,
|
||||
sub).Scan(&idents, &newID))
|
||||
require.Equal(t, 1, idents)
|
||||
|
||||
var users int
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, newID).Scan(&users))
|
||||
require.Equal(t, 1, users)
|
||||
|
||||
// Returning subject resolves straight through — no second user created.
|
||||
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var totalUsers, totalIdents int
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&totalUsers))
|
||||
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&totalIdents))
|
||||
require.Equal(t, 1, totalUsers, "a second request must not register again")
|
||||
require.Equal(t, 1, totalIdents)
|
||||
}
|
||||
|
||||
func TestRegisterRejectsMissingFields(t *testing.T) {
|
||||
app := newAppAs(t, "incomplete-subject")
|
||||
truncateAll(t, rawPool(t))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||
strings.NewReader("display_name=&accept_terms=")) // both missing
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := do(t, app, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user