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
10 changes: 10 additions & 0 deletions gonsole/auth/postgres/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ v0.x, minor releases may contain breaking changes.

Releases of this module are tagged `gonsole/auth/postgres/vX.Y.Z`.

## [Unreleased]

### Changed

- **Breaking.** Requires `gonsole/auth` 0.8.0.

### Fixed

- The record store answers each record's time in UTC, so `account:records -json` prints a UTC time, as on SQLite.

## [0.1.0] - 2026-10-12

### Added
Expand Down
61 changes: 61 additions & 0 deletions gonsole/auth/postgres/authorize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0

package postgres_test

import (
"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 = true 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 = false 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()
db := seeded(t)
if tc.change != "" {
db.run(tc.change)
}

got := testkit.Run(t, moduleProgram(db), "", "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 !db.found("SELECT count(*) = 0 FROM gonsole.records") {
t.Errorf("account:role as %s left a record, want none", tc.actor)
}
})
}
}

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

db := seeded(t)

got := testkit.Run(t, moduleProgram(db), "", "account:role", "author@example.com", "editor", "-yes",
"-as", "admin@example.com")

role := db.text("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)
}
}
2 changes: 1 addition & 1 deletion gonsole/auth/postgres/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/gopherium/framework/dbkit v0.1.0
github.com/gopherium/framework/dbkit/postgres v0.3.0
github.com/gopherium/framework/gonsole v0.6.1
github.com/gopherium/framework/gonsole/auth v0.7.0
github.com/gopherium/framework/gonsole/auth v0.8.0
github.com/gopherium/gouncer v0.4.0
github.com/gopherium/gouncer/authkit/postgres v0.13.0
github.com/jackc/pgx/v5 v5.10.0
Expand Down
4 changes: 2 additions & 2 deletions gonsole/auth/postgres/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ github.com/gopherium/framework/dbkit/postgres v0.3.0 h1:W9SoXXu9Y3YRum5vIG10iBHl
github.com/gopherium/framework/dbkit/postgres v0.3.0/go.mod h1:ZUsvp2lOkhX+08sstBErt+jN6FM5HkhxV7hXgToRd+A=
github.com/gopherium/framework/gonsole v0.6.1 h1:yi0te8BDJLuotbrd24bQDMWSJ0KgIhSIAfOjF0jpDnQ=
github.com/gopherium/framework/gonsole v0.6.1/go.mod h1:n0aJzbwPGPryrWKZ8WzDVub+6R+GUBzeHSzvhMvZR64=
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/framework/gonsole/auth v0.8.0 h1:WddQbi5eRQs98tTR8qx2bLg8clDeW99Kz6mM1T03pvQ=
github.com/gopherium/framework/gonsole/auth v0.8.0/go.mod h1:Lms8wUhRpxvQd3M8kv1tpMS9Pox0Qh/M+1j4HjLq8PE=
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=
Expand Down
67 changes: 0 additions & 67 deletions gonsole/auth/postgres/lineage_test.go

This file was deleted.

68 changes: 0 additions & 68 deletions gonsole/auth/postgres/parity_test.go

This file was deleted.

25 changes: 7 additions & 18 deletions gonsole/auth/postgres/program_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,24 @@ var vocabulary = auth.Roles{
Capabilities: map[string][]string{"admin": {"manage_users"}},
}

// storesBuilder builds the stores of one call, as auth.Config.Stores does.
type storesBuilder = func(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error)

