164 lines
6.5 KiB
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())
|
|
}
|
|
}
|
|
}
|