265 lines
10 KiB
Go
265 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/money"
|
|
"textmachine/platform/internal/pgstore"
|
|
)
|
|
|
|
// brokenBalance is a store whose write commits and whose read-back fails.
|
|
type brokenBalance struct{}
|
|
|
|
func (brokenBalance) Balance(context.Context, string) (money.MicroUSD, error) {
|
|
return 0, errors.New("connection reset by peer")
|
|
}
|
|
|
|
// The rule this command calls load-bearing, and the one it had no test for (PD-106): once the ledger
|
|
// row is committed, NOTHING downstream may report failure. An operator who reads an error retries,
|
|
// and a retry without --key mints a fresh idempotency key — so a failed BALANCE READ would buy a
|
|
// double credit out of a write that already succeeded.
|
|
// Mutation caught: returning the balance error from write instead of noting it.
|
|
func TestACommittedWriteNeverReportsFailure(t *testing.T) {
|
|
var out strings.Builder
|
|
err := write(t.Context(), brokenBalance{}, &out, "u1", "key-1",
|
|
func(string, time.Time) (bool, error) { return true, nil }, "granted 5.000000 to u1")
|
|
if err != nil {
|
|
t.Fatalf("a committed grant reported failure: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), "granted 5.000000 to u1") {
|
|
t.Fatalf("the operator was not told the write happened:\n%s", out.String())
|
|
}
|
|
// The failure is not swallowed either — it is a warning on the same output.
|
|
if !strings.Contains(out.String(), "warning: could not read the balance back") {
|
|
t.Fatalf("the failed read-back left no trace:\n%s", out.String())
|
|
}
|
|
}
|
|
|
|
// A write that did NOT happen must say so. "Applied" and "that key was already spent" are different
|
|
// outcomes, and reporting the second as the first is how an operator believes they credited twice.
|
|
// Mutation caught: dropping the !applied branch.
|
|
func TestASpentKeyIsReportedAsANoOp(t *testing.T) {
|
|
var out strings.Builder
|
|
err := write(t.Context(), brokenBalance{}, &out, "u1", "key-1",
|
|
func(string, time.Time) (bool, error) { return false, nil }, "granted 5.000000 to u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.String(), "no-op") || strings.Contains(out.String(), "granted") {
|
|
t.Fatalf("a no-op was reported as a grant:\n%s", out.String())
|
|
}
|
|
}
|
|
|
|
// The failure BEFORE the commit is the opposite case: it must reach the operator as an error, or a
|
|
// grant that never happened reads as one that did.
|
|
func TestAFailedWriteIsAnError(t *testing.T) {
|
|
var out strings.Builder
|
|
err := write(t.Context(), brokenBalance{}, &out, "u1", "",
|
|
func(string, time.Time) (bool, error) { return false, errors.New("deadlock detected") },
|
|
"granted 5.000000 to u1")
|
|
if err == nil {
|
|
t.Fatalf("a failed grant reported success:\n%s", out.String())
|
|
}
|
|
if out.String() != "" {
|
|
t.Fatalf("a failed grant printed an outcome:\n%s", out.String())
|
|
}
|
|
}
|
|
|
|
// Without --key every invocation is its own intent: two deliberate grants on one day are two grants.
|
|
// Mutation caught: deriving the default key from the account or the date.
|
|
func TestEachInvocationWithoutAKeyIsItsOwnIntent(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for range 100 {
|
|
var out strings.Builder
|
|
var used string
|
|
if err := write(t.Context(), brokenBalance{}, &out, "u1", "",
|
|
func(id string, _ time.Time) (bool, error) { used = id; return true, nil }, "granted"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(used, "cli-") {
|
|
t.Fatalf("generated key = %q, want it marked as the CLI's", used)
|
|
}
|
|
if seen[used] {
|
|
t.Fatalf("key %q was minted twice: the second grant would silently collapse", used)
|
|
}
|
|
seen[used] = true
|
|
}
|
|
// An explicit key is used verbatim — that is the whole point of offering one.
|
|
var out strings.Builder
|
|
var used string
|
|
if err := write(t.Context(), brokenBalance{}, &out, "u1", "invoice-42",
|
|
func(id string, _ time.Time) (bool, error) { used = id; return true, nil }, "granted"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if used != "invoice-42" {
|
|
t.Fatalf("explicit key became %q", used)
|
|
}
|
|
}
|
|
|
|
// Argument handling, which no test saw either. Every one of these must be refused on its own terms,
|
|
// BEFORE any database work.
|
|
//
|
|
// The expected message is asserted, not merely "an error came back": the DSN below points at a port
|
|
// nothing listens on, so a command that got past its own checks fails anyway on connect — and a test
|
|
// that only asked for non-nil would pass against a build with no argument checks at all. Measured:
|
|
// two of these mutations survived the first version of this test for exactly that reason.
|
|
func TestBadArgumentsAreRefused(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_DSN", "postgres://nobody@127.0.0.1:1/none?sslmode=disable&connect_timeout=1")
|
|
for name, tc := range map[string]struct {
|
|
args []string
|
|
want string
|
|
wantUsage bool
|
|
}{
|
|
"no command at all": {args: nil, want: "usage", wantUsage: true},
|
|
"unknown command": {args: []string{"delete-everything"}, want: `unknown command "delete-everything"`, wantUsage: true},
|
|
"grant without a user": {args: []string{"grant", "--usd", "5"}, want: "grant needs --user and --usd"},
|
|
"grant without amount": {args: []string{"grant", "--user", "u1"}, want: "grant needs --user and --usd"},
|
|
"grant of a non-amount": {args: []string{"grant", "--user", "u1", "--usd", "five"}, want: "not a decimal amount"},
|
|
"adjust without a note": {args: []string{"adjust", "--user", "u1", "--usd", "-1"}, want: "adjust needs --user, --usd and --note"},
|
|
"balance without user": {args: []string{"balance"}, want: "balance needs --user"},
|
|
"logins without user": {args: []string{"logins"}, want: "logins needs --user"},
|
|
"revoke without user": {args: []string{"revoke"}, want: "revoke needs --user"},
|
|
"unknown flag": {args: []string{"grant", "--userr", "u1"}, want: "not defined"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var out strings.Builder
|
|
err := run(tc.args, &out)
|
|
if err == nil {
|
|
t.Fatalf("accepted %v; output was %q", tc.args, out.String())
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("%v failed with %q, want it refused for %q", tc.args, err, tc.want)
|
|
}
|
|
if errors.Is(err, errUsage) != tc.wantUsage {
|
|
t.Fatalf("usage=%v for %v: %v", errors.Is(err, errUsage), tc.args, err)
|
|
}
|
|
if out.String() != "" {
|
|
t.Fatalf("a refused command printed an outcome: %q", out.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The DSN is required and its absence is an error, not a silent default to localhost.
|
|
func TestMissingDSNIsRefused(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_DSN", "")
|
|
t.Setenv("TM_PLATFORM_DSN_FILE", "")
|
|
var out strings.Builder
|
|
if err := run([]string{"balance", "--user", "u1"}, &out); err == nil {
|
|
t.Fatal("ran without a DSN")
|
|
}
|
|
}
|
|
|
|
// End to end against a live Postgres: the four commands an operator actually types, through run(),
|
|
// with the money crossing the real ledger. Skips loudly without a database, like the pgstore battery.
|
|
func TestCommandsAgainstALiveDatabase(t *testing.T) {
|
|
dsn := freshDB(t)
|
|
t.Setenv("TM_PLATFORM_DSN", dsn)
|
|
ctx := t.Context()
|
|
|
|
conn, err := pgx.Connect(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close(ctx)
|
|
if _, err := conn.Exec(ctx, `insert into users (id, created_at) values ('u-cli', now())`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
run1 := capture(t, "grant", "--user", "u-cli", "--usd", "5", "--note", "free tier", "--key", "k1")
|
|
if !strings.Contains(run1, "granted 5.000000 to u-cli") || !strings.Contains(run1, "balance is 5.000000") {
|
|
t.Fatalf("grant said:\n%s", run1)
|
|
}
|
|
// The same key again is a no-op that says so, and the balance does not move.
|
|
run2 := capture(t, "grant", "--user", "u-cli", "--usd", "5", "--note", "free tier", "--key", "k1")
|
|
if !strings.Contains(run2, "no-op") || !strings.Contains(run2, "balance is 5.000000") {
|
|
t.Fatalf("the repeat said:\n%s", run2)
|
|
}
|
|
if got := capture(t, "adjust", "--user", "u-cli", "--usd", "-2.50", "--note", "refund"); !strings.Contains(got, "balance is 2.500000") {
|
|
t.Fatalf("adjust said:\n%s", got)
|
|
}
|
|
if got := capture(t, "balance", "--user", "u-cli"); !strings.Contains(got, "balance 2.500000") {
|
|
t.Fatalf("balance said:\n%s", got)
|
|
}
|
|
// The journal renders with its header even when the account has no sign-ins yet.
|
|
if got := capture(t, "logins", "--user", "u-cli"); !strings.Contains(got, "WHEN") || !strings.Contains(got, "PROVIDER") {
|
|
t.Fatalf("logins said:\n%s", got)
|
|
}
|
|
if got := capture(t, "revoke", "--user", "u-cli"); !strings.Contains(got, "revoked 0 sessions") {
|
|
t.Fatalf("revoke said:\n%s", got)
|
|
}
|
|
// A grant against an account that does not exist is refused, not silently written: the ledger
|
|
// would otherwise carry rows nobody owns.
|
|
var out strings.Builder
|
|
if err := run([]string{"grant", "--user", "nobody", "--usd", "5"}, &out); err == nil {
|
|
t.Fatalf("credited an account that does not exist:\n%s", out.String())
|
|
}
|
|
}
|
|
|
|
func capture(t *testing.T, args ...string) string {
|
|
t.Helper()
|
|
var out strings.Builder
|
|
if err := run(args, &out); err != nil {
|
|
t.Fatalf("%v: %v (output %q)", args, err, out.String())
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// freshDB creates a database of its own and migrates it, the same contract as the pgstore battery's
|
|
// helper: a test that leaves rows behind passes once and then lies.
|
|
//
|
|
// ⚠ It is a COPY of that helper, deliberately. Sharing it would mean a package both of them import,
|
|
// and pgstore's own battery lives in `package pgstore` — so such a package could not import pgstore
|
|
// for Migrate without a cycle, and a version that took migrate as a parameter would be more
|
|
// machinery than the twenty lines it saves.
|
|
func freshDB(t *testing.T) string {
|
|
t.Helper()
|
|
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
|
|
if admin == "" {
|
|
t.Skip("TM_PLATFORM_TEST_DSN not set: the end-to-end commands need a live Postgres")
|
|
}
|
|
ctx := context.Background()
|
|
var suffix [6]byte
|
|
if _, err := rand.Read(suffix[:]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "tm_ctl_test_" + hex.EncodeToString(suffix[:])
|
|
|
|
conn, err := pgx.Connect(ctx, admin)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer conn.Close(ctx)
|
|
if _, err := conn.Exec(ctx, `create database `+pgx.Identifier{name}.Sanitize()); err != nil {
|
|
t.Fatalf("create database: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
c, err := pgx.Connect(context.Background(), admin)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close(context.Background())
|
|
_, _ = c.Exec(context.Background(), `drop database `+pgx.Identifier{name}.Sanitize()+` with (force)`)
|
|
})
|
|
|
|
u, err := url.Parse(admin)
|
|
if err != nil {
|
|
t.Fatalf("TM_PLATFORM_TEST_DSN must be a URL: %v", err)
|
|
}
|
|
u.Path = "/" + name
|
|
dsn := u.String()
|
|
if err := pgstore.Migrate(ctx, dsn); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
return dsn
|
|
}
|