Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["dbkit/postgres", "gonsole/auth"]
module: ["dbkit/postgres", "gonsole/auth", "gonsole/auth/postgres"]
defaults:
run:
working-directory: ${{ matrix.module }}
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", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"]
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "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", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"]
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ need and ignore the rest.
core commands, settings and compiled plugins.
- [`gonsole/auth`](gonsole/auth/) offers the account commands of a program
whose accounts live in gouncer's PostgreSQL or SQLite store.
- [`gonsole/auth/postgres`](gonsole/auth/postgres/) runs those account
commands and their records on a program's PostgreSQL database.
- [`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
Expand Down
81 changes: 81 additions & 0 deletions gonsole/auth/postgres/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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/gonsole/auth$
- github.com/gopherium/framework/dbkit/postgres$
- github.com/gopherium/gouncer/authkit/postgres$
- github.com/jackc/pgx/v5
test-purity:
list-mode: strict
files:
- $test
allow:
- $gostd
- github.com/gopherium/framework/gonsole$
- github.com/gopherium/framework/gonsole/auth$
- github.com/gopherium/framework/gonsole/auth/postgres$
- github.com/gopherium/framework/gonsole/auth/recordstest$
- github.com/gopherium/framework/gonsole/testkit$
- github.com/gopherium/framework/dbkit$
- github.com/gopherium/framework/dbkit/postgres$
- github.com/gopherium/framework/dbkit/postgres/pgtest$
- github.com/gopherium/gouncer$
- github.com/gopherium/gouncer/authkit/postgres$
- github.com/gopherium/gouncer/authkit/postgres/testdb$
- github.com/jackc/pgx/v5
- github.com/peterldowns/pgtestdb$
- github.com/pressly/goose/v3/lock$
forbidigo:
analyze-types: true
forbid:
- pattern: ^sql\.Open(DB)?$
pkg: ^database/sql$
msg: open PostgreSQL through pgxpool.New or pgx's stdlib.OpenDB
exclusions:
rules:
- path: _test\.go
linters:
- cyclop
- gocognit

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/gopherium/framework
20 changes: 20 additions & 0 deletions gonsole/auth/postgres/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

All notable changes to the `gonsole/auth/postgres` 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/postgres/vX.Y.Z`.

## [Unreleased]

### Added

- `Stores`, the account and record stores of a call on a pool of the database setting, closed after the call, for `auth.Config.Stores`.
- `Migration` and `RecordMigration`, the two schema steps, each under goose's migration lock.
- The records keep the version table of `gonsole/auth`, `gonsole.goose_db_version`.
- `NewRecordStore`, the record store of the `gonsole.records` table, which lists no record for a limit below zero.
- A constraint, lock or serialization failure of the record store or the records step carries dbkit's error class, such as `dbkit.ErrUnique`.
- An address pgx cannot parse fails `Stores` and the records step with one message that holds no part of the address.
78 changes: 78 additions & 0 deletions gonsole/auth/postgres/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: Apache-2.0

package postgres_test

import (
"fmt"
"testing"

"github.com/gopherium/framework/gonsole"
"github.com/gopherium/framework/gonsole/testkit"
)

// commandLine is one line a scenario runs and what the database and the output hold after it.
type commandLine struct {
// before is a statement run first, empty for none.
before string
// stdin is what the line reads.
stdin string
// args is the line.
args []string
// check is a lookup that answers true after the line.
check string
// prints are the parts the output must name.
prints []string
}

// runLine runs line through p on db and fails the test unless it exits 0 and leaves db as line says.
func (db testDB) runLine(p gonsole.Program, line commandLine) {
db.t.Helper()
if line.before != "" {
db.run(line.before)
}

got := testkit.Run(db.t, p, line.stdin, line.args...)

if got.Code != gonsole.ExitDone {
db.t.Fatalf("%v = %d with %q, want %d", line.args, got.Code, got.Stderr, gonsole.ExitDone)
}
if !db.found(line.check) {
db.t.Errorf("after %v the database fails %q, want it to hold", line.args, line.check)
}
wantPrinted(db.t, fmt.Sprint(line.args), got.Stdout, line.prints...)
db.noSessionLeft()
}

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

db := empty(t)
p := moduleProgram(db)
asAdmin := []string{"-yes", "-as", "admin@example.com"}

for _, line := range []commandLine{
{args: []string{"migrate"}, check: "SELECT to_regclass('gonsole.records') IS NOT NULL",
prints: []string{"migrated accounts", "migrated records"}},
{stdin: demoPassword + "\n", args: []string{"account:create-admin", "-email", "admin@example.com",
"-name", "Maria Perez", "-role", "admin"},
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'admin@example.com' AND role = 'admin')"},
{stdin: demoPassword + "\n", args: []string{"account:create-admin", "-email", "author@example.com",
"-name", "Maria Perez", "-role", "author"},
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'author')"},
{before: "UPDATE auth.users SET role = '' WHERE email = 'author@example.com'",
args: append([]string{"account:grant-role", "-role", "editor"}, asAdmin...),
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'editor')"},
{args: append([]string{"account:role", "author@example.com", "author"}, asAdmin...),
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'author')"},
{args: append([]string{"account:disable", "author@example.com"}, asAdmin...),
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND disabled)"},
{args: append([]string{"account:enable", "author@example.com"}, asAdmin...),
check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND NOT disabled)"},
{args: []string{"account:list"}, check: "SELECT count(*) = 2 FROM auth.users",
prints: []string{"admin@example.com", "author@example.com"}},
{args: []string{"account:records"}, check: "SELECT count(*) = 4 FROM gonsole.records",
prints: []string{"account:grant-role", "account:role", "account:disable", "account:enable"}},
} {
db.runLine(p, line)
}
}
56 changes: 56 additions & 0 deletions gonsole/auth/postgres/contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: Apache-2.0

