From 9ac062c1965282a955712447424931a776bd450d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:29:28 +0200 Subject: [PATCH 01/26] feat(gonsole/auth/sqlite): add the module with the accounts and records schema steps --- gonsole/auth/sqlite/doc.go | 4 + gonsole/auth/sqlite/go.mod | 31 +++++ gonsole/auth/sqlite/go.sum | 80 ++++++++++++ gonsole/auth/sqlite/helpers_test.go | 49 +++++++ .../migrations/00001_create_records.sql | 17 +++ gonsole/auth/sqlite/schema.go | 71 ++++++++++ gonsole/auth/sqlite/schema_internal_test.go | 17 +++ gonsole/auth/sqlite/schema_test.go | 123 ++++++++++++++++++ 8 files changed, 392 insertions(+) create mode 100644 gonsole/auth/sqlite/doc.go create mode 100644 gonsole/auth/sqlite/go.mod create mode 100644 gonsole/auth/sqlite/go.sum create mode 100644 gonsole/auth/sqlite/helpers_test.go create mode 100644 gonsole/auth/sqlite/migrations/00001_create_records.sql create mode 100644 gonsole/auth/sqlite/schema.go create mode 100644 gonsole/auth/sqlite/schema_internal_test.go create mode 100644 gonsole/auth/sqlite/schema_test.go diff --git a/gonsole/auth/sqlite/doc.go b/gonsole/auth/sqlite/doc.go new file mode 100644 index 0000000..0f5f340 --- /dev/null +++ b/gonsole/auth/sqlite/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package sqlite runs the account commands and command records of gonsole/auth on a program's one SQLite handle. +package sqlite diff --git a/gonsole/auth/sqlite/go.mod b/gonsole/auth/sqlite/go.mod new file mode 100644 index 0000000..6ae743b --- /dev/null +++ b/gonsole/auth/sqlite/go.mod @@ -0,0 +1,31 @@ +module github.com/gopherium/framework/gonsole/auth/sqlite + +go 1.27.2 + +require ( + github.com/gopherium/framework/dbkit v0.2.0 + github.com/gopherium/framework/dbkit/sqlite v0.1.0 + github.com/gopherium/framework/gonsole v0.7.0 + github.com/gopherium/gouncer/authkit/sqlite v0.1.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gopherium/gouncer v0.4.0 // indirect + github.com/gopherium/gouncer/authkit v0.19.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pressly/goose/v3 v3.27.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.60.1 // indirect +) diff --git a/gonsole/auth/sqlite/go.sum b/gonsole/auth/sqlite/go.sum new file mode 100644 index 0000000..bec0f0d --- /dev/null +++ b/gonsole/auth/sqlite/go.sum @@ -0,0 +1,80 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherium/framework/dbkit v0.2.0 h1:V+Di+B8oGvdmQKPx5q/a/PbgfHOvRAHhCFfQ5g8DojM= +github.com/gopherium/framework/dbkit v0.2.0/go.mod h1:sGsNkpbHe1LRW7k1qEjTzqbeHVYOnoCI5e1l/81TAvQ= +github.com/gopherium/framework/dbkit/sqlite v0.1.0 h1:NmfcEuhlV5/SpqqZHHeN1qtA6FBDwERGmYFCHMXy5YA= +github.com/gopherium/framework/dbkit/sqlite v0.1.0/go.mod h1:43gIZC9JkU/PeAvKLVjEubbe6TOz+1rjIANf615QEOI= +github.com/gopherium/framework/gonsole v0.7.0 h1:JluS72WcE6LXcBihJB+5zdhX7IUiLZxv0g059z8T3/U= +github.com/gopherium/framework/gonsole v0.7.0/go.mod h1:6ZLlrT2q5W8Pk2BrD7WgorL6b68nx2n2OX1Rg8FKwAo= +github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4TvI= +github.com/gopherium/gouncer v0.4.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= +github.com/gopherium/gouncer/authkit v0.19.0 h1:4TPPakAHNmqG0H+7c62YomNgDfd7uRbuccIXvbdutKc= +github.com/gopherium/gouncer/authkit v0.19.0/go.mod h1:oJP/pk0xnXA4HhOlFoqm4lh9ulScCUbgGeLFrquZ6g8= +github.com/gopherium/gouncer/authkit/sqlite v0.1.0 h1:pyPOkXVy5CzYR+tTkqL/6FuVcwBKf9FXCZe2XDcgwl0= +github.com/gopherium/gouncer/authkit/sqlite v0.1.0/go.mod h1:Rd+jedhpfi4GaQ34RrdmlqQgIvVB4FHN4ftGCAa+gfs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/gonsole/auth/sqlite/helpers_test.go b/gonsole/auth/sqlite/helpers_test.go new file mode 100644 index 0000000..8a9b128 --- /dev/null +++ b/gonsole/auth/sqlite/helpers_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "database/sql" + "testing" + "time" + + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// testMigrate is how every test run of a schema step waits for the migration lock. +var testMigrate = sqlite.MigrateOptions{LockWait: 5 * time.Second, LockPoll: 10 * time.Millisecond} + +// testOptions returns the handle options every test opens with. +func testOptions() dbsqlite.Options { + return dbsqlite.Options{ + BusyTimeout: 5 * time.Second, CacheSize: 2048, MaxConns: 4, Synchronous: dbsqlite.SynchronousNormal, + } +} + +// steps returns the two schema steps in the order a program runs them. +func steps(opts sqlite.MigrateOptions) []gonsole.Step { + return []gonsole.Step{sqlite.Migration(opts), sqlite.RecordMigration(opts)} +} + +// applied runs both schema steps on db and fails t when one errs. +func applied(t *testing.T, db *sql.DB) { + t.Helper() + for _, step := range steps(testMigrate) { + if err := step.Apply(t.Context(), "", db); err != nil { + t.Fatalf("applying the %s step: %v", step.Name, err) + } + } +} + +// count returns the single integer lookup answers on db. +func count(t *testing.T, db *sql.DB, lookup string) int { + t.Helper() + var n int + if err := db.QueryRowContext(t.Context(), lookup).Scan(&n); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + return n +} diff --git a/gonsole/auth/sqlite/migrations/00001_create_records.sql b/gonsole/auth/sqlite/migrations/00001_create_records.sql new file mode 100644 index 0000000..2f15b4d --- /dev/null +++ b/gonsole/auth/sqlite/migrations/00001_create_records.sql @@ -0,0 +1,17 @@ +-- SPDX-License-Identifier: Apache-2.0 + +-- +goose Up +CREATE TABLE gonsole_records ( + id TEXT NOT NULL PRIMARY KEY, + applied_at INTEGER NOT NULL, + actor TEXT NOT NULL, + account_id TEXT, + command TEXT NOT NULL, + args TEXT NOT NULL CHECK (json_valid(args)), + flags TEXT NOT NULL CHECK (json_valid(flags)) +); + +CREATE INDEX gonsole_records_applied_at ON gonsole_records (applied_at DESC); + +-- +goose Down +DROP TABLE gonsole_records; diff --git a/gonsole/auth/sqlite/schema.go b/gonsole/auth/sqlite/schema.go new file mode 100644 index 0000000..6a66f81 --- /dev/null +++ b/gonsole/auth/sqlite/schema.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + "time" + + authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" + + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/gonsole" +) + +// migrationFiles holds the migrations of the command records. +// +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// recordsVersionTable is the goose version table of the records migrations, beside any other owner's in the file. +const recordsVersionTable = "gonsole_goose_db_version" + +// migrationSource holds the records migrations at its root. +var migrationSource = mustSub(migrationFiles, "migrations") + +// MigrateOptions sets how a run of a schema step waits for the migration lock of the database file. +type MigrateOptions struct { + // LockWait is how long a run waits for the migration lock, above zero. + LockWait time.Duration + // LockPoll is how often a waiting run tries the migration lock, above zero. + LockPoll time.Duration +} + +// Migration returns the step that applies gouncer's account tables on the run's one handle. +func Migration(opts MigrateOptions) gonsole.Step { + lock := authkitsqlite.MigrateOptions{LockWait: opts.LockWait, LockPoll: opts.LockPoll} + return gonsole.Step{Name: "accounts", RunOn: func(ctx context.Context, db *sql.DB) error { + return authkitsqlite.Migrate(ctx, db, lock) + }} +} + +// RecordMigration returns the step that applies the table keeping the command records on the run's one handle. +func RecordMigration(opts MigrateOptions) gonsole.Step { + return gonsole.Step{Name: "records", RunOn: func(ctx context.Context, db *sql.DB) error { + return migrateRecords(ctx, db, opts) + }} +} + +// migrateRecords applies the records migrations to db under the migration lock of its file. +func migrateRecords(ctx context.Context, db *sql.DB, opts MigrateOptions) error { + err := dbsqlite.Migrate(ctx, db, dbsqlite.Migrations{ + Table: recordsVersionTable, FS: migrationSource, LockWait: opts.LockWait, LockPoll: opts.LockPoll, + }) + if err != nil { + return fmt.Errorf("sqlite: apply the records schema: %w", dbsqlite.Classify(err)) + } + return nil +} + +// mustSub returns the subtree of fsys at dir, and panics when dir is not a valid path. +func mustSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/gonsole/auth/sqlite/schema_internal_test.go b/gonsole/auth/sqlite/schema_internal_test.go new file mode 100644 index 0000000..9c614a6 --- /dev/null +++ b/gonsole/auth/sqlite/schema_internal_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite + +import "testing" + +func TestMustSubPanicsOnAFolderOutsideTheTree(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Fatal("mustSub(..) did not panic, want a panic") + } + }() + + mustSub(migrationFiles, "..") +} diff --git a/gonsole/auth/sqlite/schema_test.go b/gonsole/auth/sqlite/schema_test.go new file mode 100644 index 0000000..c3fc5c6 --- /dev/null +++ b/gonsole/auth/sqlite/schema_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/gopherium/framework/dbkit" + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" + + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// insertRecord stores a record row with the given values for the columns that have no default. +const insertRecord = "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) " + + "VALUES (?, ?, ?, ?, ?, ?)" + +func TestBothStepsMigrateTheirOwnTablesUnderTheirOwnNames(t *testing.T) { + t.Parallel() + + db := sqlitetest.Open(t, testOptions()) + + var names []string + for _, step := range steps(testMigrate) { + names = append(names, step.Name) + } + applied(t, db) + applied(t, db) + + tables := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name IN ('auth_users', 'gonsole_records')") + versions := count(t, db, "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN "+ + "('auth_goose_db_version', 'gonsole_goose_db_version')") + records := count(t, db, "SELECT max(version_id) FROM gonsole_goose_db_version") + if !slices.Equal(names, []string{"accounts", "records"}) || tables != 2 || versions != 2 || records != 1 { + t.Errorf("steps %v left %d of the 2 tables and %d of the 2 version tables, records at %d, "+ + "want accounts then records, both tables, both version tables and records at 1", names, tables, + versions, records) + } +} + +func TestTheRecordsStepRefusesLockSettingsAtZero(t *testing.T) { + t.Parallel() + + db := sqlitetest.Open(t, testOptions()) + + err := sqlite.RecordMigration(sqlite.MigrateOptions{}).Apply(t.Context(), "", db) + + tables := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name = 'gonsole_records'") + if err == nil || !strings.HasPrefix(err.Error(), "sqlite: apply the records schema: ") || tables != 0 { + t.Errorf("the records step with no lock settings = %v with %d records tables, want a refusal and none", + err, tables) + } +} + +func TestTheRecordsStepHeldPastTheBusyTimeoutAnswersDbkitsBusyClass(t *testing.T) { + t.Parallel() + + opts := testOptions() + opts.BusyTimeout = 50 * time.Millisecond + db := sqlitetest.Open(t, opts) + writer, err := db.BeginTx(t.Context(), nil) + if err != nil { + t.Fatalf("beginning the writer: %v", err) + } + t.Cleanup(func() { _ = writer.Rollback() }) + if _, err := writer.ExecContext(t.Context(), "CREATE TABLE core_items (id TEXT NOT NULL PRIMARY KEY)"); err != nil { + t.Fatalf("writing inside the writer: %v", err) + } + + err = sqlite.RecordMigration(testMigrate).Apply(context.Background(), "", db) + + if !errors.Is(err, dbkit.ErrBusy) || !strings.HasPrefix(err.Error(), "sqlite: apply the records schema: ") { + t.Errorf("the records step behind a write past the busy timeout = %v, want dbkit.ErrBusy under the step", err) + } +} + +func TestTheRecordsTableRefusesNullsAndInvalidJSON(t *testing.T) { + t.Parallel() + + db := sqlitetest.Open(t, testOptions()) + applied(t, db) + valid := []any{"019a0000-0000-7000-8000-000000000001", int64(1767225600000000), "admin@example.com", + "account:role", "[]", "{}"} + + for column := range valid { + row := slices.Clone(valid) + row[column] = nil + _, err := db.ExecContext(t.Context(), insertRecord, row...) + if got := dbsqlite.Classify(err); !errors.Is(got, dbkit.ErrNotNull) { + t.Errorf("a record with column %d NULL = %v, want dbkit.ErrNotNull", column, got) + } + } + for _, column := range []int{4, 5} { + row := slices.Clone(valid) + row[column] = "not json" + _, err := db.ExecContext(t.Context(), insertRecord, row...) + if got := dbsqlite.Classify(err); !errors.Is(got, dbkit.ErrCheck) { + t.Errorf("a record with column %d holding invalid JSON = %v, want dbkit.ErrCheck", column, got) + } + } +} + +func TestTheRecordsIndexCarriesItsName(t *testing.T) { + t.Parallel() + + db := sqlitetest.Open(t, testOptions()) + applied(t, db) + + var definition string + lookup := "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'gonsole_records_applied_at'" + if err := db.QueryRowContext(t.Context(), lookup).Scan(&definition); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + if !strings.HasSuffix(definition, "(applied_at DESC)") { + t.Errorf("the records index reads %q, want it on applied_at DESC", definition) + } +} From c1fde41c11c2c5554d73f617c414b011e85cab2f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:29:29 +0200 Subject: [PATCH 02/26] chore(gonsole/auth/sqlite): lint the module with a strict import list, the SQLite driver deny list and forbidigo --- gonsole/auth/sqlite/.golangci.yml | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 gonsole/auth/sqlite/.golangci.yml diff --git a/gonsole/auth/sqlite/.golangci.yml b/gonsole/auth/sqlite/.golangci.yml new file mode 100644 index 0000000..3e519cf --- /dev/null +++ b/gonsole/auth/sqlite/.golangci.yml @@ -0,0 +1,83 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - forbidigo + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + auth-purity: + list-mode: strict + files: + - "**/*.go" + allow: + - $gostd + - github.com/gopherium/framework/gonsole + - github.com/gopherium/framework/dbkit$ + - github.com/gopherium/framework/dbkit/sqlite + - github.com/gopherium/gouncer$ + - github.com/gopherium/gouncer/authkit$ + - github.com/gopherium/gouncer/authkit/sqlite + - github.com/google/uuid + one-sqlite-driver: + deny: + - pkg: modernc.org/sqlite + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + - pkg: modernc.org/libc + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + - pkg: github.com/mattn/go-sqlite3 + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + - pkg: github.com/ncruces/go-sqlite3 + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + - pkg: github.com/glebarez/go-sqlite + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + - pkg: turso.tech/database/tursogo + desc: open SQLite through github.com/gopherium/framework/dbkit/sqlite + forbidigo: + analyze-types: true + forbid: + - pattern: ^sql\.Open(DB)?$ + pkg: ^database/sql$ + msg: take the run's database handle from gonsole.Call.DB + - pattern: ^sqlite\.Open$ + pkg: ^github\.com/gopherium/framework/dbkit/sqlite$ + msg: take the run's database handle from gonsole.Call.DB + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + - path: _test\.go + linters: + - forbidigo + text: "sqlite\\.Open" + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework From db83892fb7c52f0530e3cf10e087a963bad3eb5a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:29:29 +0200 Subject: [PATCH 03/26] test(gonsole/auth/sqlite): run the two steps one after the other under the migration lock --- gonsole/auth/sqlite/lock_test.go | 96 ++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 gonsole/auth/sqlite/lock_test.go diff --git a/gonsole/auth/sqlite/lock_test.go b/gonsole/auth/sqlite/lock_test.go new file mode 100644 index 0000000..77dcc3a --- /dev/null +++ b/gonsole/auth/sqlite/lock_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux || darwin + +package sqlite_test + +import ( + "database/sql" + "fmt" + "os" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" + + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// migrationLock opens the migration lock file beside the main file of db. +func migrationLock(t *testing.T, db *sql.DB) *os.File { + t.Helper() + var path string + lookup := "SELECT file FROM pragma_database_list WHERE name = 'main'" + if err := db.QueryRowContext(t.Context(), lookup).Scan(&path); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + lock, err := os.OpenFile(path+".migrate.lock", os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + t.Fatalf("opening the migration lock: %v", err) + } + return lock +} + +// lockFree reports whether the migration lock beside the main file of db can be taken now, then lets it go. +func lockFree(t *testing.T, db *sql.DB) bool { + t.Helper() + lock := migrationLock(t, db) + defer func() { _ = lock.Close() }() + return syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) == nil +} + +func TestStepsRunOneAfterTheOther(t *testing.T) { + t.Parallel() + + db := sqlitetest.Open(t, testOptions()) + + var events []string + for _, step := range steps(testMigrate) { + accounts := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name = 'auth_users'") == 1 + events = append(events, fmt.Sprintf("%s started with the accounts table %t", step.Name, accounts)) + if err := step.Apply(t.Context(), "", db); err != nil { + t.Fatalf("applying the %s step: %v", step.Name, err) + } + events = append(events, fmt.Sprintf("%s ended with the lock free %t", step.Name, lockFree(t, db))) + } + + want := []string{"accounts started with the accounts table false", "accounts ended with the lock free true", + "records started with the accounts table true", "records ended with the lock free true"} + if !slices.Equal(events, want) { + t.Errorf("the steps ran as %q, want %q", events, want) + } +} + +func TestEachStepGivesUpOnAMigrationLockHeldPastItsWait(t *testing.T) { + t.Parallel() + + short := sqlite.MigrateOptions{LockWait: 100 * time.Millisecond, LockPoll: 10 * time.Millisecond} + for _, step := range steps(short) { + t.Run(step.Name, func(t *testing.T) { + t.Parallel() + db := sqlitetest.Open(t, testOptions()) + lock := migrationLock(t, db) + t.Cleanup(func() { _ = lock.Close() }) + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + t.Fatalf("holding the migration lock: %v", err) + } + + start := time.Now() + err := step.Apply(t.Context(), "", db) + waited := time.Since(start) + + versions := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name LIKE '%goose_db_version'") + if err == nil || !strings.Contains(err.Error(), "migration lock") || versions != 0 { + t.Errorf("the %s step behind a held lock = %v with %d version tables, want the lock named and none", + step.Name, err, versions) + } + if waited < short.LockWait { + t.Errorf("the %s step gave up after %v, want it to wait out the lock wait of %v", step.Name, waited, + short.LockWait) + } + }) + } +} From b71f155abacbcad65b2d5a3c7b4fce33e674d7a6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:29:30 +0200 Subject: [PATCH 04/26] test(gonsole/auth/sqlite): match the records migration numbers with gonsole/auth --- gonsole/auth/sqlite/numbers_test.go | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 gonsole/auth/sqlite/numbers_test.go diff --git a/gonsole/auth/sqlite/numbers_test.go b/gonsole/auth/sqlite/numbers_test.go new file mode 100644 index 0000000..ff7e571 --- /dev/null +++ b/gonsole/auth/sqlite/numbers_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// migrationNumbers returns the version numbers of the migration files in folder. +func migrationNumbers(t *testing.T, folder string) []string { + t.Helper() + entries, err := os.ReadDir(folder) + if err != nil { + t.Fatalf("reading %s: %v", folder, err) + } + var numbers []string + for _, entry := range entries { + number, _, found := strings.Cut(entry.Name(), "_") + if found && strings.HasSuffix(entry.Name(), ".sql") { + numbers = append(numbers, number) + } + } + return numbers +} + +func TestRecordsMigrationNumbersMatchGonsoleAuth(t *testing.T) { + t.Parallel() + + if _, err := os.Stat(filepath.Join("..", "go.mod")); errors.Is(err, os.ErrNotExist) { + t.Skip("gonsole/auth is not beside this module") + } + + sqliteNumbers := migrationNumbers(t, "migrations") + postgresNumbers := migrationNumbers(t, filepath.Join("..", "migrations")) + + if !slices.Equal(sqliteNumbers, postgresNumbers) { + t.Errorf("records migration numbers are %v on SQLite and %v on PostgreSQL, want the same", + sqliteNumbers, postgresNumbers) + } +} From 74b03a14c7cc54d0577d08a42558a8e04a49fba4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:29:30 +0200 Subject: [PATCH 05/26] test(gonsole/auth/sqlite): check modernc.org/libc against the driver's version --- gonsole/auth/sqlite/libc_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 gonsole/auth/sqlite/libc_test.go diff --git a/gonsole/auth/sqlite/libc_test.go b/gonsole/auth/sqlite/libc_test.go new file mode 100644 index 0000000..21b1ac0 --- /dev/null +++ b/gonsole/auth/sqlite/libc_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "testing" + + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" +) + +func TestTheBuildRunsTheLibcTheDriverWasMadeFor(t *testing.T) { + t.Parallel() + + sqlitetest.CheckLibc(t) +} From 87557eb909cdf25425469305419e42e06faf5d7a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:38:11 +0200 Subject: [PATCH 06/26] feat(gonsole/auth/sqlite): keep the command records in gonsole_records and pass recordstest --- gonsole/auth/sqlite/contract_test.go | 41 +++++++++ gonsole/auth/sqlite/export_test.go | 25 ++++++ gonsole/auth/sqlite/go.mod | 9 +- gonsole/auth/sqlite/go.sum | 26 ++++++ gonsole/auth/sqlite/helpers_test.go | 45 ++++++++++ gonsole/auth/sqlite/records.go | 124 +++++++++++++++++++++++++++ 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 gonsole/auth/sqlite/contract_test.go create mode 100644 gonsole/auth/sqlite/export_test.go create mode 100644 gonsole/auth/sqlite/records.go diff --git a/gonsole/auth/sqlite/contract_test.go b/gonsole/auth/sqlite/contract_test.go new file mode 100644 index 0000000..bea03b4 --- /dev/null +++ b/gonsole/auth/sqlite/contract_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "testing" + + authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole/auth/recordstest" + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// recordsFixture returns the contract fixture of a record store over a fresh copy holding the account tables only. +func recordsFixture(t *testing.T) recordstest.Fixture { + db := accountsOnly(t) + accounts := authkitsqlite.NewUserStore(db) + return recordstest.Fixture{ + Records: sqlite.NewRecordStore(db), + Migrate: func(ctx context.Context) error { + return sqlite.RecordMigration(testMigrate).Apply(ctx, "", db) + }, + Account: func(ctx context.Context, email string) (string, error) { + user, err := gouncer.NewUser(email, "recorded account", "correct horse battery") + if err != nil { + return "", err + } + return user.ID.String(), accounts.CreateUser(ctx, user) + }, + Plant: sqlite.PlantOn(db), + } +} + +func TestSQLiteRecordsKeepTheContract(t *testing.T) { + t.Parallel() + + recordstest.Run(t, recordsFixture) +} diff --git a/gonsole/auth/sqlite/export_test.go b/gonsole/auth/sqlite/export_test.go new file mode 100644 index 0000000..ec0912b --- /dev/null +++ b/gonsole/auth/sqlite/export_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite + +import ( + "context" + "database/sql" + "time" + + "github.com/gopherium/framework/gonsole/auth" +) + +const ( + // HeldRecords is the exact text of the statement Held runs. + HeldRecords = heldRecords + // StoreRecord is the exact text of the statement Insert runs. + StoreRecord = storeRecord + // LatestRecords is the exact text of the statement Latest runs. + LatestRecords = latestRecords +) + +// PlantOn returns the hook that stores an entry in db as Insert does, but as applied at appliedAt. +func PlantOn(db *sql.DB) func(ctx context.Context, entry auth.Entry, appliedAt time.Time) error { + return recordStore{db: db}.insert +} diff --git a/gonsole/auth/sqlite/go.mod b/gonsole/auth/sqlite/go.mod index 6ae743b..8072aeb 100644 --- a/gonsole/auth/sqlite/go.mod +++ b/gonsole/auth/sqlite/go.mod @@ -6,14 +6,20 @@ require ( github.com/gopherium/framework/dbkit v0.2.0 github.com/gopherium/framework/dbkit/sqlite v0.1.0 github.com/gopherium/framework/gonsole v0.7.0 + github.com/gopherium/framework/gonsole/auth v0.7.0 + github.com/gopherium/gouncer v0.4.0 github.com/gopherium/gouncer/authkit/sqlite v0.1.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gopherium/gouncer v0.4.0 // indirect github.com/gopherium/gouncer/authkit v0.19.0 // indirect + github.com/gopherium/gouncer/authkit/postgres v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -24,6 +30,7 @@ require ( golang.org/x/crypto v0.54.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.41.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/gonsole/auth/sqlite/go.sum b/gonsole/auth/sqlite/go.sum index bec0f0d..4c8da0b 100644 --- a/gonsole/auth/sqlite/go.sum +++ b/gonsole/auth/sqlite/go.sum @@ -1,7 +1,10 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -12,20 +15,36 @@ github.com/gopherium/framework/dbkit/sqlite v0.1.0 h1:NmfcEuhlV5/SpqqZHHeN1qtA6F github.com/gopherium/framework/dbkit/sqlite v0.1.0/go.mod h1:43gIZC9JkU/PeAvKLVjEubbe6TOz+1rjIANf615QEOI= github.com/gopherium/framework/gonsole v0.7.0 h1:JluS72WcE6LXcBihJB+5zdhX7IUiLZxv0g059z8T3/U= github.com/gopherium/framework/gonsole v0.7.0/go.mod h1:6ZLlrT2q5W8Pk2BrD7WgorL6b68nx2n2OX1Rg8FKwAo= +github.com/gopherium/framework/gonsole/auth v0.7.0 h1:e1s0LJlLK2zPmrzexzMix7pkiTIXqRe0bd83MzYsRiE= +github.com/gopherium/framework/gonsole/auth v0.7.0/go.mod h1:t9PxTMk18NqcoXgxokdUQnOvNPYfcW2HXVjx8fPxei0= github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4TvI= github.com/gopherium/gouncer v0.4.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= github.com/gopherium/gouncer/authkit v0.19.0 h1:4TPPakAHNmqG0H+7c62YomNgDfd7uRbuccIXvbdutKc= github.com/gopherium/gouncer/authkit v0.19.0/go.mod h1:oJP/pk0xnXA4HhOlFoqm4lh9ulScCUbgGeLFrquZ6g8= +github.com/gopherium/gouncer/authkit/postgres v0.13.0 h1:9uN7xZirufk0osM/LxMj0K1L8ZZ5MrUBsSFze/SuurA= +github.com/gopherium/gouncer/authkit/postgres v0.13.0/go.mod h1:wnA/EmvSqV3w8n3L4EYTejY1bnYjP7Cd7O+sRtf9bkw= github.com/gopherium/gouncer/authkit/sqlite v0.1.0 h1:pyPOkXVy5CzYR+tTkqL/6FuVcwBKf9FXCZe2XDcgwl0= github.com/gopherium/gouncer/authkit/sqlite v0.1.0/go.mod h1:Rd+jedhpfi4GaQ34RrdmlqQgIvVB4FHN4ftGCAa+gfs= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= +github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= +github.com/peterldowns/pgtestdb v0.1.1 h1:+hBCD1DcbKeg5Sfg0G+5WNIy/Cm0ORgwMkF4ygihrmU= +github.com/peterldowns/pgtestdb v0.1.1/go.mod h1:yVWInWV0dxvmLdL2ao3nXDzWZ9+G6EhJ4gRwvI1Ozeg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= @@ -34,6 +53,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -46,8 +68,12 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= diff --git a/gonsole/auth/sqlite/helpers_test.go b/gonsole/auth/sqlite/helpers_test.go index 8a9b128..ece61fa 100644 --- a/gonsole/auth/sqlite/helpers_test.go +++ b/gonsole/auth/sqlite/helpers_test.go @@ -3,11 +3,15 @@ package sqlite_test import ( + "context" "database/sql" + "fmt" + "os" "testing" "time" dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/auth/sqlite" @@ -47,3 +51,44 @@ func count(t *testing.T, db *sql.DB, lookup string) int { } return n } + +// mustExec runs statement with args on db and fails t when it errs. +func mustExec(t *testing.T, db *sql.DB, statement string, args ...any) { + t.Helper() + if _, err := db.ExecContext(t.Context(), statement, args...); err != nil { + t.Fatalf("%s: %v", statement, err) + } +} + +// accountsOnly returns a handle on a fresh copy of a file holding the account tables and no records table. +func accountsOnly(t *testing.T) *sql.DB { + t.Helper() + return template.Open(t) +} + +// withRecords returns a handle on a fresh copy of a file holding the account tables and the records table. +func withRecords(t *testing.T) *sql.DB { + t.Helper() + db := accountsOnly(t) + if err := sqlite.RecordMigration(testMigrate).Apply(t.Context(), "", db); err != nil { + t.Fatalf("applying the records step: %v", err) + } + return db +} + +// template is the file holding the account tables each test opens a copy of. +var template *sqlitetest.Template + +// TestMain builds the template holding the account tables, runs the tests, then removes the template. +func TestMain(m *testing.M) { + accounts := sqlite.Migration(testMigrate) + built, err := sqlitetest.NewTemplate(context.Background(), testOptions(), accounts.RunOn) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "building the account template:", err) + os.Exit(1) + } + template = built + code := m.Run() + _ = template.Close() + os.Exit(code) +} diff --git a/gonsole/auth/sqlite/records.go b/gonsole/auth/sqlite/records.go new file mode 100644 index 0000000..f3cb487 --- /dev/null +++ b/gonsole/auth/sqlite/records.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "maps" + "time" + + "github.com/gopherium/framework/dbkit" + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/gonsole/auth" +) + +// heldRecords reads whether the file holds the gonsole_records table. +const heldRecords = `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'gonsole_records')` + +// storeRecord inserts one record, finding the account the acting address names. +const storeRecord = `INSERT INTO gonsole_records (id, actor, account_id, command, args, flags, applied_at) +VALUES (?1, ?2, (SELECT id FROM auth_users WHERE email = ?2), ?3, ?4, ?5, ?6)` + +// latestRecords reads the newest records first, as many as the limit allows. +const latestRecords = `SELECT id, applied_at, actor, account_id, command, args, flags +FROM gonsole_records ORDER BY applied_at DESC, id DESC LIMIT ?1` + +// recordStore is the record store of the gonsole_records table in a program's SQLite file. +type recordStore struct { + // db is the run's one handle. + db *sql.DB +} + +// NewRecordStore returns the record store of the SQLite file behind db. +func NewRecordStore(db *sql.DB) auth.RecordStore { + return recordStore{db: db} +} + +// Held reports whether the file holds the gonsole_records table. +func (r recordStore) Held(ctx context.Context) (bool, error) { + var held bool + if err := r.db.QueryRowContext(ctx, heldRecords).Scan(&held); err != nil { + return false, failed("check the records", err) + } + return held, nil +} + +// Insert stores entry as applied now, finding the account its actor names in auth_users. +func (r recordStore) Insert(ctx context.Context, entry auth.Entry) error { + return r.insert(ctx, entry, time.Now()) +} + +// insert stores entry as applied at appliedAt, finding the account its actor names in auth_users. +func (r recordStore) insert(ctx context.Context, entry auth.Entry, appliedAt time.Time) error { + args, flags := encoded(entry) + _, err := r.db.ExecContext(ctx, storeRecord, entry.ID, entry.Actor, entry.Command, args, flags, + dbkit.Time{Time: appliedAt}) + return failed("insert a record", err) +} + +// Latest returns the newest entries first, at most limit of them, and an empty list for none. +func (r recordStore) Latest(ctx context.Context, limit int) ([]auth.Entry, error) { + rows, err := r.db.QueryContext(ctx, latestRecords, limit) + if err != nil { + return nil, failed("list the records", err) + } + held, err := collected(rows) + if err != nil { + return nil, failed("list the records", err) + } + return held, nil +} + +// collected returns the entries of rows in their order, an empty list for none, and closes rows. +func collected(rows *sql.Rows) ([]auth.Entry, error) { + defer func() { _ = rows.Close() }() + held := []auth.Entry{} + for rows.Next() { + entry, err := scanned(rows) + if err != nil { + return nil, err + } + held = append(held, entry) + } + return held, rows.Err() +} + +// scanned returns the entry of the current row, its arguments and flags decoded from their JSON text. +func scanned(rows *sql.Rows) (auth.Entry, error) { + var ( + entry auth.Entry + appliedAt dbkit.Time + args, flags string + ) + err := rows.Scan(&entry.ID, &appliedAt, &entry.Actor, &entry.AccountID, &entry.Command, &args, &flags) + if err != nil { + return auth.Entry{}, err + } + entry.AppliedAt = appliedAt.Time + err = errors.Join(json.Unmarshal([]byte(args), &entry.Args), json.Unmarshal([]byte(flags), &entry.Flags)) + if err != nil { + return auth.Entry{}, fmt.Errorf("decode the record %s: %w", entry.ID, err) + } + return entry, nil +} + +// encoded returns the JSON text of the entry's arguments and flags, an empty list and object for none. +func encoded(entry auth.Entry) (string, string) { + flags := map[string]string{} + maps.Copy(flags, entry.Flags) + args, _ := json.Marshal(append([]string{}, entry.Args...)) + object, _ := json.Marshal(flags) + return string(args), string(object) +} + +// failed returns nil for a nil err, or err marked with its dbkit class under action. +func failed(action string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("sqlite: %s: %w", action, dbsqlite.Classify(err)) +} From ee1ebde51bfaf3ceba94c04e14f58b911e987941 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:38:12 +0200 Subject: [PATCH 07/26] test(gonsole/auth/sqlite): store record times as UTC microseconds and list them in UTC --- gonsole/auth/sqlite/records_test.go | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 gonsole/auth/sqlite/records_test.go diff --git a/gonsole/auth/sqlite/records_test.go b/gonsole/auth/sqlite/records_test.go new file mode 100644 index 0000000..b045670 --- /dev/null +++ b/gonsole/auth/sqlite/records_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "testing" + "time" + + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// recordID is the id of the record the store tests insert. +const recordID = "019a0000-0000-7000-8000-000000000001" + +// roleChange returns an entry of an applied role change by the admin account. +func roleChange() auth.Entry { + return auth.Entry{ID: recordID, Actor: "admin@example.com", Command: "account:role", + Args: []string{"maria@example.com", "editor"}, Flags: map[string]string{"yes": "true"}} +} + +func TestARecordKeepsItsTimeAsUTCMicrosecondsSinceTheEpoch(t *testing.T) { + t.Parallel() + + db := withRecords(t) + store := sqlite.NewRecordStore(db) + before := time.Now() + if err := store.Insert(t.Context(), roleChange()); err != nil { + t.Fatalf("Insert() error = %v, want nil", err) + } + after := time.Now() + + var kind string + var stored int64 + lookup := "SELECT typeof(applied_at), applied_at FROM gonsole_records" + if err := db.QueryRowContext(t.Context(), lookup).Scan(&kind, &stored); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + listed, err := store.Latest(t.Context(), 1) + if err != nil || len(listed) != 1 { + t.Fatalf("Latest() = %v, %v, want the one record", listed, err) + } + if kind != "integer" || stored < before.UnixMicro() || stored > after.UnixMicro() { + t.Errorf("applied_at is %s %d, want an integer between %d and %d", kind, stored, before.UnixMicro(), + after.UnixMicro()) + } + if got := listed[0].AppliedAt; got.Location() != time.UTC || got.UnixMicro() != stored { + t.Errorf("Latest() lists the record at %v, want %d microseconds in UTC", got, stored) + } +} From a4c5474861d7467313f882cee68e280de2312231 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 22:38:12 +0200 Subject: [PATCH 08/26] test(gonsole/auth/sqlite): class each record store failure under its action --- gonsole/auth/sqlite/failures_test.go | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 gonsole/auth/sqlite/failures_test.go diff --git a/gonsole/auth/sqlite/failures_test.go b/gonsole/auth/sqlite/failures_test.go new file mode 100644 index 0000000..6743b81 --- /dev/null +++ b/gonsole/auth/sqlite/failures_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" + + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +// errPlanted is the failure a chosen statement answers with. +var errPlanted = errors.New("planted failure") + +// plantRow stores a records row with every column given, past the store. +const plantRow = "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) " + + "VALUES (?, ?, 'admin@example.com', 'account:role', ?, ?)" + +// wantUnder fails t unless err matches want under the action the store names. +func wantUnder(t *testing.T, err, want error, action string) { + t.Helper() + if !errors.Is(err, want) || !strings.HasPrefix(err.Error(), "sqlite: "+action+": ") { + t.Errorf("the call answered %v, want %v under %q", err, want, "sqlite: "+action+": ") + } +} + +func TestARepeatedRecordIDAnswersDbkitsUniqueClass(t *testing.T) { + t.Parallel() + + store := sqlite.NewRecordStore(withRecords(t)) + if err := store.Insert(t.Context(), roleChange()); err != nil { + t.Fatalf("Insert() error = %v, want nil", err) + } + + err := store.Insert(t.Context(), roleChange()) + + wantUnder(t, err, dbkit.ErrUnique, "insert a record") +} + +func TestEveryStatementFailureReachesTheCallerUnderItsAction(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + statement, action string + call func(ctx context.Context, store auth.RecordStore) error + }{ + {sqlite.HeldRecords, "check the records", func(ctx context.Context, store auth.RecordStore) error { + _, err := store.Held(ctx) + return err + }}, + {sqlite.StoreRecord, "insert a record", func(ctx context.Context, store auth.RecordStore) error { + return store.Insert(ctx, roleChange()) + }}, + {sqlite.LatestRecords, "list the records", func(ctx context.Context, store auth.RecordStore) error { + _, err := store.Latest(ctx, 10) + return err + }}, + } { + t.Run(tc.action, func(t *testing.T) { + t.Parallel() + faults := &sqlitetest.Faults{} + db := template.OpenWithFaults(t, faults) + if err := sqlite.RecordMigration(testMigrate).Apply(t.Context(), "", db); err != nil { + t.Fatalf("applying the records step: %v", err) + } + faults.FailStatement(tc.statement, errPlanted) + + err := tc.call(t.Context(), sqlite.NewRecordStore(db)) + + wantUnder(t, err, errPlanted, tc.action) + }) + } +} + +func TestARecordThatListsBadlyFailsTheWholeList(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + appliedAt any + args string + }{ + {"arguments that are a JSON object", int64(1767225600000000), "{}"}, + {"a time that is no integer", "yesterday", "[]"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db := withRecords(t) + mustExec(t, db, plantRow, recordID, tc.appliedAt, tc.args, "{}") + + listed, err := sqlite.NewRecordStore(db).Latest(t.Context(), 10) + + if listed != nil || err == nil || !strings.HasPrefix(err.Error(), "sqlite: list the records: ") { + t.Errorf("Latest() over a record with %s = %v, %v, want no list and a failure under the action", + tc.name, listed, err) + } + }) + } +} From d00c4cd474b400e48891689aadec1e9933b63a12 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 23:22:02 +0200 Subject: [PATCH 09/26] feat(gonsole/auth/sqlite): build the account and record stores on the run's one handle --- gonsole/auth/sqlite/program_test.go | 97 +++++++++++++++++++++++++++++ gonsole/auth/sqlite/stores.go | 21 +++++++ gonsole/auth/sqlite/stores_test.go | 59 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 gonsole/auth/sqlite/program_test.go create mode 100644 gonsole/auth/sqlite/stores.go create mode 100644 gonsole/auth/sqlite/stores_test.go diff --git a/gonsole/auth/sqlite/program_test.go b/gonsole/auth/sqlite/program_test.go new file mode 100644 index 0000000..d0ebf78 --- /dev/null +++ b/gonsole/auth/sqlite/program_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "database/sql" + "io" + "sync/atomic" + "testing" + "time" + + authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" + + "github.com/gopherium/gouncer" + + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/sqlite" + "github.com/gopherium/framework/gonsole/testkit" +) + +// demoPassword is the password the seeded accounts log in with. +const demoPassword = "correct horse battery" + +// vocabulary is the role vocabulary of the test program, where only an admin may manage users. +var vocabulary = auth.Roles{ + Known: []string{"admin", "editor", "author"}, Privileged: gouncer.Roles{"admin"}, + Capabilities: map[string][]string{"admin": {"manage_users"}}, +} + +// testConfig returns the account commands' configuration on the run's one SQLite handle. +func testConfig() auth.Config { + return auth.Config{ + Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return vocabulary, nil }, + Capability: "manage_users", RecordTimeout: 5 * time.Second, RecordsLimit: 10, Stores: sqlite.Stores, + } +} + +// program returns a program called myapp over the SQLite file at path, counting each open of its handle in opens. +func program(path string, opens *atomic.Int32) gonsole.Program { + cfg := testConfig() + settings := map[string]string{"MYAPP_DATABASE_URL": "sqlite:" + path} + return gonsole.Program{ + Name: "myapp", + Env: gonsole.Env{Prefix: "MYAPP_", Getenv: testkit.Getenv(settings)}, + Database: "DATABASE_URL", + Open: func(_ context.Context, address string) (*sql.DB, error) { + opens.Add(1) + opts := testOptions() + opts.Create = true + return dbsqlite.Open(address, opts) + }, + Migrations: steps(testMigrate), + Commands: append(auth.Commands(cfg), auth.Records(cfg)), + Authorize: auth.Authorize(cfg), + Record: auth.Record(cfg), + } +} + +// pathOf returns the path of the main file behind db. +func pathOf(t *testing.T, db *sql.DB) string { + t.Helper() + var path string + lookup := "SELECT file FROM pragma_database_list WHERE name = 'main'" + if err := db.QueryRowContext(t.Context(), lookup).Scan(&path); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + return path +} + +// seeded returns the path of a migrated file holding one enabled account per role, and the test's own handle on it. +func seeded(t *testing.T) (string, *sql.DB) { + t.Helper() + db := withRecords(t) + var accounts []auth.Account + for _, role := range vocabulary.Known { + accounts = append(accounts, auth.Account{ + Email: role + "@example.com", Name: role + " account", Password: demoPassword, Role: role, + }) + } + if err := auth.EnsureAccounts(t.Context(), authkitsqlite.NewUserStore(db), accounts, io.Discard); err != nil { + t.Fatalf("seeding the accounts: %v", err) + } + return pathOf(t, db), db +} + +// text returns the single text lookup answers on db. +func text(t *testing.T, db *sql.DB, lookup string) string { + t.Helper() + var value string + if err := db.QueryRowContext(t.Context(), lookup).Scan(&value); err != nil { + t.Fatalf("%s: %v", lookup, err) + } + return value +} diff --git a/gonsole/auth/sqlite/stores.go b/gonsole/auth/sqlite/stores.go new file mode 100644 index 0000000..f230b55 --- /dev/null +++ b/gonsole/auth/sqlite/stores.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite + +import ( + "context" + + authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" +) + +// Stores returns the account and record stores of call on the run's one handle, which the run owns and closes. +func Stores(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) { + db, err := call.DB(ctx) + if err != nil { + return auth.Stores{}, nil, err + } + return auth.Stores{Accounts: authkitsqlite.NewUserStore(db), Records: NewRecordStore(db)}, nil, nil +} diff --git a/gonsole/auth/sqlite/stores_test.go b/gonsole/auth/sqlite/stores_test.go new file mode 100644 index 0000000..fcf1c3e --- /dev/null +++ b/gonsole/auth/sqlite/stores_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "errors" + "testing" + + authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/sqlite" +) + +var ( + _ auth.Accounts = (*authkitsqlite.UserStore)(nil) + _ = auth.Config{Stores: sqlite.Stores} +) + +func TestTheStoresRunOnTheCallsOneHandleWithNothingToRelease(t *testing.T) { + t.Parallel() + + db := withRecords(t) + + stores, release, err := sqlite.Stores(t.Context(), gonsole.Call{}.WithDB(db)) + + if err != nil || release != nil || stores.Accounts == nil || stores.Records == nil { + t.Fatalf("Stores() = %v, a release %t, %v, want both stores and no release", stores, release != nil, err) + } + user, err := gouncer.NewUser("maria@example.com", "Maria Perez", "correct horse battery") + if err != nil { + t.Fatalf("gouncer.NewUser() error = %v, want nil", err) + } + if err := stores.Accounts.CreateUser(t.Context(), user); err != nil { + t.Fatalf("CreateUser() error = %v, want nil", err) + } + held, err := stores.Records.Held(t.Context()) + accounts := count(t, db, "SELECT count(*) FROM auth_users") + if err != nil || !held || accounts != 1 { + t.Errorf("the stores read the records as held %t with %v and wrote %d accounts, want both on the handle", + held, err, accounts) + } +} + +func TestTheStoresPassTheHandleFailureThroughUnchanged(t *testing.T) { + t.Parallel() + + _, _, missing := sqlite.Stores(t.Context(), gonsole.Call{}) + _, _, describing := sqlite.Stores(t.Context(), gonsole.Call{Describe: true}) + + if missing == nil || missing.Error() != "gonsole: no database handle in this call" || + !errors.Is(describing, gonsole.ErrDescribing) || describing != gonsole.ErrDescribing { + t.Errorf("Stores() answered %v with no handle and %v for a describing call, want gonsole's errors as they are", + missing, describing) + } +} From f77cc19879cf2c809acf08e0ee77dd1a1a59e8b1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 23:22:03 +0200 Subject: [PATCH 10/26] test(gonsole/auth/sqlite): refuse a disabled, unactivated or incapable account in Authorize --- gonsole/auth/sqlite/authorize_test.go | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 gonsole/auth/sqlite/authorize_test.go diff --git a/gonsole/auth/sqlite/authorize_test.go b/gonsole/auth/sqlite/authorize_test.go new file mode 100644 index 0000000..e50d977 --- /dev/null +++ b/gonsole/auth/sqlite/authorize_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "sync/atomic" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/testkit" +) + +func TestAuthorizeRefusesAnAccountItCannotLetThrough(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, change, actor, stderr string + }{ + {"a role that lacks the capability", "", "editor@example.com", + "myapp: the account editor@example.com holds the role editor, which lacks manage_users\n"}, + {"a disabled account", "UPDATE auth_users SET disabled = 1 WHERE email = 'admin@example.com'", + "admin@example.com", "myapp: the account admin@example.com is disabled\n"}, + {"an account never activated", "UPDATE auth_users SET confirmed = 0 WHERE email = 'admin@example.com'", + "admin@example.com", "myapp: the account admin@example.com was never activated\n"}, + {"an account holding no role", "UPDATE auth_users SET role = '' WHERE email = 'author@example.com'", + "author@example.com", "myapp: the account author@example.com holds no role, so it lacks manage_users\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + path, db := seeded(t) + if tc.change != "" { + mustExec(t, db, tc.change) + } + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", + "-as", tc.actor) + + records := count(t, db, "SELECT count(*) FROM gonsole_records") + if got.Code != gonsole.ExitFailed || got.Stderr != tc.stderr || records != 0 { + t.Errorf("account:role as %s = %d with %q and %d records, want %d with %q and none", tc.actor, + got.Code, got.Stderr, records, gonsole.ExitFailed, tc.stderr) + } + }) + } +} + +func TestAuthorizeLetsACapableAccountThrough(t *testing.T) { + t.Parallel() + + path, db := seeded(t) + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", + "-as", "admin@example.com") + + role := text(t, db, "SELECT role FROM auth_users WHERE email = 'author@example.com'") + if got.Code != gonsole.ExitDone || role != "editor" { + t.Errorf("account:role as the admin = %d with %q and the role %q, want %d and editor", got.Code, got.Stderr, + role, gonsole.ExitDone) + } +} From 5188b7013307e2cf89b21830238e1960915d70a0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 23:22:03 +0200 Subject: [PATCH 11/26] test(gonsole/auth/sqlite): record one row naming the account and list it with account:records --- gonsole/auth/sqlite/record_test.go | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 gonsole/auth/sqlite/record_test.go diff --git a/gonsole/auth/sqlite/record_test.go b/gonsole/auth/sqlite/record_test.go new file mode 100644 index 0000000..b4058dd --- /dev/null +++ b/gonsole/auth/sqlite/record_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "encoding/json" + "strings" + "sync/atomic" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/testkit" +) + +func TestAnAppliedCommandRecordsOneRowNamingTheAccount(t *testing.T) { + t.Parallel() + + path, db := seeded(t) + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", + "-as", " Admin@Example.com ") + + admin := text(t, db, "SELECT id FROM auth_users WHERE email = 'admin@example.com'") + recorded := text(t, db, "SELECT actor || ' ' || command || ' ' || account_id FROM gonsole_records") + want := "admin@example.com account:role " + admin + if got.Code != gonsole.ExitDone || count(t, db, "SELECT count(*) FROM gonsole_records") != 1 || recorded != want { + t.Errorf("an applied account:role = %d with %q and the record %q, want %d and %q", got.Code, got.Stderr, + recorded, gonsole.ExitDone, want) + } +} + +func TestAccountRecordsListsTheRecordInTextAndInUTCJSON(t *testing.T) { + t.Parallel() + + path, _ := seeded(t) + var opens atomic.Int32 + applied := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", + "-as", "admin@example.com") + if applied.Code != gonsole.ExitDone { + t.Fatalf("account:role = %d with %q, want %d", applied.Code, applied.Stderr, gonsole.ExitDone) + } + + listed := testkit.Run(t, program(path, &opens), "", "account:records") + asJSON := testkit.Run(t, program(path, &opens), "", "account:records", "-json") + + var document struct { + Records []struct { + AppliedAt string `json:"applied_at"` + Actor string `json:"actor"` + Command string `json:"command"` + } `json:"records"` + } + if err := json.Unmarshal([]byte(asJSON.Stdout), &document); err != nil || len(document.Records) != 1 { + t.Fatalf("account:records -json printed %q, %v, want one record", asJSON.Stdout, err) + } + first := document.Records[0] + if !strings.Contains(listed.Stdout, "admin@example.com") || !strings.Contains(listed.Stdout, "account:role") || + first.Actor != "admin@example.com" || first.Command != "account:role" || !strings.HasSuffix(first.AppliedAt, "Z") { + t.Errorf("account:records printed %q and -json %+v, want the admin's role change at a UTC time", + listed.Stdout, first) + } +} + +func TestAccountRecordsPrintsAnEmptyListOnAnEmptyTable(t *testing.T) { + t.Parallel() + + path := pathOf(t, withRecords(t)) + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), "", "account:records", "-json") + + if got.Code != gonsole.ExitDone || !strings.Contains(got.Stdout, `"records": []`) { + t.Errorf("account:records -json on an empty table = %d with %q, want an empty list", got.Code, got.Stdout) + } +} From 6867e4e0a1e5777e2d782cb304898909ec87b285 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 23:22:04 +0200 Subject: [PATCH 12/26] test(gonsole/auth/sqlite): run every account command on one handle opened once --- gonsole/auth/sqlite/commands_test.go | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 gonsole/auth/sqlite/commands_test.go diff --git a/gonsole/auth/sqlite/commands_test.go b/gonsole/auth/sqlite/commands_test.go new file mode 100644 index 0000000..ad8f248 --- /dev/null +++ b/gonsole/auth/sqlite/commands_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "path/filepath" + "sync/atomic" + "testing" + + dbsqlite "github.com/gopherium/framework/dbkit/sqlite" + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/testkit" +) + +func TestTheFirstAdminComesOnAFreshFileThroughTheOneHandle(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "site.db") + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), demoPassword+"\n", "account:create-admin", + "-email", "maria.perez@example.com", "-name", "Maria Perez", "-role", "admin") + + opts := testOptions() + db, err := dbsqlite.Open("sqlite:"+path, opts) + if err != nil { + t.Fatalf("opening the file the run made: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + admins := count(t, db, "SELECT count(*) FROM auth_users WHERE email = 'maria.perez@example.com' AND role = 'admin'") + if got.Code != gonsole.ExitDone || admins != 1 || opens.Load() != 1 { + t.Errorf("account:create-admin on a fresh file = %d with %q, %d admins and %d opens, want %d, 1 and 1", + got.Code, got.Stderr, admins, opens.Load(), gonsole.ExitDone) + } +} + +func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, check, want string + args []string + }{ + {"account:grant-role", "SELECT count(*) FROM auth_users WHERE role = ''", "0", + []string{"account:grant-role", "-role", "author", "-yes", "-as", "admin@example.com"}}, + {"account:list", "SELECT count(*) FROM auth_users", "3", []string{"account:list"}}, + {"account:role", "SELECT role FROM auth_users WHERE email = 'author@example.com'", "editor", + []string{"account:role", "author@example.com", "editor", "-yes", "-as", "admin@example.com"}}, + {"account:disable", "SELECT disabled FROM auth_users WHERE email = 'author@example.com'", "1", + []string{"account:disable", "author@example.com", "-yes", "-as", "admin@example.com"}}, + {"account:enable", "SELECT disabled FROM auth_users WHERE email = 'editor@example.com'", "0", + []string{"account:enable", "editor@example.com", "-yes", "-as", "admin@example.com"}}, + {"account:records", "SELECT count(*) FROM gonsole_records", "0", []string{"account:records"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + path, db := seeded(t) + var opens atomic.Int32 + + got := testkit.Run(t, program(path, &opens), "", tc.args...) + + if after := text(t, db, tc.check); got.Code != gonsole.ExitDone || after != tc.want || opens.Load() != 1 { + t.Errorf("%v = %d with %q, %q and %d opens, want %d, %q and 1", tc.args, got.Code, got.Stderr, after, + opens.Load(), gonsole.ExitDone, tc.want) + } + }) + } +} From 37105f693c1a83a1311cfbfd41925a2851500af0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 11 Oct 2026 23:22:05 +0200 Subject: [PATCH 13/26] test(gonsole/auth/sqlite): wait for a busy file no longer than the busy timeout in Record --- gonsole/auth/sqlite/busy_test.go | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 gonsole/auth/sqlite/busy_test.go diff --git a/gonsole/auth/sqlite/busy_test.go b/gonsole/auth/sqlite/busy_test.go new file mode 100644 index 0000000..7c06d8b --- /dev/null +++ b/gonsole/auth/sqlite/busy_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +func TestRecordWaitsNoLongerThanTheBusyTimeout(t *testing.T) { + t.Parallel() + + opts := testOptions() + opts.BusyTimeout = 2 * time.Second + opts.MaxConns = 3 + db := sqlitetest.Open(t, opts) + applied(t, db) + writer, err := db.BeginTx(t.Context(), nil) + if err != nil { + t.Fatalf("beginning the writer: %v", err) + } + t.Cleanup(func() { _ = writer.Rollback() }) + if _, err := writer.ExecContext(t.Context(), "CREATE TABLE core_items (id TEXT NOT NULL PRIMARY KEY)"); err != nil { + t.Fatalf("writing inside the writer: %v", err) + } + cfg := testConfig() + cfg.RecordTimeout = 20 * time.Millisecond + call := gonsole.Call{ + Env: gonsole.Env{Prefix: "MYAPP_", Getenv: testkit.Getenv(nil)}, Actor: "admin@example.com", + Args: []string{"author@example.com"}, + }.WithDB(db) + + start := time.Now() + err = auth.Record(cfg)(context.Background(), call, "account:disable") + waited := time.Since(start) + + if err == nil || !strings.HasPrefix(err.Error(), "record account:disable: sqlite: insert a record: ") || + !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, dbkit.ErrBusy) { + t.Errorf("Record() behind a held write = %v, want the record's deadline under the insert", err) + } + if waited < cfg.RecordTimeout || waited >= 2*opts.BusyTimeout { + t.Errorf("Record() returned after %v, want at least the record timeout %v and under twice the busy timeout %v", + waited, cfg.RecordTimeout, opts.BusyTimeout) + } +} From db6bbfd8bf33da2eab012729509d6b285f6dbedc Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:24:59 +0200 Subject: [PATCH 14/26] chore(gonsole/auth): allow only the gouncer packages the module imports --- gonsole/auth/.golangci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gonsole/auth/.golangci.yml b/gonsole/auth/.golangci.yml index 30ce193..1061846 100644 --- a/gonsole/auth/.golangci.yml +++ b/gonsole/auth/.golangci.yml @@ -33,7 +33,9 @@ linters: allow: - $gostd - github.com/gopherium/framework/gonsole - - github.com/gopherium/gouncer + - github.com/gopherium/gouncer$ + - github.com/gopherium/gouncer/authkit$ + - github.com/gopherium/gouncer/authkit/postgres - github.com/jackc/pgx/v5 - github.com/google/uuid - github.com/peterldowns/pgtestdb From e8a8e6eda70367c71d4348b6de7090957a126e72 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:24:59 +0200 Subject: [PATCH 15/26] docs(gonsole/auth): say the accounts live in gouncer's PostgreSQL or SQLite store --- gonsole/auth/doc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gonsole/auth/doc.go b/gonsole/auth/doc.go index abd797b..f179f7e 100644 --- a/gonsole/auth/doc.go +++ b/gonsole/auth/doc.go @@ -1,4 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Package auth offers the account commands of a program whose accounts live in gouncer's Postgres store. +// Package auth offers the account commands of a program whose accounts live in gouncer's PostgreSQL or SQLite store. package auth From a2f60803b9a82ceb6a561ee84e0e2e0754bc89de Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:24:59 +0200 Subject: [PATCH 16/26] docs(gonsole/auth/sqlite): add the changelog with the module's entries under Unreleased --- gonsole/auth/sqlite/CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 gonsole/auth/sqlite/CHANGELOG.md diff --git a/gonsole/auth/sqlite/CHANGELOG.md b/gonsole/auth/sqlite/CHANGELOG.md new file mode 100644 index 0000000..6d26f76 --- /dev/null +++ b/gonsole/auth/sqlite/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to the `gonsole/auth/sqlite` module are documented +in this file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +module follows [Semantic Versioning](https://semver.org/). While at +v0.x, minor releases may contain breaking changes. + +Releases of this module are tagged `gonsole/auth/sqlite/vX.Y.Z`. + +## [Unreleased] + +### Added + +- `Stores`, the account and record stores of a call on the run's one SQLite handle, for `auth.Config.Stores`. +- `Migration` and `RecordMigration` with `MigrateOptions`, the two schema steps, each under the file's migration lock. +- The records keep their own version table, `gonsole_goose_db_version`. +- `NewRecordStore`, the record store of the `gonsole_records` table, whose arguments and flags must be valid JSON. +- A busy, locked or constraint failure of the record store or the records step carries dbkit's error class. From 53ec53575a72eadf0ed5cde82a4f7cbb806ba473 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:00 +0200 Subject: [PATCH 17/26] feat(ci): test, lint and scan gonsole/auth/sqlite --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154d0fc..2291be5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} @@ -30,7 +30,7 @@ jobs: ${{ matrix.module }}/go.sum - run: go test -race -covermode=atomic -coverprofile=cover.out ./... - run: go vet ./... - - if: matrix.module == 'dbkit/sqlite' + - if: matrix.module == 'dbkit/sqlite' || matrix.module == 'gonsole/auth/sqlite' run: GOOS=darwin GOARCH=arm64 go vet ./... && GOOS=windows GOARCH=amd64 go vet ./... test-postgres: @@ -99,7 +99,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -121,7 +121,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} From 313b34947cbccbba6652d6685f86dc231bdd898f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:00 +0200 Subject: [PATCH 18/26] docs(gonsole/auth/sqlite): list the module in the README and name both stores --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f79e37..bd0c155 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ need and ignore the rest. - [`gonsole`](gonsole/) runs the command line of a Go program built from core commands, settings and compiled plugins. - [`gonsole/auth`](gonsole/auth/) offers the account commands of a program - whose accounts live in gouncer's Postgres store. + whose accounts live in gouncer's PostgreSQL or SQLite store. +- [`gonsole/auth/sqlite`](gonsole/auth/sqlite/) runs those account + commands and their records on a program's one SQLite handle. - [`gottext`](gottext/) reads, writes and syncs gettext catalogs for TypeScript applications, published to npm as `@gopherium/gottext`. - [`mailkit`](mailkit/) renders mail from template files and sends it From 5a59c5e63185bfec2849b8c5c3305a08d7bcc600 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:01 +0200 Subject: [PATCH 19/26] fix(gonsole/auth/sqlite): list no records for a limit below zero --- gonsole/auth/sqlite/records.go | 4 ++-- gonsole/auth/sqlite/records_test.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/gonsole/auth/sqlite/records.go b/gonsole/auth/sqlite/records.go index f3cb487..53e5469 100644 --- a/gonsole/auth/sqlite/records.go +++ b/gonsole/auth/sqlite/records.go @@ -60,9 +60,9 @@ func (r recordStore) insert(ctx context.Context, entry auth.Entry, appliedAt tim return failed("insert a record", err) } -// Latest returns the newest entries first, at most limit of them, and an empty list for none. +// Latest returns the newest entries first, at most limit of them, and an empty list for none or a limit below zero. func (r recordStore) Latest(ctx context.Context, limit int) ([]auth.Entry, error) { - rows, err := r.db.QueryContext(ctx, latestRecords, limit) + rows, err := r.db.QueryContext(ctx, latestRecords, max(limit, 0)) if err != nil { return nil, failed("list the records", err) } diff --git a/gonsole/auth/sqlite/records_test.go b/gonsole/auth/sqlite/records_test.go index b045670..e52ce40 100644 --- a/gonsole/auth/sqlite/records_test.go +++ b/gonsole/auth/sqlite/records_test.go @@ -19,6 +19,21 @@ func roleChange() auth.Entry { Args: []string{"maria@example.com", "editor"}, Flags: map[string]string{"yes": "true"}} } +func TestALimitBelowZeroListsNoRecords(t *testing.T) { + t.Parallel() + + store := sqlite.NewRecordStore(withRecords(t)) + if err := store.Insert(t.Context(), roleChange()); err != nil { + t.Fatalf("Insert() error = %v, want nil", err) + } + + listed, err := store.Latest(t.Context(), -1) + + if err != nil || listed == nil || len(listed) != 0 { + t.Errorf("Latest(-1) = %v, %v, want an empty list, at most -1 records", listed, err) + } +} + func TestARecordKeepsItsTimeAsUTCMicrosecondsSinceTheEpoch(t *testing.T) { t.Parallel() From 251a513892dfe1a17bd1efe35a776c304943fcfc Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:01 +0200 Subject: [PATCH 20/26] test(gonsole/auth/sqlite): make each account command case change or print what it covers --- gonsole/auth/sqlite/commands_test.go | 50 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/gonsole/auth/sqlite/commands_test.go b/gonsole/auth/sqlite/commands_test.go index ad8f248..8965b51 100644 --- a/gonsole/auth/sqlite/commands_test.go +++ b/gonsole/auth/sqlite/commands_test.go @@ -4,6 +4,7 @@ package sqlite_test import ( "path/filepath" + "strings" "sync/atomic" "testing" @@ -38,30 +39,49 @@ func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) { t.Parallel() for _, tc := range []struct { - name, check, want string - args []string + name, before, check, want string + args, prints []string }{ - {"account:grant-role", "SELECT count(*) FROM auth_users WHERE role = ''", "0", - []string{"account:grant-role", "-role", "author", "-yes", "-as", "admin@example.com"}}, - {"account:list", "SELECT count(*) FROM auth_users", "3", []string{"account:list"}}, - {"account:role", "SELECT role FROM auth_users WHERE email = 'author@example.com'", "editor", - []string{"account:role", "author@example.com", "editor", "-yes", "-as", "admin@example.com"}}, - {"account:disable", "SELECT disabled FROM auth_users WHERE email = 'author@example.com'", "1", - []string{"account:disable", "author@example.com", "-yes", "-as", "admin@example.com"}}, - {"account:enable", "SELECT disabled FROM auth_users WHERE email = 'editor@example.com'", "0", - []string{"account:enable", "editor@example.com", "-yes", "-as", "admin@example.com"}}, - {"account:records", "SELECT count(*) FROM gonsole_records", "0", []string{"account:records"}}, + {"account:grant-role", "UPDATE auth_users SET role = '' WHERE email = 'author@example.com'", + "SELECT role FROM auth_users WHERE email = 'author@example.com'", "author", + []string{"account:grant-role", "-role", "author", "-yes", "-as", "admin@example.com"}, nil}, + {"account:list", "", "", "", []string{"account:list"}, + []string{"admin@example.com", "editor@example.com", "author@example.com"}}, + {"account:role", "", "SELECT role FROM auth_users WHERE email = 'author@example.com'", "editor", + []string{"account:role", "author@example.com", "editor", "-yes", "-as", "admin@example.com"}, nil}, + {"account:disable", "", "SELECT disabled FROM auth_users WHERE email = 'author@example.com'", "1", + []string{"account:disable", "author@example.com", "-yes", "-as", "admin@example.com"}, nil}, + {"account:enable", "UPDATE auth_users SET disabled = 1 WHERE email = 'editor@example.com'", + "SELECT disabled FROM auth_users WHERE email = 'editor@example.com'", "0", + []string{"account:enable", "editor@example.com", "-yes", "-as", "admin@example.com"}, nil}, + {"account:records", "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) VALUES " + + "('019a0000-0000-7000-8000-000000000001', 1767225600000000, 'admin@example.com', 'account:disable', " + + "'[\"author@example.com\"]', '{}')", "", "", []string{"account:records"}, + []string{"admin@example.com", "account:disable", "author@example.com"}}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() path, db := seeded(t) + if tc.before != "" { + mustExec(t, db, tc.before) + } var opens atomic.Int32 got := testkit.Run(t, program(path, &opens), "", tc.args...) - if after := text(t, db, tc.check); got.Code != gonsole.ExitDone || after != tc.want || opens.Load() != 1 { - t.Errorf("%v = %d with %q, %q and %d opens, want %d, %q and 1", tc.args, got.Code, got.Stderr, after, - opens.Load(), gonsole.ExitDone, tc.want) + if got.Code != gonsole.ExitDone || opens.Load() != 1 { + t.Errorf("%v = %d with %q and %d opens, want %d and 1", tc.args, got.Code, got.Stderr, opens.Load(), + gonsole.ExitDone) + } + if tc.check != "" { + if after := text(t, db, tc.check); after != tc.want { + t.Errorf("after %v the file reads %q, want %q", tc.args, after, tc.want) + } + } + for _, want := range tc.prints { + if !strings.Contains(got.Stdout, want) { + t.Errorf("%v printed %q, want it to name %s", tc.args, got.Stdout, want) + } } }) } From 45a9710b865c2379c0545d4a6ef2fdad20883359 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:02 +0200 Subject: [PATCH 21/26] test(gonsole/auth/sqlite): refuse zero lock settings on both steps and share one record insert --- gonsole/auth/sqlite/failures_test.go | 6 +----- gonsole/auth/sqlite/helpers_test.go | 4 ++++ gonsole/auth/sqlite/schema_test.go | 29 +++++++++++++++------------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/gonsole/auth/sqlite/failures_test.go b/gonsole/auth/sqlite/failures_test.go index 6743b81..aa42934 100644 --- a/gonsole/auth/sqlite/failures_test.go +++ b/gonsole/auth/sqlite/failures_test.go @@ -18,10 +18,6 @@ import ( // errPlanted is the failure a chosen statement answers with. var errPlanted = errors.New("planted failure") -// plantRow stores a records row with every column given, past the store. -const plantRow = "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) " + - "VALUES (?, ?, 'admin@example.com', 'account:role', ?, ?)" - // wantUnder fails t unless err matches want under the action the store names. func wantUnder(t *testing.T, err, want error, action string) { t.Helper() @@ -92,7 +88,7 @@ func TestARecordThatListsBadlyFailsTheWholeList(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() db := withRecords(t) - mustExec(t, db, plantRow, recordID, tc.appliedAt, tc.args, "{}") + mustExec(t, db, insertRecord, recordID, tc.appliedAt, "admin@example.com", "account:role", tc.args, "{}") listed, err := sqlite.NewRecordStore(db).Latest(t.Context(), 10) diff --git a/gonsole/auth/sqlite/helpers_test.go b/gonsole/auth/sqlite/helpers_test.go index ece61fa..57fbff4 100644 --- a/gonsole/auth/sqlite/helpers_test.go +++ b/gonsole/auth/sqlite/helpers_test.go @@ -17,6 +17,10 @@ import ( "github.com/gopherium/framework/gonsole/auth/sqlite" ) +// insertRecord stores a records row with every column but account_id, past the record store. +const insertRecord = "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) " + + "VALUES (?, ?, ?, ?, ?, ?)" + // testMigrate is how every test run of a schema step waits for the migration lock. var testMigrate = sqlite.MigrateOptions{LockWait: 5 * time.Second, LockPoll: 10 * time.Millisecond} diff --git a/gonsole/auth/sqlite/schema_test.go b/gonsole/auth/sqlite/schema_test.go index c3fc5c6..ab439a8 100644 --- a/gonsole/auth/sqlite/schema_test.go +++ b/gonsole/auth/sqlite/schema_test.go @@ -17,10 +17,6 @@ import ( "github.com/gopherium/framework/gonsole/auth/sqlite" ) -// insertRecord stores a record row with the given values for the columns that have no default. -const insertRecord = "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) " + - "VALUES (?, ?, ?, ?, ?, ?)" - func TestBothStepsMigrateTheirOwnTablesUnderTheirOwnNames(t *testing.T) { t.Parallel() @@ -44,17 +40,24 @@ func TestBothStepsMigrateTheirOwnTablesUnderTheirOwnNames(t *testing.T) { } } -func TestTheRecordsStepRefusesLockSettingsAtZero(t *testing.T) { +func TestEachStepRefusesLockSettingsAtZero(t *testing.T) { t.Parallel() - db := sqlitetest.Open(t, testOptions()) - - err := sqlite.RecordMigration(sqlite.MigrateOptions{}).Apply(t.Context(), "", db) - - tables := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name = 'gonsole_records'") - if err == nil || !strings.HasPrefix(err.Error(), "sqlite: apply the records schema: ") || tables != 0 { - t.Errorf("the records step with no lock settings = %v with %d records tables, want a refusal and none", - err, tables) + prefixes := map[string]string{"accounts": "sqlite: apply migrations: ", + "records": "sqlite: apply the records schema: "} + for _, step := range steps(sqlite.MigrateOptions{}) { + t.Run(step.Name, func(t *testing.T) { + t.Parallel() + db := sqlitetest.Open(t, testOptions()) + + err := step.Apply(t.Context(), "", db) + + tables := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name IN ('auth_users', 'gonsole_records')") + if err == nil || !strings.HasPrefix(err.Error(), prefixes[step.Name]) || tables != 0 { + t.Errorf("the %s step with no lock settings = %v with %d tables, want an error under %q and no table", + step.Name, err, tables, prefixes[step.Name]) + } + }) } } From 67206d708fdbf02c5db5bb7059d8700bc0fff72a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:02 +0200 Subject: [PATCH 22/26] test(gonsole/auth/sqlite): compare a handle failure with gonsole's own error --- gonsole/auth/sqlite/stores_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/gonsole/auth/sqlite/stores_test.go b/gonsole/auth/sqlite/stores_test.go index fcf1c3e..94066c4 100644 --- a/gonsole/auth/sqlite/stores_test.go +++ b/gonsole/auth/sqlite/stores_test.go @@ -3,7 +3,6 @@ package sqlite_test import ( - "errors" "testing" authkitsqlite "github.com/gopherium/gouncer/authkit/sqlite" @@ -48,12 +47,14 @@ func TestTheStoresRunOnTheCallsOneHandleWithNothingToRelease(t *testing.T) { func TestTheStoresPassTheHandleFailureThroughUnchanged(t *testing.T) { t.Parallel() + _, gonsoles := gonsole.Call{}.DB(t.Context()) + _, _, missing := sqlite.Stores(t.Context(), gonsole.Call{}) _, _, describing := sqlite.Stores(t.Context(), gonsole.Call{Describe: true}) - if missing == nil || missing.Error() != "gonsole: no database handle in this call" || - !errors.Is(describing, gonsole.ErrDescribing) || describing != gonsole.ErrDescribing { - t.Errorf("Stores() answered %v with no handle and %v for a describing call, want gonsole's errors as they are", - missing, describing) + if gonsoles == nil || missing == nil || missing.Error() != gonsoles.Error() || + describing != gonsole.ErrDescribing { + t.Errorf("Stores() answered %v with no handle and %v for a describing call, want %v and %v as they are", + missing, describing, gonsoles, gonsole.ErrDescribing) } } From a967628e8ffda47f25cd2e6df1018290f674ce01 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:03 +0200 Subject: [PATCH 23/26] test(gonsole/auth/sqlite): read the migration lock path through pathOf --- gonsole/auth/sqlite/lock_test.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/gonsole/auth/sqlite/lock_test.go b/gonsole/auth/sqlite/lock_test.go index 77dcc3a..1a415e9 100644 --- a/gonsole/auth/sqlite/lock_test.go +++ b/gonsole/auth/sqlite/lock_test.go @@ -22,12 +22,7 @@ import ( // migrationLock opens the migration lock file beside the main file of db. func migrationLock(t *testing.T, db *sql.DB) *os.File { t.Helper() - var path string - lookup := "SELECT file FROM pragma_database_list WHERE name = 'main'" - if err := db.QueryRowContext(t.Context(), lookup).Scan(&path); err != nil { - t.Fatalf("%s: %v", lookup, err) - } - lock, err := os.OpenFile(path+".migrate.lock", os.O_RDWR|os.O_CREATE, 0o600) + lock, err := os.OpenFile(pathOf(t, db)+".migrate.lock", os.O_RDWR|os.O_CREATE, 0o600) if err != nil { t.Fatalf("opening the migration lock: %v", err) } From 976307390ee958db4a02b76a0034c0b251b7838c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 11:25:03 +0200 Subject: [PATCH 24/26] chore(gonsole/auth/sqlite): keep sqlitetest to the tests and drop unused allow entries --- gonsole/auth/sqlite/.golangci.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/gonsole/auth/sqlite/.golangci.yml b/gonsole/auth/sqlite/.golangci.yml index 3e519cf..31d966e 100644 --- a/gonsole/auth/sqlite/.golangci.yml +++ b/gonsole/auth/sqlite/.golangci.yml @@ -29,16 +29,25 @@ linters: auth-purity: list-mode: strict files: - - "**/*.go" + - $all + - "!$test" + allow: + - $gostd + - github.com/gopherium/framework/gonsole + - github.com/gopherium/framework/dbkit$ + - github.com/gopherium/framework/dbkit/sqlite$ + - github.com/gopherium/gouncer/authkit/sqlite$ + test-purity: + list-mode: strict + files: + - $test allow: - $gostd - github.com/gopherium/framework/gonsole - github.com/gopherium/framework/dbkit$ - github.com/gopherium/framework/dbkit/sqlite - github.com/gopherium/gouncer$ - - github.com/gopherium/gouncer/authkit$ - - github.com/gopherium/gouncer/authkit/sqlite - - github.com/google/uuid + - github.com/gopherium/gouncer/authkit/sqlite$ one-sqlite-driver: deny: - pkg: modernc.org/sqlite From f7fef7290548c4cd7d97b1ecabb33b21e7f0cf4b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 12:02:10 +0200 Subject: [PATCH 25/26] test(gonsole/auth/sqlite): split compound conditions and nested checks in the tests --- gonsole/auth/sqlite/authorize_test.go | 10 +++--- gonsole/auth/sqlite/busy_test.go | 8 +++-- gonsole/auth/sqlite/commands_test.go | 32 +++++++++---------- gonsole/auth/sqlite/failures_test.go | 7 ++--- gonsole/auth/sqlite/helpers_test.go | 9 ++++++ gonsole/auth/sqlite/lock_test.go | 2 +- gonsole/auth/sqlite/program_test.go | 11 +++++++ gonsole/auth/sqlite/record_test.go | 18 ++++++----- gonsole/auth/sqlite/records_test.go | 30 +++++++++--------- gonsole/auth/sqlite/schema_test.go | 14 ++++++--- gonsole/auth/sqlite/stores_test.go | 45 ++++++++++++++++++--------- 11 files changed, 116 insertions(+), 70 deletions(-) diff --git a/gonsole/auth/sqlite/authorize_test.go b/gonsole/auth/sqlite/authorize_test.go index e50d977..2b5365d 100644 --- a/gonsole/auth/sqlite/authorize_test.go +++ b/gonsole/auth/sqlite/authorize_test.go @@ -36,10 +36,12 @@ func TestAuthorizeRefusesAnAccountItCannotLetThrough(t *testing.T) { got := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", "-as", tc.actor) - records := count(t, db, "SELECT count(*) FROM gonsole_records") - if got.Code != gonsole.ExitFailed || got.Stderr != tc.stderr || records != 0 { - t.Errorf("account:role as %s = %d with %q and %d records, want %d with %q and none", tc.actor, - got.Code, got.Stderr, records, gonsole.ExitFailed, tc.stderr) + if got.Code != gonsole.ExitFailed || got.Stderr != tc.stderr { + t.Errorf("account:role as %s = %d with %q, want %d with %q", tc.actor, got.Code, got.Stderr, + gonsole.ExitFailed, tc.stderr) + } + if records := count(t, db, "SELECT count(*) FROM gonsole_records"); records != 0 { + t.Errorf("account:role as %s left %d records, want none", tc.actor, records) } }) } diff --git a/gonsole/auth/sqlite/busy_test.go b/gonsole/auth/sqlite/busy_test.go index 7c06d8b..8d8b150 100644 --- a/gonsole/auth/sqlite/busy_test.go +++ b/gonsole/auth/sqlite/busy_test.go @@ -5,6 +5,7 @@ package sqlite_test import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -43,10 +44,13 @@ func TestRecordWaitsNoLongerThanTheBusyTimeout(t *testing.T) { err = auth.Record(cfg)(context.Background(), call, "account:disable") waited := time.Since(start) - if err == nil || !strings.HasPrefix(err.Error(), "record account:disable: sqlite: insert a record: ") || - !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, dbkit.ErrBusy) { + if !strings.HasPrefix(fmt.Sprint(err), "record account:disable: sqlite: insert a record: ") || + !errors.Is(err, context.DeadlineExceeded) { t.Errorf("Record() behind a held write = %v, want the record's deadline under the insert", err) } + if errors.Is(err, dbkit.ErrBusy) { + t.Errorf("Record() behind a held write = %v, want the record's deadline before the busy timeout", err) + } if waited < cfg.RecordTimeout || waited >= 2*opts.BusyTimeout { t.Errorf("Record() returned after %v, want at least the record timeout %v and under twice the busy timeout %v", waited, cfg.RecordTimeout, opts.BusyTimeout) diff --git a/gonsole/auth/sqlite/commands_test.go b/gonsole/auth/sqlite/commands_test.go index 8965b51..aec26a8 100644 --- a/gonsole/auth/sqlite/commands_test.go +++ b/gonsole/auth/sqlite/commands_test.go @@ -3,8 +3,8 @@ package sqlite_test import ( + "fmt" "path/filepath" - "strings" "sync/atomic" "testing" @@ -22,16 +22,18 @@ func TestTheFirstAdminComesOnAFreshFileThroughTheOneHandle(t *testing.T) { got := testkit.Run(t, program(path, &opens), demoPassword+"\n", "account:create-admin", "-email", "maria.perez@example.com", "-name", "Maria Perez", "-role", "admin") - opts := testOptions() - db, err := dbsqlite.Open("sqlite:"+path, opts) + if got.Code != gonsole.ExitDone || opens.Load() != 1 { + t.Fatalf("account:create-admin on a fresh file = %d with %q and %d opens, want %d and 1", + got.Code, got.Stderr, opens.Load(), gonsole.ExitDone) + } + db, err := dbsqlite.Open("sqlite:"+path, testOptions()) if err != nil { t.Fatalf("opening the file the run made: %v", err) } t.Cleanup(func() { _ = db.Close() }) admins := count(t, db, "SELECT count(*) FROM auth_users WHERE email = 'maria.perez@example.com' AND role = 'admin'") - if got.Code != gonsole.ExitDone || admins != 1 || opens.Load() != 1 { - t.Errorf("account:create-admin on a fresh file = %d with %q, %d admins and %d opens, want %d, 1 and 1", - got.Code, got.Stderr, admins, opens.Load(), gonsole.ExitDone) + if admins != 1 { + t.Errorf("account:create-admin on a fresh file left %d admins, want 1", admins) } } @@ -45,7 +47,7 @@ func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) { {"account:grant-role", "UPDATE auth_users SET role = '' WHERE email = 'author@example.com'", "SELECT role FROM auth_users WHERE email = 'author@example.com'", "author", []string{"account:grant-role", "-role", "author", "-yes", "-as", "admin@example.com"}, nil}, - {"account:list", "", "", "", []string{"account:list"}, + {"account:list", "", "SELECT count(*) FROM gonsole_records", "0", []string{"account:list"}, []string{"admin@example.com", "editor@example.com", "author@example.com"}}, {"account:role", "", "SELECT role FROM auth_users WHERE email = 'author@example.com'", "editor", []string{"account:role", "author@example.com", "editor", "-yes", "-as", "admin@example.com"}, nil}, @@ -56,8 +58,8 @@ func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) { []string{"account:enable", "editor@example.com", "-yes", "-as", "admin@example.com"}, nil}, {"account:records", "INSERT INTO gonsole_records (id, applied_at, actor, command, args, flags) VALUES " + "('019a0000-0000-7000-8000-000000000001', 1767225600000000, 'admin@example.com', 'account:disable', " + - "'[\"author@example.com\"]', '{}')", "", "", []string{"account:records"}, - []string{"admin@example.com", "account:disable", "author@example.com"}}, + "'[\"author@example.com\"]', '{}')", "SELECT count(*) FROM gonsole_records", "1", + []string{"account:records"}, []string{"admin@example.com", "account:disable", "author@example.com"}}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -73,16 +75,10 @@ func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) { t.Errorf("%v = %d with %q and %d opens, want %d and 1", tc.args, got.Code, got.Stderr, opens.Load(), gonsole.ExitDone) } - if tc.check != "" { - if after := text(t, db, tc.check); after != tc.want { - t.Errorf("after %v the file reads %q, want %q", tc.args, after, tc.want) - } - } - for _, want := range tc.prints { - if !strings.Contains(got.Stdout, want) { - t.Errorf("%v printed %q, want it to name %s", tc.args, got.Stdout, want) - } + if after := text(t, db, tc.check); after != tc.want { + t.Errorf("after %v the file reads %q, want %q", tc.args, after, tc.want) } + wantPrinted(t, fmt.Sprint(tc.args), got.Stdout, tc.prints...) }) } } diff --git a/gonsole/auth/sqlite/failures_test.go b/gonsole/auth/sqlite/failures_test.go index aa42934..e04c819 100644 --- a/gonsole/auth/sqlite/failures_test.go +++ b/gonsole/auth/sqlite/failures_test.go @@ -5,6 +5,7 @@ package sqlite_test import ( "context" "errors" + "fmt" "strings" "testing" @@ -30,9 +31,7 @@ func TestARepeatedRecordIDAnswersDbkitsUniqueClass(t *testing.T) { t.Parallel() store := sqlite.NewRecordStore(withRecords(t)) - if err := store.Insert(t.Context(), roleChange()); err != nil { - t.Fatalf("Insert() error = %v, want nil", err) - } + mustInsert(t, store, roleChange()) err := store.Insert(t.Context(), roleChange()) @@ -92,7 +91,7 @@ func TestARecordThatListsBadlyFailsTheWholeList(t *testing.T) { listed, err := sqlite.NewRecordStore(db).Latest(t.Context(), 10) - if listed != nil || err == nil || !strings.HasPrefix(err.Error(), "sqlite: list the records: ") { + if listed != nil || !strings.HasPrefix(fmt.Sprint(err), "sqlite: list the records: ") { t.Errorf("Latest() over a record with %s = %v, %v, want no list and a failure under the action", tc.name, listed, err) } diff --git a/gonsole/auth/sqlite/helpers_test.go b/gonsole/auth/sqlite/helpers_test.go index 57fbff4..f5f0b06 100644 --- a/gonsole/auth/sqlite/helpers_test.go +++ b/gonsole/auth/sqlite/helpers_test.go @@ -14,6 +14,7 @@ import ( "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/auth/sqlite" ) @@ -64,6 +65,14 @@ func mustExec(t *testing.T, db *sql.DB, statement string, args ...any) { } } +// mustInsert inserts entry through store and fails t when it errs. +func mustInsert(t *testing.T, store auth.RecordStore, entry auth.Entry) { + t.Helper() + if err := store.Insert(t.Context(), entry); err != nil { + t.Fatalf("Insert() error = %v, want nil", err) + } +} + // accountsOnly returns a handle on a fresh copy of a file holding the account tables and no records table. func accountsOnly(t *testing.T) *sql.DB { t.Helper() diff --git a/gonsole/auth/sqlite/lock_test.go b/gonsole/auth/sqlite/lock_test.go index 1a415e9..346a5b5 100644 --- a/gonsole/auth/sqlite/lock_test.go +++ b/gonsole/auth/sqlite/lock_test.go @@ -78,7 +78,7 @@ func TestEachStepGivesUpOnAMigrationLockHeldPastItsWait(t *testing.T) { waited := time.Since(start) versions := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name LIKE '%goose_db_version'") - if err == nil || !strings.Contains(err.Error(), "migration lock") || versions != 0 { + if !strings.Contains(fmt.Sprint(err), "migration lock") || versions != 0 { t.Errorf("the %s step behind a held lock = %v with %d version tables, want the lock named and none", step.Name, err, versions) } diff --git a/gonsole/auth/sqlite/program_test.go b/gonsole/auth/sqlite/program_test.go index d0ebf78..4f8445e 100644 --- a/gonsole/auth/sqlite/program_test.go +++ b/gonsole/auth/sqlite/program_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "io" + "strings" "sync/atomic" "testing" "time" @@ -86,6 +87,16 @@ func seeded(t *testing.T) (string, *sql.DB) { return pathOf(t, db), db } +// wantPrinted fails t unless out, which command printed, names every part. +func wantPrinted(t *testing.T, command, out string, parts ...string) { + t.Helper() + for _, part := range parts { + if !strings.Contains(out, part) { + t.Errorf("%s printed %q, want it to name %s", command, out, part) + } + } +} + // text returns the single text lookup answers on db. func text(t *testing.T, db *sql.DB, lookup string) string { t.Helper() diff --git a/gonsole/auth/sqlite/record_test.go b/gonsole/auth/sqlite/record_test.go index b4058dd..388fc44 100644 --- a/gonsole/auth/sqlite/record_test.go +++ b/gonsole/auth/sqlite/record_test.go @@ -21,12 +21,14 @@ func TestAnAppliedCommandRecordsOneRowNamingTheAccount(t *testing.T) { got := testkit.Run(t, program(path, &opens), "", "account:role", "author@example.com", "editor", "-yes", "-as", " Admin@Example.com ") + if got.Code != gonsole.ExitDone { + t.Fatalf("an applied account:role = %d with %q, want %d", got.Code, got.Stderr, gonsole.ExitDone) + } admin := text(t, db, "SELECT id FROM auth_users WHERE email = 'admin@example.com'") recorded := text(t, db, "SELECT actor || ' ' || command || ' ' || account_id FROM gonsole_records") want := "admin@example.com account:role " + admin - if got.Code != gonsole.ExitDone || count(t, db, "SELECT count(*) FROM gonsole_records") != 1 || recorded != want { - t.Errorf("an applied account:role = %d with %q and the record %q, want %d and %q", got.Code, got.Stderr, - recorded, gonsole.ExitDone, want) + if records := count(t, db, "SELECT count(*) FROM gonsole_records"); records != 1 || recorded != want { + t.Errorf("an applied account:role left %d records and the record %q, want 1 and %q", records, recorded, want) } } @@ -54,11 +56,13 @@ func TestAccountRecordsListsTheRecordInTextAndInUTCJSON(t *testing.T) { if err := json.Unmarshal([]byte(asJSON.Stdout), &document); err != nil || len(document.Records) != 1 { t.Fatalf("account:records -json printed %q, %v, want one record", asJSON.Stdout, err) } + wantPrinted(t, "account:records", listed.Stdout, "admin@example.com", "account:role") first := document.Records[0] - if !strings.Contains(listed.Stdout, "admin@example.com") || !strings.Contains(listed.Stdout, "account:role") || - first.Actor != "admin@example.com" || first.Command != "account:role" || !strings.HasSuffix(first.AppliedAt, "Z") { - t.Errorf("account:records printed %q and -json %+v, want the admin's role change at a UTC time", - listed.Stdout, first) + if first.Actor != "admin@example.com" || first.Command != "account:role" { + t.Errorf("account:records -json printed %+v, want the admin's role change", first) + } + if !strings.HasSuffix(first.AppliedAt, "Z") { + t.Errorf("account:records -json printed the time %q, want a UTC time", first.AppliedAt) } } diff --git a/gonsole/auth/sqlite/records_test.go b/gonsole/auth/sqlite/records_test.go index e52ce40..1eb67df 100644 --- a/gonsole/auth/sqlite/records_test.go +++ b/gonsole/auth/sqlite/records_test.go @@ -19,18 +19,24 @@ func roleChange() auth.Entry { Args: []string{"maria@example.com", "editor"}, Flags: map[string]string{"yes": "true"}} } +// within reports whether micros falls between the microseconds of from and to. +func within(micros int64, from, to time.Time) bool { + return micros >= from.UnixMicro() && micros <= to.UnixMicro() +} + func TestALimitBelowZeroListsNoRecords(t *testing.T) { t.Parallel() store := sqlite.NewRecordStore(withRecords(t)) - if err := store.Insert(t.Context(), roleChange()); err != nil { - t.Fatalf("Insert() error = %v, want nil", err) - } + mustInsert(t, store, roleChange()) listed, err := store.Latest(t.Context(), -1) - if err != nil || listed == nil || len(listed) != 0 { - t.Errorf("Latest(-1) = %v, %v, want an empty list, at most -1 records", listed, err) + if err != nil { + t.Fatalf("Latest(-1) error = %v, want nil", err) + } + if listed == nil || len(listed) != 0 { + t.Errorf("Latest(-1) = %#v, want an empty list, at most -1 records", listed) } } @@ -40,22 +46,16 @@ func TestARecordKeepsItsTimeAsUTCMicrosecondsSinceTheEpoch(t *testing.T) { db := withRecords(t) store := sqlite.NewRecordStore(db) before := time.Now() - if err := store.Insert(t.Context(), roleChange()); err != nil { - t.Fatalf("Insert() error = %v, want nil", err) - } + mustInsert(t, store, roleChange()) after := time.Now() - var kind string - var stored int64 - lookup := "SELECT typeof(applied_at), applied_at FROM gonsole_records" - if err := db.QueryRowContext(t.Context(), lookup).Scan(&kind, &stored); err != nil { - t.Fatalf("%s: %v", lookup, err) - } + kind := text(t, db, "SELECT typeof(applied_at) FROM gonsole_records") + stored := int64(count(t, db, "SELECT applied_at FROM gonsole_records")) listed, err := store.Latest(t.Context(), 1) if err != nil || len(listed) != 1 { t.Fatalf("Latest() = %v, %v, want the one record", listed, err) } - if kind != "integer" || stored < before.UnixMicro() || stored > after.UnixMicro() { + if kind != "integer" || !within(stored, before, after) { t.Errorf("applied_at is %s %d, want an integer between %d and %d", kind, stored, before.UnixMicro(), after.UnixMicro()) } diff --git a/gonsole/auth/sqlite/schema_test.go b/gonsole/auth/sqlite/schema_test.go index ab439a8..2d5718d 100644 --- a/gonsole/auth/sqlite/schema_test.go +++ b/gonsole/auth/sqlite/schema_test.go @@ -5,6 +5,7 @@ package sqlite_test import ( "context" "errors" + "fmt" "slices" "strings" "testing" @@ -33,10 +34,13 @@ func TestBothStepsMigrateTheirOwnTablesUnderTheirOwnNames(t *testing.T) { versions := count(t, db, "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN "+ "('auth_goose_db_version', 'gonsole_goose_db_version')") records := count(t, db, "SELECT max(version_id) FROM gonsole_goose_db_version") - if !slices.Equal(names, []string{"accounts", "records"}) || tables != 2 || versions != 2 || records != 1 { - t.Errorf("steps %v left %d of the 2 tables and %d of the 2 version tables, records at %d, "+ - "want accounts then records, both tables, both version tables and records at 1", names, tables, - versions, records) + type state struct { + steps string + tables, versions, records int + } + got := state{strings.Join(names, " then "), tables, versions, records} + if want := (state{"accounts then records", 2, 2, 1}); got != want { + t.Errorf("both steps applied twice left %+v, want %+v", got, want) } } @@ -53,7 +57,7 @@ func TestEachStepRefusesLockSettingsAtZero(t *testing.T) { err := step.Apply(t.Context(), "", db) tables := count(t, db, "SELECT count(*) FROM sqlite_master WHERE name IN ('auth_users', 'gonsole_records')") - if err == nil || !strings.HasPrefix(err.Error(), prefixes[step.Name]) || tables != 0 { + if !strings.HasPrefix(fmt.Sprint(err), prefixes[step.Name]) || tables != 0 { t.Errorf("the %s step with no lock settings = %v with %d tables, want an error under %q and no table", step.Name, err, tables, prefixes[step.Name]) } diff --git a/gonsole/auth/sqlite/stores_test.go b/gonsole/auth/sqlite/stores_test.go index 94066c4..dbb6f0f 100644 --- a/gonsole/auth/sqlite/stores_test.go +++ b/gonsole/auth/sqlite/stores_test.go @@ -19,28 +19,41 @@ var ( _ = auth.Config{Stores: sqlite.Stores} ) -func TestTheStoresRunOnTheCallsOneHandleWithNothingToRelease(t *testing.T) { +func TestTheStoresComeWithNothingToRelease(t *testing.T) { + t.Parallel() + + stores, release, err := sqlite.Stores(t.Context(), gonsole.Call{}.WithDB(withRecords(t))) + + if err != nil || release != nil { + t.Fatalf("Stores() = a release %t, %v, want no release and no error", release != nil, err) + } + if stores.Accounts == nil || stores.Records == nil { + t.Errorf("Stores() = %+v, want both stores", stores) + } +} + +func TestTheStoresRunOnTheCallsOneHandle(t *testing.T) { t.Parallel() db := withRecords(t) - stores, release, err := sqlite.Stores(t.Context(), gonsole.Call{}.WithDB(db)) + stores, _, err := sqlite.Stores(t.Context(), gonsole.Call{}.WithDB(db)) - if err != nil || release != nil || stores.Accounts == nil || stores.Records == nil { - t.Fatalf("Stores() = %v, a release %t, %v, want both stores and no release", stores, release != nil, err) + if err != nil { + t.Fatalf("Stores() error = %v, want nil", err) } - user, err := gouncer.NewUser("maria@example.com", "Maria Perez", "correct horse battery") + user, err := gouncer.NewUser("maria@example.com", "Maria Perez", demoPassword) if err != nil { t.Fatalf("gouncer.NewUser() error = %v, want nil", err) } if err := stores.Accounts.CreateUser(t.Context(), user); err != nil { t.Fatalf("CreateUser() error = %v, want nil", err) } - held, err := stores.Records.Held(t.Context()) - accounts := count(t, db, "SELECT count(*) FROM auth_users") - if err != nil || !held || accounts != 1 { - t.Errorf("the stores read the records as held %t with %v and wrote %d accounts, want both on the handle", - held, err, accounts) + if accounts := count(t, db, "SELECT count(*) FROM auth_users"); accounts != 1 { + t.Errorf("the account store wrote %d accounts on the handle, want 1", accounts) + } + if held, err := stores.Records.Held(t.Context()); err != nil || !held { + t.Errorf("the record store read the records as held %t with %v, want held on the handle", held, err) } } @@ -48,13 +61,17 @@ func TestTheStoresPassTheHandleFailureThroughUnchanged(t *testing.T) { t.Parallel() _, gonsoles := gonsole.Call{}.DB(t.Context()) + if gonsoles == nil { + t.Fatal("gonsole.Call{}.DB() error = nil, want the failure of a call with no handle") + } _, _, missing := sqlite.Stores(t.Context(), gonsole.Call{}) _, _, describing := sqlite.Stores(t.Context(), gonsole.Call{Describe: true}) - if gonsoles == nil || missing == nil || missing.Error() != gonsoles.Error() || - describing != gonsole.ErrDescribing { - t.Errorf("Stores() answered %v with no handle and %v for a describing call, want %v and %v as they are", - missing, describing, gonsoles, gonsole.ErrDescribing) + if missing == nil || missing.Error() != gonsoles.Error() { + t.Errorf("Stores() with no handle = %v, want %v as it is", missing, gonsoles) + } + if describing != gonsole.ErrDescribing { + t.Errorf("Stores() for a describing call = %v, want %v as it is", describing, gonsole.ErrDescribing) } } From c538cc52727498bcac7d371191828c73df67a313 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 12:52:15 +0200 Subject: [PATCH 26/26] test(gonsole/auth/sqlite): read the stored record time into an int64 so 32-bit builds pass --- gonsole/auth/sqlite/records_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gonsole/auth/sqlite/records_test.go b/gonsole/auth/sqlite/records_test.go index 1eb67df..4169548 100644 --- a/gonsole/auth/sqlite/records_test.go +++ b/gonsole/auth/sqlite/records_test.go @@ -50,7 +50,11 @@ func TestARecordKeepsItsTimeAsUTCMicrosecondsSinceTheEpoch(t *testing.T) { after := time.Now() kind := text(t, db, "SELECT typeof(applied_at) FROM gonsole_records") - stored := int64(count(t, db, "SELECT applied_at FROM gonsole_records")) + var stored int64 + lookup := "SELECT applied_at FROM gonsole_records" + if err := db.QueryRowContext(t.Context(), lookup).Scan(&stored); err != nil { + t.Fatalf("%s: %v", lookup, err) + } listed, err := store.Latest(t.Context(), 1) if err != nil || len(listed) != 1 { t.Fatalf("Latest() = %v, %v, want the one record", listed, err)