// testConfig returns the account commands' configuration on the stores build makes, nil for gonsole/auth's own.
func testConfig(build storesBuilder) auth.Config {
return auth.Config{
// moduleProgram returns a program called myapp over db that runs on the module's stores and schema steps.
func moduleProgram(db testDB) gonsole.Program {
cfg := auth.Config{
Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return vocabulary, nil },
Capability: "manage_users", RecordTimeout: 5 * time.Second, RecordsLimit: 10, Stores: build,
Capability: "manage_users", RecordTimeout: 5 * time.Second, RecordsLimit: 10, Stores: postgres.Stores,
}
}

// program returns a program called myapp over the database at address, configured by cfg, with migrations.
func program(address string, cfg auth.Config, migrations ...gonsole.Step) gonsole.Program {
settings := map[string]string{"MYAPP_DATABASE_URL": db.address}
return gonsole.Program{
Name: "myapp",
Env: gonsole.Env{Prefix: "MYAPP_", Getenv: testkit.Getenv(map[string]string{"MYAPP_DATABASE_URL": address})},
Env: gonsole.Env{Prefix: "MYAPP_", Getenv: testkit.Getenv(settings)},
Database: "DATABASE_URL",
Migrations: migrations,
Migrations: steps(),
Commands: append(auth.Commands(cfg), auth.Records(cfg)),
Authorize: auth.Authorize(cfg),
Record: auth.Record(cfg),
}
}

// moduleProgram returns the program over db that runs on the module's stores and schema steps.
func moduleProgram(db testDB) gonsole.Program {
return program(db.address, testConfig(postgres.Stores), steps()...)
}

// wantPrinted fails t unless out, which command printed, names every part.
func wantPrinted(t *testing.T, command, out string, parts ...string) {
t.Helper()
Expand Down
34 changes: 34 additions & 0 deletions gonsole/auth/postgres/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package postgres_test

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -77,3 +78,36 @@ func TestRecordEndsAtItsTimeout(t *testing.T) {
t.Fatalf("the record outlived its timeout of 200ms by %v, want it to end at the timeout", recordBound)
}
}

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

db := seeded(t)
applied := testkit.Run(t, moduleProgram(db), "", "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, moduleProgram(db), "", "account:records")
asJSON := testkit.Run(t, moduleProgram(db), "", "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)
}
wantPrinted(t, "account:records", listed.Stdout, "admin@example.com", "account:role")
first := document.Records[0]
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)
}
}
3 changes: 2 additions & 1 deletion gonsole/auth/postgres/records.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,12 @@ func (r recordStore) Latest(ctx context.Context, limit int) ([]auth.Entry, error
return held, nil
}

// scanned returns the entry of row, its arguments and flags decoded from their JSON.
// scanned returns the entry of row in UTC, its arguments and flags decoded from their JSON.
func scanned(row pgx.CollectableRow) (auth.Entry, error) {
var entry auth.Entry
err := row.Scan(&entry.ID, &entry.AppliedAt, &entry.Actor, &entry.AccountID, &entry.Command, &entry.Args,
&entry.Flags)
entry.AppliedAt = entry.AppliedAt.UTC()
return entry, err
}

Expand Down
30 changes: 30 additions & 0 deletions gonsole/auth/postgres/records_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package postgres_test

import (
"context"
"fmt"
"testing"
"time"

"github.com/gopherium/framework/gonsole/auth"
"github.com/gopherium/framework/gonsole/auth/postgres"
Expand All @@ -19,6 +21,18 @@ func roleChange() auth.Entry {
Args: []string{"maria@example.com", "editor"}, Flags: map[string]string{"yes": "true"}}
}

// text returns the single text lookup answers on db.
func (db testDB) text(lookup string) string {
db.t.Helper()
conn := db.dial()
defer func() { _ = conn.Close(context.Background()) }()
var value string
if err := conn.QueryRow(db.t.Context(), lookup).Scan(&value); err != nil {
db.t.Fatalf("%s: %v", lookup, err)
}
return value
}

// mustInsert inserts entry through store and fails t when it errs.
func mustInsert(t *testing.T, store auth.RecordStore, entry auth.Entry) {
t.Helper()
Expand All @@ -43,6 +57,22 @@ func TestALimitBelowZeroListsNoRecords(t *testing.T) {
}
}

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

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

listed, err := store.Latest(t.Context(), 10)

if err != nil || len(listed) != 1 {
t.Fatalf("Latest() = %v, %v, want the one record", listed, err)
}
if at := listed[0].AppliedAt; at.Location() != time.UTC {
t.Errorf("Latest() answers the time %v in %v, want UTC", at, at.Location())
}
}

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

Expand Down
Loading
Loading