Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9ac062c
feat(gonsole/auth/sqlite): add the module with the accounts and recor…
SirLouen Oct 11, 2026
c1fde41
chore(gonsole/auth/sqlite): lint the module with a strict import list…
SirLouen Oct 11, 2026
db83892
test(gonsole/auth/sqlite): run the two steps one after the other unde…
SirLouen Oct 11, 2026
b71f155
test(gonsole/auth/sqlite): match the records migration numbers with g…
SirLouen Oct 11, 2026
74b03a1
test(gonsole/auth/sqlite): check modernc.org/libc against the driver'…
SirLouen Oct 11, 2026
87557eb
feat(gonsole/auth/sqlite): keep the command records in gonsole_record…
SirLouen Oct 11, 2026
ee1ebde
test(gonsole/auth/sqlite): store record times as UTC microseconds and…
SirLouen Oct 11, 2026
a4c5474
test(gonsole/auth/sqlite): class each record store failure under its …
SirLouen Oct 11, 2026
d00c4cd
feat(gonsole/auth/sqlite): build the account and record stores on the…
SirLouen Oct 11, 2026
f77cc19
test(gonsole/auth/sqlite): refuse a disabled, unactivated or incapabl…
SirLouen Oct 11, 2026
5188b70
test(gonsole/auth/sqlite): record one row naming the account and list…
SirLouen Oct 11, 2026
6867e4e
test(gonsole/auth/sqlite): run every account command on one handle op…
SirLouen Oct 11, 2026
37105f6
test(gonsole/auth/sqlite): wait for a busy file no longer than the bu…
SirLouen Oct 11, 2026
db6bbfd
chore(gonsole/auth): allow only the gouncer packages the module imports
SirLouen Oct 12, 2026
e8a8e6e
docs(gonsole/auth): say the accounts live in gouncer's PostgreSQL or …
SirLouen Oct 12, 2026
a2f6080
docs(gonsole/auth/sqlite): add the changelog with the module's entrie…
SirLouen Oct 12, 2026
53ec535
feat(ci): test, lint and scan gonsole/auth/sqlite
SirLouen Oct 12, 2026
313b349
docs(gonsole/auth/sqlite): list the module in the README and name bot…
SirLouen Oct 12, 2026
5a59c5e
fix(gonsole/auth/sqlite): list no records for a limit below zero
SirLouen Oct 12, 2026
251a513
test(gonsole/auth/sqlite): make each account command case change or p…
SirLouen Oct 12, 2026
45a9710
test(gonsole/auth/sqlite): refuse zero lock settings on both steps an…
SirLouen Oct 12, 2026
67206d7
test(gonsole/auth/sqlite): compare a handle failure with gonsole's ow…
SirLouen Oct 12, 2026
a967628
test(gonsole/auth/sqlite): read the migration lock path through pathOf
SirLouen Oct 12, 2026
9763073
chore(gonsole/auth/sqlite): keep sqlitetest to the tests and drop unu…
SirLouen Oct 12, 2026
f7fef72
test(gonsole/auth/sqlite): split compound conditions and nested check…
SirLouen Oct 12, 2026
c538cc5
test(gonsole/auth/sqlite): read the stored record time into an int64 …
SirLouen Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 }}
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion gonsole/auth/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gonsole/auth/doc.go
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions gonsole/auth/sqlite/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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:
- $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/sqlite$
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
19 changes: 19 additions & 0 deletions gonsole/auth/sqlite/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions gonsole/auth/sqlite/authorize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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)

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)
}
})
}
}

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)
}
}
58 changes: 58 additions & 0 deletions gonsole/auth/sqlite/busy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0

package sqlite_test

import (
"context"
"errors"
"fmt"
"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 !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)
}
}
84 changes: 84 additions & 0 deletions gonsole/auth/sqlite/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: Apache-2.0

package sqlite_test

import (
"fmt"
"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")

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 admins != 1 {
t.Errorf("account:create-admin on a fresh file left %d admins, want 1", admins)
}
}

func TestEveryAccountCommandRunsOnTheOneHandle(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name, before, check, want string
args, prints []string
}{
{"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", "", "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},
{"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\"]', '{}')", "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()
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 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 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...)
})
}
}
Loading
Loading