feat(web): stamp login_events on every gated request
The registration gate, once it resolves the authenticated subject to a tapir user_id, calls StampLogin (store-throttled to one row per user per day). Best- effort: a stamp failure is logged and swallowed so it never breaks the request. This is what makes the read-side Stage-0 usage signal actually accrue. Tests cover the happy-path stamp, the same-day throttle, and that an unregistered subject (redirected to /register) is never stamped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,11 @@ type Store interface {
|
|||||||
DeleteConnection(ctx context.Context, userID, provider string) error
|
DeleteConnection(ctx context.Context, userID, provider string) error
|
||||||
DeleteUser(ctx context.Context, userID string) error
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
DisplayName(ctx context.Context, userID string) (string, error)
|
DisplayName(ctx context.Context, userID string) (string, error)
|
||||||
|
|
||||||
|
// StampLogin records (throttled, one row per user per day) that the resolved
|
||||||
|
// user was active on this request — the read-side Stage-0 usage signal the
|
||||||
|
// registration gate stamps for every authenticated request.
|
||||||
|
StampLogin(ctx context.Context, userID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
|
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +75,48 @@ func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
|
|||||||
require.Equal(t, 1, totalIdents)
|
require.Equal(t, 1, totalIdents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginEventCount counts login_events for the stub user via the raw pool.
|
||||||
|
func loginEventCount(t *testing.T, p *pgxpool.Pool) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM login_events WHERE user_id = $1`, userID).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGateStampsLoginEventThrottled: a gated request for a registered user stamps
|
||||||
|
// exactly one login event, and a same-day repeat is throttled to no new row — the
|
||||||
|
// read-side Stage-0 signal flowing from the registration gate.
|
||||||
|
func TestGateStampsLoginEventThrottled(t *testing.T) {
|
||||||
|
app := newApp(t) // stubSubject → userID
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
require.Equal(t, 0, loginEventCount(t, p))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Equal(t, 1, loginEventCount(t, p), "a gated request must stamp one login event")
|
||||||
|
|
||||||
|
do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, 1, loginEventCount(t, p), "a same-day repeat must not stamp again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregisteredSubjectIsNotStamped: a subject with no tapir user is redirected
|
||||||
|
// to /register and never reaches the stamp (no user_id to attribute it to).
|
||||||
|
func TestUnregisteredSubjectIsNotStamped(t *testing.T) {
|
||||||
|
app := newAppAs(t, "unregistered-sub")
|
||||||
|
p := rawPool(t)
|
||||||
|
truncateAll(t, p)
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM login_events`).Scan(&n))
|
||||||
|
require.Equal(t, 0, n, "an unregistered subject must not stamp a login event")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterRejectsMissingFields(t *testing.T) {
|
func TestRegisterRejectsMissingFields(t *testing.T) {
|
||||||
app := newAppAs(t, "incomplete-subject")
|
app := newAppAs(t, "incomplete-subject")
|
||||||
truncateAll(t, rawPool(t))
|
truncateAll(t, rawPool(t))
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ func (a *App) registrationGate(h http.Handler) http.Handler {
|
|||||||
http.Redirect(w, r, registerPath, http.StatusFound)
|
http.Redirect(w, r, registerPath, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Stamp the read-side usage signal (Stage-0 gate, ADR-016): the store
|
||||||
|
// throttles this to one row per user per day, so a stamp on every gated
|
||||||
|
// request is cheap. Best-effort — a stamp failure must never break the
|
||||||
|
// request the user actually came for, so it is logged and swallowed.
|
||||||
|
if err := a.Store.StampLogin(r.Context(), userID); err != nil {
|
||||||
|
a.logger().Warn("stamp login event", "user", userID, "err", err)
|
||||||
|
}
|
||||||
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
|
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user