package postgres_test

import (
"context"
"io"
"maps"
"testing"
"time"

authkitpostgres "github.com/gopherium/gouncer/authkit/postgres"

"github.com/gopherium/framework/gonsole/auth"
"github.com/gopherium/framework/gonsole/auth/postgres"
"github.com/gopherium/framework/gonsole/auth/recordstest"
)

// plantAt inserts one record as applied at a given time, finding the account the acting address names.
const plantAt = `INSERT INTO gonsole.records (id, applied_at, actor, account_id, command, args, flags)
VALUES ($1::uuid, $2, $3, (SELECT id FROM auth.users WHERE email = $3), $4, $5::jsonb, $6::jsonb)`

var _ auth.Accounts = (*authkitpostgres.UserStore)(nil)

// recordsFixture returns the contract fixture of a record store over a fresh database holding the accounts only.
func recordsFixture(t *testing.T) recordstest.Fixture {
t.Helper()
db := migrated(t)
pool := db.pool()
accounts := authkitpostgres.NewUserStore(pool)
return recordstest.Fixture{
Records: postgres.NewRecordStore(pool),
Migrate: func(ctx context.Context) error { return postgres.RecordMigration().Run(ctx, db.address) },
Account: func(ctx context.Context, email string) (string, error) {
held := auth.Account{Email: email, Name: "Maria Perez", Password: demoPassword, Role: "admin"}
if err := auth.EnsureAccounts(ctx, accounts, []auth.Account{held}, io.Discard); err != nil {
return "", err
}
user, err := accounts.UserByEmail(ctx, email)
return user.ID.String(), err
},
Plant: func(ctx context.Context, entry auth.Entry, appliedAt time.Time) error {
flags := map[string]string{}
maps.Copy(flags, entry.Flags)
_, err := pool.Exec(ctx, plantAt, entry.ID, appliedAt, entry.Actor, entry.Command,
append([]string{}, entry.Args...), flags)
return err
},
}
}

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

recordstest.Run(t, recordsFixture)
}
4 changes: 4 additions & 0 deletions gonsole/auth/postgres/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: Apache-2.0

// Package postgres runs the account commands and command records of gonsole/auth on a program's PostgreSQL database.
package postgres
93 changes: 93 additions & 0 deletions gonsole/auth/postgres/failures_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0

package postgres_test

import (
"context"
"errors"
"fmt"
"strings"
"testing"

"github.com/gopherium/framework/dbkit"
"github.com/gopherium/framework/gonsole/auth"
"github.com/gopherium/framework/gonsole/auth/postgres"
)

// badArgs stores a record whose arguments are a JSON object, past the record store.
const badArgs = "INSERT INTO gonsole.records (id, actor, command, args, flags) VALUES " +
"('019a0000-0000-7000-8000-000000000001', 'admin@example.com', 'account:role', '{}', '{}')"

// wantUnder fails t unless err is a failure under the action the store names.
func wantUnder(t *testing.T, err error, action string) {
t.Helper()
if prefix := "postgres: " + action + ": "; !strings.HasPrefix(fmt.Sprint(err), prefix) {
t.Errorf("the call answered %v, want a failure under %q", err, prefix)
}
}

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

store := postgres.NewRecordStore(recorded(t).pool())
mustInsert(t, store, roleChange())

err := store.Insert(t.Context(), roleChange())

wantUnder(t, err, "insert a record")
if !errors.Is(err, dbkit.ErrUnique) {
t.Errorf("Insert() of a repeated id = %v, want dbkit.ErrUnique", err)
}
}

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

pool := recorded(t).pool()
pool.Close()

_, err := postgres.NewRecordStore(pool).Held(t.Context())

wantUnder(t, err, "check the records")
}

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

for _, tc := range []struct {
action string
call func(ctx context.Context, store auth.RecordStore) error
}{
{"insert a record", func(ctx context.Context, store auth.RecordStore) error {
return store.Insert(ctx, roleChange())
}},
{"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()
db := recorded(t)
db.run("DROP TABLE gonsole.records")

err := tc.call(t.Context(), postgres.NewRecordStore(db.pool()))

wantUnder(t, err, tc.action)
})
}
}

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

db := recorded(t)
db.run(badArgs)

listed, err := postgres.NewRecordStore(db.pool()).Latest(t.Context(), 10)

wantUnder(t, err, "list the records")
if listed != nil {
t.Errorf("Latest() over a record with object arguments = %v, want no list", listed)
}
}
Loading
Loading