diff --git a/internal/adapters/store/migrate_test.go b/internal/adapters/store/migrate_test.go new file mode 100644 index 0000000..2bca650 --- /dev/null +++ b/internal/adapters/store/migrate_test.go @@ -0,0 +1,61 @@ +package store_test + +import ( + "context" + "database/sql" + "os" + "testing" + + "github.com/golang-migrate/migrate/v4" + migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/stretchr/testify/require" + + _ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver +) + +// fileMigrator builds a golang-migrate instance from the on-disk migration files +// (not the embedded FS the production Migrate uses), so a test can step the schema +// up and down. os.DirFS(".") is rooted at the package dir; the SQL lives under +// "migrations". Mirrors store.Migrate's construction otherwise. +func fileMigrator(t *testing.T) *migrate.Migrate { + t.Helper() + db, err := sql.Open("pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + drv, err := migratepgx.WithInstance(db, &migratepgx.Config{}) + require.NoError(t, err) + src, err := iofs.New(os.DirFS("."), "migrations") + require.NoError(t, err) + m, err := migrate.NewWithInstance("iofs", src, "pgx", drv) + require.NoError(t, err) + t.Cleanup(func() { _, _ = m.Close() }) + return m +} + +// loginEventsExists reports whether the login_events relation is present. +func loginEventsExists(t *testing.T) bool { + t.Helper() + var reg *string + require.NoError(t, rawPool(t).QueryRow(context.Background(), + `SELECT to_regclass('public.login_events')::text`).Scan(®)) + return reg != nil +} + +// TestMigration010LoginEventsUpDown proves migration 010 is reversible: the down +// migration drops login_events cleanly and the up migration recreates it. A rotten +// down migration (forgotten DROP, dangling policy) would fail here rather than in +// production during a rollback. The test restores the schema to latest before +// returning so the shared embedded-postgres stays at HEAD for sibling tests. +func TestMigration010LoginEventsUpDown(t *testing.T) { + newStore(t) // ensure the schema is migrated to latest (010 applied) + require.True(t, loginEventsExists(t), "login_events must exist at latest migration") + + m := fileMigrator(t) + require.NoError(t, m.Steps(-1), "down one migration must drop login_events") + require.False(t, loginEventsExists(t), "login_events must be gone after the down migration") + + require.NoError(t, m.Steps(1), "up one migration must recreate login_events") + require.True(t, loginEventsExists(t), "login_events must be restored after the up migration") +} diff --git a/internal/adapters/store/migrations/010_login_events.down.sql b/internal/adapters/store/migrations/010_login_events.down.sql new file mode 100644 index 0000000..1fafa94 --- /dev/null +++ b/internal/adapters/store/migrations/010_login_events.down.sql @@ -0,0 +1,4 @@ +DROP POLICY IF EXISTS login_events_isolation ON login_events; +ALTER TABLE login_events NO FORCE ROW LEVEL SECURITY; +ALTER TABLE login_events DISABLE ROW LEVEL SECURITY; +DROP TABLE IF EXISTS login_events; diff --git a/internal/adapters/store/migrations/010_login_events.up.sql b/internal/adapters/store/migrations/010_login_events.up.sql new file mode 100644 index 0000000..e90ab0b --- /dev/null +++ b/internal/adapters/store/migrations/010_login_events.up.sql @@ -0,0 +1,33 @@ +-- Migration 010: login_events records THAT a user was active (returned and read) +-- on a given day — the Stage-0 signal summary_actions misses. summary_actions +-- captures *acts* (watch/skip/save); a reader who logs in weekly and clicks +-- nothing is otherwise invisible, yet for a reading product that return IS the +-- signal the gate ("usage in >=2 distinct weeks", VISION/ADR-016) is defined on. +-- +-- Append-only: one row per user per active day (the request-path throttle in the +-- web layer enforces that cadence), never updated. Per-user isolation like every +-- user-owned table. +-- +-- NO foreign key to users (mirrors summary_actions, migration 002): user_id is +-- carried for RLS/scoping but the table is decoupled so a stamp never blocks on a +-- users row. The cost of that decoupling: the users-row cascade does NOT reach +-- login_events, so DeleteUser must delete it explicitly (see account.go) — the +-- exact footgun the summary_actions delete work caught. +CREATE TABLE login_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- (user_id, seen_at) serves both the per-user-per-day throttle lookup +-- (seen_at >= start-of-today) and the gate query's per-user week bucketing. +CREATE INDEX idx_login_events_user_seen ON login_events(user_id, seen_at); + +-- RLS: identical GUC-keyed policy/pattern to migration 003. FORCE so the table +-- owner (tapir, non-superuser in prod) is subject to it; an unset GUC yields NULL +-- → no rows match → deny-all. +ALTER TABLE login_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE login_events FORCE ROW LEVEL SECURITY; +CREATE POLICY login_events_isolation ON login_events + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); diff --git a/internal/adapters/store/store_test.go b/internal/adapters/store/store_test.go index 623d3ed..06c6ff8 100644 --- a/internal/adapters/store/store_test.go +++ b/internal/adapters/store/store_test.go @@ -74,7 +74,7 @@ func rawPool(t *testing.T) *pgxpool.Pool { func resetDB(t *testing.T, p *pgxpool.Pool) { t.Helper() _, err := p.Exec(context.Background(), - `TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) + `TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) require.NoError(t, err) } diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go index 710b1b6..94dc049 100644 --- a/internal/web/handlers_test.go +++ b/internal/web/handlers_test.go @@ -72,7 +72,7 @@ func rawPool(t *testing.T) *pgxpool.Pool { 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`) + `TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) require.NoError(t, err) }