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:
@@ -45,6 +45,9 @@ const (
|
||||
userID = "11111111-1111-1111-1111-111111111111"
|
||||
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
// stubSubject is the StubAuth Dex subject the registration gate resolves to
|
||||
// the fixed userID (mapping seeded by resetDB).
|
||||
stubSubject = "stub-subject-xyz"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *store.Store {
|
||||
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
|
||||
return p
|
||||
}
|
||||
|
||||
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
||||
// truncateAll wipes every table to a pristine state (user_identities is cleared
|
||||
// via the ON DELETE CASCADE from users). Registration tests use this directly so
|
||||
// no subject is pre-registered.
|
||||
func truncateAll(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(),
|
||||
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
|
||||
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
|
||||
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
|
||||
// the registration gate resolves the stub user and the existing handler tests can
|
||||
// keep seeding and scoping by the fixed userID.
|
||||
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
truncateAll(t, p)
|
||||
ctx := context.Background()
|
||||
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||
require.NoError(t, err)
|
||||
_, err = p.Exec(ctx,
|
||||
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newApp builds the App under test as the registered stub user (subject
|
||||
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
|
||||
// minus Dex: the store is both the Store and the Identity port.
|
||||
func newApp(t *testing.T) *web.App {
|
||||
t.Helper()
|
||||
return newAppAs(t, stubSubject)
|
||||
}
|
||||
|
||||
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
|
||||
// registration-gate tests can drive registered vs unregistered subjects.
|
||||
func newAppAs(t *testing.T, subject string) *web.App {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
return &web.App{
|
||||
Store: newStore(t),
|
||||
Auth: web.StubAuth{U: web.User{Subject: userID}},
|
||||
UserID: userID,
|
||||
Store: s,
|
||||
Identity: s,
|
||||
Auth: web.StubAuth{U: web.User{Subject: subject}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user