textmachine/platform/cmd/tmplatformctl/token_test.go

164 lines
6.5 KiB
Go

package main
import (
"crypto/sha256"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/pgstore"
)
// The whole of the non-browser door, end to end against a live Postgres: a token comes out, only its
// DIGEST is stored, the session it names authenticates, the operator's own tool can revoke it, and
// the account's journal shows it happened.
//
// ⚠ The digest assertion is the load-bearing one. "In the database only the hash" was TRUE and not
// pinned once before in this zone, and the acceptance pack's mutation survived (ENGINEERING_STANDARDS
// §3.4, the P0 lesson). A door that stored the plaintext would pass every other check here.
func TestAnIssuedTokenAuthenticatesAndOnlyItsDigestIsStored(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-token', now())`); err != nil {
t.Fatal(err)
}
out := capture(t, "token", "issue", "--user", "u-token", "--client", "smoke")
lines := strings.Split(strings.TrimSpace(out), "\n")
token := strings.TrimSpace(lines[0])
if token == "" || strings.HasPrefix(token, "#") {
t.Fatalf("no token on the first line:\n%s", out)
}
// It is shown ONCE and the caller is told so — the property that makes the digest-only storage
// survivable is that nobody expects to look it up later.
if !strings.Contains(out, "shown ONCE") || !strings.Contains(out, "Bearer") {
t.Errorf("the caller is not told what they are holding:\n%s", out)
}
// The PLAINTEXT is nowhere in the database. Checked against the column rather than by reading the
// code that wrote it.
var stored []byte
if err := conn.QueryRow(ctx, `select token_sha256 from sessions where user_id = 'u-token'`).Scan(&stored); err != nil {
t.Fatalf("no session row was written: %v", err)
}
want := sha256.Sum256([]byte(token))
if string(stored) != string(want[:]) {
t.Errorf("what is stored is not the digest of what was printed")
}
var plaintextRows int
if err := conn.QueryRow(ctx,
`select count(*) from sessions where encode(token_sha256, 'escape') = $1`, token).Scan(&plaintextRows); err != nil {
t.Fatal(err)
}
if plaintextRows != 0 {
t.Errorf("the plaintext token is in the sessions table")
}
// It is a REAL session: the middleware's own lookup accepts it, and it carries the deployment's
// own clocks rather than a policy of this command's invention.
store, err := pgstore.Open(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer store.Close()
sess, err := store.Lookup(ctx, auth.Digest(token), time.Now().UTC())
if err != nil {
t.Fatalf("the issued token does not authenticate: %v", err)
}
if sess.UserID != "u-token" {
t.Errorf("it authenticates as %q", sess.UserID)
}
idle := time.Until(sess.IdleExpiresAt).Round(time.Hour)
if idle != 14*24*time.Hour {
t.Errorf("idle window %s: not the deployment's own session policy", idle)
}
// ⚠ AND THE PROPERTY THE EXTRACTION OF config.SessionTTLs() EXISTS FOR — that this command reads
// the DEPLOYMENT's policy rather than a private one. Asserting the default proves nothing: a
// hard-coded 14d/30d inside the command would pass it. So the policy is MOVED and the token has
// to move with it.
t.Setenv("TM_PLATFORM_SESSION_IDLE", "48h")
t.Setenv("TM_PLATFORM_SESSION_MAX_AGE", "96h")
out2 := capture(t, "token", "issue", "--user", "u-token")
moved := strings.TrimSpace(strings.Split(strings.TrimSpace(out2), "\n")[0])
sess2, err := store.Lookup(ctx, auth.Digest(moved), time.Now().UTC())
if err != nil {
t.Fatalf("the second token does not authenticate: %v", err)
}
if got := time.Until(sess2.IdleExpiresAt).Round(time.Hour); got != 48*time.Hour {
t.Errorf("idle window %s: the command did not read the deployment's own policy", got)
}
// The journal answers "was that me" for an operator-issued session, like it does for a sign-in.
//
// ⚠ Read off the COLUMN and not out of the rendered line: the word "operator" also appears in the
// reason text on that same line, so a substring check over the table would stay green with the
// provider mutated to anything at all.
var provider string
if err := conn.QueryRow(ctx,
`select provider from login_events where user_id = 'u-token' order by at limit 1`).Scan(&provider); err != nil {
t.Fatalf("the issue is not in the account's journal: %v", err)
}
if provider != "operator" {
t.Errorf("the journal files this session under provider %q", provider)
}
if got := capture(t, "logins", "--user", "u-token"); !strings.Contains(got, "operator") {
t.Errorf("the operator's own view does not show it:\n%s", got)
}
// And the ordinary revocation ends it — no second revocation path for a second credential model,
// because there is no second credential model.
if got := capture(t, "revoke", "--user", "u-token"); !strings.Contains(got, "revoked 2 sessions") {
t.Errorf("revoke said:\n%s", got)
}
if _, err := store.Lookup(ctx, auth.Digest(token), time.Now().UTC()); err == nil {
t.Error("a revoked token still authenticates")
}
}
// A token for an account that does not exist is REFUSED and writes nothing: a session naming nobody
// would authenticate as nobody, and the foreign key is what says so.
func TestATokenForAnAccountThatDoesNotExistIsRefused(t *testing.T) {
dsn := freshDB(t)
t.Setenv("TM_PLATFORM_DSN", dsn)
var out strings.Builder
if err := run([]string{"token", "issue", "--user", "nobody"}, &out); err == nil {
t.Fatalf("issued a token for an account that is not there:\n%s", out.String())
}
if out.String() != "" {
t.Errorf("a refused issue printed something that could be mistaken for a token: %q", out.String())
}
}
// Bad arguments are a USAGE error and print no outcome, like every other command here.
//
// ⚠ It runs against a live database on purpose. Without one the dispatcher refuses at the DSN gate
// before it ever reaches the subcommand, so the test would pass without exercising anything — the
// shape of green test that proves only that something went wrong somewhere.
func TestTokenArgumentsAreRefused(t *testing.T) {
t.Setenv("TM_PLATFORM_DSN", freshDB(t))
for _, args := range [][]string{
{"token"},
{"token", "revoke"},
{"token", "issue"}, // no --user
} {
var out strings.Builder
err := run(args, &out)
if err == nil {
t.Errorf("%v was accepted", args)
}
if out.String() != "" {
t.Errorf("%v printed an outcome: %q", args, out.String())
}
}
}