336 lines
14 KiB
Go
336 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/books"
|
|
"textmachine/platform/internal/pgstore"
|
|
)
|
|
|
|
// PD-114. An environment-only configuration leaves no artifact to read back, so the boot line IS the
|
|
// artifact — and the two things it must never carry are a secret and a sum of money.
|
|
|
|
func effective(t *testing.T) (map[string]Setting, string) {
|
|
t.Helper()
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
c.LogEffective(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
by := map[string]Setting{}
|
|
for _, s := range c.Settings {
|
|
if _, dup := by[s.Key]; dup {
|
|
t.Errorf("%s is printed twice", s.Key)
|
|
}
|
|
by[s.Key] = s
|
|
}
|
|
return by, buf.String()
|
|
}
|
|
|
|
// The exemption `loggedFields` grants is exactly ONE field of ONE line, and nothing deeper.
|
|
//
|
|
// It exists because the first version of that helper skipped any key named `time` at any DEPTH, so a
|
|
// nested group called `time` would have been silently exempt from the money check — a hole aimed the
|
|
// wrong way, in a helper whose entire purpose is that no field escapes. Flat lines made the two
|
|
// behave identically, which is how such a hole survives until the shape changes and nobody looks.
|
|
//
|
|
// Mutation caught: moving the `delete` back inside the recursive walk.
|
|
func TestTheTimestampExemptionDoesNotReachInsideTheLine(t *testing.T) {
|
|
const line = `{"time":"2026-08-29T03:00:00.123456789+03:00","level":"INFO","msg":"config",` +
|
|
`"nested":{"time":"7.50","key":"TM_PLATFORM_SIGNUP_GRANT_USD"}}`
|
|
fields := loggedFields(t, line)
|
|
var top, deep bool
|
|
for _, f := range fields {
|
|
if strings.Contains(f, "2026-08-29T03:00:00") {
|
|
top = true // the handler's own clock leaked through
|
|
}
|
|
if f == "7.50" {
|
|
deep = true // a value under a nested key called `time` was still looked at
|
|
}
|
|
}
|
|
if top {
|
|
t.Error("the handler's own timestamp reached the assertion: the false positive is back")
|
|
}
|
|
if !deep {
|
|
t.Error("a value nested under a key called `time` was exempted from the check: the exemption " +
|
|
"is meant to cover the handler's field, not any field that happens to share its name")
|
|
}
|
|
}
|
|
|
|
// loggedFields is every key and every value the boot log printed, MINUS the handler's own timestamp.
|
|
//
|
|
// ⚠ It exists because the money test used to search the RAW buffer, and that buffer carries an
|
|
// RFC3339Nano time whose digits collide with the very amounts it looks for. Measured on 200 000
|
|
// timestamps: `7.50` appears in 205 of them, `42500` in 5, `0.0425` in 3 — and that is PER LINE,
|
|
// while a run prints one line per setting, so the collisions arrive in bursts (every line of one run
|
|
// shares the same second). Reproduced at `-count=3000`. The test therefore went red over a fact that
|
|
// does not exist: the value IS withheld — `config.go` records `valueAmount` for both, which this same
|
|
// test asserts a few lines above. Register row PD-429.
|
|
//
|
|
// ⚠ A flake in a MONEY test is worse than no test, and that is why this is repaired rather than left
|
|
// with a note: red that is usually noise trains the reader to skip it, and the day an amount really
|
|
// leaks is the day that habit costs the most.
|
|
//
|
|
// ⚠ PARSED, not muted, and the reason is INDEPENDENCE FROM THE FORMAT rather than reach. Silencing
|
|
// `time` through slog's ReplaceAttr would work today and break the day the handler, its options or
|
|
// the time layout change — the search would still be over one rendered blob, and the next collision
|
|
// would be someone else's afternoon.
|
|
//
|
|
// ⚠ What this does NOT buy, measured rather than assumed: it is not WIDER than the old form. The
|
|
// raw-buffer search covered `msg` too — planted an amount into the message and both forms went red,
|
|
// so "now it catches the amount in any field" would have been a pleasing sentence and a false one.
|
|
// What changed is that the false POSITIVE is gone and the assertion no longer depends on how time is
|
|
// rendered. Equal reach, no noise.
|
|
func loggedFields(t *testing.T, log string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
var walk func(v any)
|
|
walk = func(v any) {
|
|
switch v := v.(type) {
|
|
case map[string]any:
|
|
for k, sub := range v {
|
|
out = append(out, k)
|
|
walk(sub)
|
|
}
|
|
case []any:
|
|
for _, sub := range v {
|
|
walk(sub)
|
|
}
|
|
case nil:
|
|
default:
|
|
out = append(out, fmt.Sprint(v))
|
|
}
|
|
}
|
|
for _, l := range strings.Split(strings.TrimSpace(log), "\n") {
|
|
if l == "" {
|
|
continue
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(l), &m); err != nil {
|
|
// Not a skip: a line this cannot read is a line nothing checks, and the whole point is
|
|
// that every field is looked at.
|
|
t.Fatalf("the boot log emitted a line that is not JSON: %q (%v)", l, err)
|
|
}
|
|
// ⚠ Dropped at the TOP LEVEL only, which is where the handler writes it. The first version of
|
|
// this helper skipped any key named `time` at any DEPTH, and that is a hole pointing the wrong
|
|
// way: a nested group called `time` would have been quietly exempt from a check whose whole
|
|
// purpose is that nothing is exempt. Today every line is flat, so the two behaved alike — which
|
|
// is exactly how such a hole survives to the day the shape changes.
|
|
delete(m, slog.TimeKey)
|
|
walk(m)
|
|
}
|
|
if len(out) == 0 {
|
|
t.Fatal("the boot log yielded no fields at all: this assertion would pass over anything")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The completeness gate, and the reason it is written against the SOURCE rather than a list: a print
|
|
// that covers most of the configuration is worse than none, because the variable an operator is
|
|
// hunting is exactly the one nobody remembered to record. A setting added without a record fails
|
|
// here, in the same commit that adds it.
|
|
func TestEverySettingThisServiceReadsIsPrinted(t *testing.T) {
|
|
src, err := os.ReadFile("config.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]bool{}
|
|
for _, m := range regexp.MustCompile(`TM_PLATFORM_[A-Z0-9_]+`).FindAllString(string(src), -1) {
|
|
// A *_FILE variable is the alternative SOURCE of the key beside it, not a setting of its own:
|
|
// it is recorded as `source=file` on that key, and its path is never printed.
|
|
want[strings.TrimSuffix(m, "_FILE")] = true
|
|
}
|
|
got, _ := effective(t)
|
|
var missing []string
|
|
for k := range want {
|
|
if _, ok := got[k]; !ok {
|
|
missing = append(missing, k)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
if len(missing) != 0 {
|
|
t.Fatalf("read but never printed: %v", missing)
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("%d settings printed, %d read from the environment", len(got), len(want))
|
|
}
|
|
}
|
|
|
|
// A DSN carries a password and a client secret is a client secret. What the operator gets is the
|
|
// fact and the SOURCE, which is what answers "did my file get picked up".
|
|
func TestASecretIsNamedAndNeverPrinted(t *testing.T) {
|
|
path := t.TempDir() + "/dsn"
|
|
if err := os.WriteFile(path, []byte("postgres://u:hunter2@db/x\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("TM_PLATFORM_DSN_FILE", path)
|
|
// The whole OIDC quartet, because half a login configuration is refused at boot.
|
|
t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "s3cret")
|
|
t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.example.org")
|
|
t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "client")
|
|
t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback")
|
|
got, line := effective(t)
|
|
if got["TM_PLATFORM_DSN"].Source != sourceFile || got["TM_PLATFORM_DSN"].Value != valueSet {
|
|
t.Errorf("DSN printed as %+v", got["TM_PLATFORM_DSN"])
|
|
}
|
|
if got["TM_PLATFORM_OIDC_CLIENT_SECRET"].Source != sourceEnv {
|
|
t.Errorf("client secret printed as %+v", got["TM_PLATFORM_OIDC_CLIENT_SECRET"])
|
|
}
|
|
for _, leaked := range []string{"hunter2", "s3cret", path} {
|
|
if strings.Contains(line, leaked) {
|
|
t.Errorf("the boot line carries %q", leaked)
|
|
}
|
|
}
|
|
// And the value really was read, not lost by being redacted.
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(c.DSN, "hunter2") || c.OIDCClientSecret != "s3cret" {
|
|
t.Fatal("the secret was redacted out of the configuration itself, not only out of the log")
|
|
}
|
|
}
|
|
|
|
// Money does not reach an INFO log in ANY form (D39.84, PD-99) — including the amounts an operator
|
|
// configured. The source is what they need, and the source is all they get.
|
|
func TestAConfiguredAmountIsNeverPrinted(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "7.50")
|
|
// ⚠ The per-chapter rate used to stand beside the grant here and is GONE: what a chapter costs is
|
|
// no longer a setting at all (see TestThePerChapterRateIsNoLongerAConfigurationAtAll). What
|
|
// replaced it, the hold factor, is a PERCENTAGE and not an amount — so it is logged plainly, and
|
|
// the assertion below is about the money that is left.
|
|
got, line := effective(t)
|
|
for _, k := range []string{"TM_PLATFORM_SIGNUP_GRANT_USD"} {
|
|
if got[k].Source != sourceEnv {
|
|
t.Errorf("%s: source %q, want the environment it came from", k, got[k].Source)
|
|
}
|
|
if got[k].Value != valueAmount {
|
|
t.Errorf("%s: value %q, want it withheld", k, got[k].Value)
|
|
}
|
|
}
|
|
fields := loggedFields(t, line) // parsed ONCE: the log does not change between amounts
|
|
for _, amount := range []string{"7.50", "7500000"} {
|
|
for _, field := range fields {
|
|
if strings.Contains(field, amount) {
|
|
t.Errorf("the boot line carries the amount %q, in the field %q", amount, field)
|
|
}
|
|
}
|
|
}
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.SignupGrantMicroUSD != 7_500_000 {
|
|
t.Fatalf("the amount did not apply: %d", c.SignupGrantMicroUSD)
|
|
}
|
|
}
|
|
|
|
// A default and an environment variable holding the same string are the same line without the
|
|
// source, and telling them apart is the whole question ("did my setting take effect").
|
|
func TestTheSourceOfEveryValueIsPrintedBesideIt(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_ADDR", "127.0.0.1:8080") // exactly the default
|
|
got, line := effective(t)
|
|
if got["TM_PLATFORM_ADDR"].Source != sourceEnv {
|
|
t.Errorf("a value set to the default reads as %q", got["TM_PLATFORM_ADDR"].Source)
|
|
}
|
|
if got["TM_PLATFORM_AFTER_LOGIN"].Source != sourceDefault {
|
|
t.Errorf("an unset value reads as %q", got["TM_PLATFORM_AFTER_LOGIN"].Source)
|
|
}
|
|
// One line per setting, as structured fields rather than a rendered sentence.
|
|
var first map[string]any
|
|
if err := json.Unmarshal([]byte(strings.SplitN(line, "\n", 2)[0]), &first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"key", "value", "source"} {
|
|
if _, ok := first[k]; !ok {
|
|
t.Errorf("the boot line has no %q field: %v", k, first)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An intake root that is not absolute names a different directory to every process that reads a
|
|
// book's workdir out of the database — the same class as PD-149 on the state directory, refused in
|
|
// the same place.
|
|
func TestARelativeBooksDirectoryIsRefusedAtBoot(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "zh>ru")
|
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "books")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("a relative TM_PLATFORM_BOOKS_DIR was accepted")
|
|
}
|
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books")
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.IntakeEnabled() {
|
|
t.Fatal("intake is enabled without an engine binary: a deployment that cannot parse would take uploads it can only reject")
|
|
}
|
|
t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl")
|
|
if c, err = Load(); err != nil || !c.IntakeEnabled() {
|
|
t.Fatalf("intake stayed off with a root and an engine: %v %+v", err, c.Intake)
|
|
}
|
|
}
|
|
|
|
// The upload deadline is bounded by two windows, and a deployment that breaks either loses data:
|
|
// past the intake sweep's grace the sweep deletes the row and the directory out from under a request
|
|
// still writing into them; past the idempotency claim window a retry takes the claim from an upload
|
|
// that is still running and the user gets a second book.
|
|
//
|
|
// ⚠ The boundary is pinned to the EXACT value, and that is the whole of this test. The window that
|
|
// binds today is the claim's, so any deadline over the sweep's grace is over it too — the earlier
|
|
// form asserted two over-long values and both were caught by the same check, so dropping the other
|
|
// one changed nothing. And "under the window" is not enough: what an upload does AFTER its body has
|
|
// to fit as well.
|
|
func TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "zh>ru")
|
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books")
|
|
t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl")
|
|
window := min(books.UploadGrace, pgstore.ClaimStale)
|
|
for _, d := range []time.Duration{
|
|
books.UploadGrace + time.Minute,
|
|
pgstore.ClaimStale + time.Minute,
|
|
// Inside the window and still wrong: the body ends a second before the claim is stealable and
|
|
// the receipt is written after it.
|
|
window - time.Second,
|
|
window - books.UploadSettle,
|
|
} {
|
|
t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", d.String())
|
|
if _, err := Load(); err == nil {
|
|
t.Errorf("an upload deadline of %s was accepted, though the whole upload does not fit %s", d, window)
|
|
}
|
|
}
|
|
t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (window - books.UploadSettle - time.Second).String())
|
|
if _, err := Load(); err != nil {
|
|
t.Fatalf("a deadline whose whole upload fits the window was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// An intake with no declared pairs makes the wire and the intake say opposite things.
|
|
func TestAnIntakeWithNoDeclaredPairsIsRefusedAtBoot(t *testing.T) {
|
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books")
|
|
t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("an intake that declares no language pair was accepted")
|
|
}
|
|
// A list that declares only pairs it CANNOT run is the same emptiness one level down: the intake
|
|
// judges by the available half, so it would accept every book while `/capabilities` lists nothing
|
|
// translatable.
|
|
t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "ja>ru:unavailable")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("an intake whose every declared pair is unavailable was accepted")
|
|
}
|
|
t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "zh>ru,ja>ru:unavailable")
|
|
if _, err := Load(); err != nil {
|
|
t.Fatalf("a declared available pair was refused: %v", err)
|
|
}
|
|
}
|