feat(store): add append-only login_events table with forced RLS
Stage-0 usage measurement (VISION/ADR-016): summary_actions captures acts
(watch/skip/save) but not reads. A reader who logs in weekly and clicks
nothing is invisible — for a reading product that return is the signal the
gate ("usage in >=2 distinct weeks") is defined on. login_events records
THAT a user was active, append-only, one row per user per active day.
Per-user isolation via the same GUC-keyed FORCE RLS policy as migration 003.
No FK to users (mirrors summary_actions) — the cascade footgun is handled by
DeleteUser in a later commit. Adds an up/down reversibility test and registers
the table in both truncate helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user