textmachine/platform/internal/config/config_test.go

403 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"os"
"path/filepath"
"textmachine/platform/internal/pricing"
"testing"
"time"
)
func TestDefaultsAndOverrides(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != "127.0.0.1:8080" {
t.Fatalf("default addr = %q; a control plane must not bind the world by default", c.Addr)
}
if c.Migrate {
t.Fatal("migrations must be opt-in")
}
t.Setenv("TM_PLATFORM_ADDR", ":9000")
t.Setenv("TM_PLATFORM_SESSION_IDLE", "30m")
t.Setenv("TM_PLATFORM_TRUSTED_ORIGINS", "https://app.example.org, https://desktop.example.org ")
c, err = Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != ":9000" || c.SessionIdleTTL != 30*time.Minute {
t.Fatalf("config = %+v", c)
}
if len(c.TrustedOrigins) != 2 || c.TrustedOrigins[1] != "https://desktop.example.org" {
t.Fatalf("origins = %q", c.TrustedOrigins)
}
}
// A RELATIVE state directory names two different directories: the exit marker is written by a unit
// whose WorkingDirectory is the BOOK's directory and read by this daemon from its own. The run then
// ends, its marker lands where nothing looks, and the reconciler restarts the run forever.
func TestARelativeStateDirectoryIsRefusedAtBoot(t *testing.T) {
t.Setenv("TM_PLATFORM_STATE_DIR", "var/run-state")
if _, err := Load(); err == nil {
t.Fatal("a relative state directory was accepted; the marker it writes is unreadable by the daemon")
}
t.Setenv("TM_PLATFORM_STATE_DIR", "/var/lib/other")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Runner.StateDir != "/var/lib/other" {
t.Errorf("state dir = %q", c.Runner.StateDir)
}
}
// The engine's ceiling flag landed (row 145, D39.122), so its form is the default rather than one
// more thing an operator must set before any run can start. An override that cannot carry the amount
// is refused instead of being sent as a literal.
func TestTheCeilingArgumentHasTheLandedFormByDefault(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Runner.CeilingArg != "--ceiling-usd {{usd}}" {
t.Errorf("default ceiling argument = %q", c.Runner.CeilingArg)
}
}
func TestImpossibleSessionWindowIsRefusedAtBoot(t *testing.T) {
t.Setenv("TM_PLATFORM_SESSION_IDLE", "100h")
t.Setenv("TM_PLATFORM_SESSION_MAX_AGE", "1h")
if _, err := Load(); err == nil {
t.Fatal("an idle window longer than the absolute one must fail at boot, not at 3am")
}
}
func TestMalformedDurationIsRefused(t *testing.T) {
t.Setenv("TM_PLATFORM_SESSION_IDLE", "fortnight")
if _, err := Load(); err == nil {
t.Fatal("want a parse error")
}
}
// A secret read from a file never enters the process environment, which is where a credential
// leaks from first (child processes inherit it, /proc exposes it). systemd hands one over this way.
func TestSecretsCanComeFromFiles(t *testing.T) {
path := filepath.Join(t.TempDir(), "dsn")
if err := os.WriteFile(path, []byte("postgres://u:p@h/db\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TM_PLATFORM_DSN_FILE", path)
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.DSN != "postgres://u:p@h/db" {
t.Fatalf("DSN = %q (trailing newline must be trimmed)", c.DSN)
}
t.Setenv("TM_PLATFORM_DSN_FILE", filepath.Join(t.TempDir(), "absent"))
if _, err := Load(); err == nil {
t.Fatal("a named secret file that cannot be read must fail at boot, not at the first query")
}
}
// Half a login configuration mounts a surface that fails at the first click instead of at boot.
func TestPartialOIDCConfigurationIsRefused(t *testing.T) {
t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.google.com")
t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "id")
if _, err := Load(); err == nil {
t.Fatal("an issuer without a secret and a redirect must be refused")
}
t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "s")
t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if !c.LoginEnabled() {
t.Fatal("a complete configuration must enable sign-in")
}
}
// The free tier is a number an operator sets, and a bad one must not become a silent zero.
//
// ⚠ The DEFAULT is zero (owner, 16.08 — D39.138 п.2л, register row PD-104): during the beta credit
// is granted by hand. It is pinned here because a self-service grant with no daily aggregate cap
// behind it is bounded only by how fast an attacker can create subjects, and the version of this
// test that read "five dollars" would go green again the moment somebody restored that default
// while looking at something else.
func TestSignupGrantIsParsedNotGuessed(t *testing.T) {
if c, err := Load(); err != nil || c.SignupGrantMicroUSD != 0 {
t.Fatalf("default grant = %d (%v)", c.SignupGrantMicroUSD, err)
}
t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "2.50")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.SignupGrantMicroUSD != 2_500_000 {
t.Fatalf("grant = %d", c.SignupGrantMicroUSD)
}
t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "five dollars")
if _, err := Load(); err == nil {
t.Fatal("an unparseable grant must fail at boot")
}
}
// PD-58. The session clocks are a declared conformance point, not a preference: ASVS 5.0 7.1.1
// takes the baseline from NIST SP 800-63B-4, whose AAL1 rule is that the overall reauthentication
// timeout SHOULD be no more than 30 days. The reasoning lives in STACK_DECISIONS §13; this keeps
// the defaults from drifting past it without someone changing that document too.
// Mutation caught: raising either default beyond the norm.
func TestSessionClocksStayWithinTheDeclaredBaseline(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatal(err)
}
const aal1Overall = 30 * 24 * time.Hour
if c.SessionMaxAge > aal1Overall {
t.Errorf("absolute session lifetime is %s, above the NIST SP 800-63B-4 AAL1 figure of %s: a deviation needs a written justification (ASVS 7.1.1)",
c.SessionMaxAge, aal1Overall)
}
if c.SessionIdleTTL <= 0 || c.SessionIdleTTL > c.SessionMaxAge {
t.Errorf("idle window is %s against an absolute of %s: an idle window that cannot expire first is not one",
c.SessionIdleTTL, c.SessionMaxAge)
}
}
// The runner's settings decide whether a run can start at all, so the failure modes have to be
// visible at LOAD, where an operator is watching, rather than at the first click.
func TestTheRunnerSettingsFailAtLoadAndNotAtTheFirstRun(t *testing.T) {
t.Setenv("TM_PLATFORM_RUN_TASKS_MAX", "not a number")
if _, err := Load(); err == nil {
t.Error("a non-numeric task cap was accepted")
}
t.Setenv("TM_PLATFORM_RUN_TASKS_MAX", "0")
if _, err := Load(); err == nil {
t.Error("a task cap of zero was accepted")
}
t.Setenv("TM_PLATFORM_RUN_TASKS_MAX", "")
t.Setenv("TM_PLATFORM_HOLD_FACTOR_PERCENT", "0")
if _, err := Load(); err == nil {
t.Error("a hold factor of zero was accepted: it holds nothing against the bill")
}
t.Setenv("TM_PLATFORM_HOLD_FACTOR_PERCENT", "")
t.Setenv("TM_PLATFORM_RESYNC_EVERY", "-5m")
if _, err := Load(); err == nil {
t.Error("a negative resync interval was accepted")
}
}
// The default rate is resolved HERE and nowhere else, so there is one answer to "what is this
// instance using" instead of one per caller. (It used to be resolved by the daemon, which meant a
// zero in the config and a real number in the process — found by the owner's question, 08.08.)
func TestTheHoldFactorAlwaysHasAValueAndIsJudgedAtBoot(t *testing.T) {
t.Setenv("TM_PLATFORM_HOLD_FACTOR_PERCENT", "")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Runner.HoldFactorPercent < 100 {
t.Fatalf("the hold factor is %d: the default has to be resolved in config", c.Runner.HoldFactorPercent)
}
t.Setenv("TM_PLATFORM_HOLD_FACTOR_PERCENT", "140")
if c, err = Load(); err != nil || c.Runner.HoldFactorPercent != 140 {
t.Fatalf("an explicit hold factor gave %d (%v)", c.Runner.HoldFactorPercent, err)
}
// ⚠ JUDGED AT BOOT and not at the first admission: a cushion below the engine's own expected bill
// is a discount, and every purchase under it latches before it delivers what it quoted. An
// operator who mistyped it must learn so here, not from a user's paused run.
t.Setenv("TM_PLATFORM_HOLD_FACTOR_PERCENT", "80")
if _, err := Load(); err == nil {
t.Fatal("a hold factor below 100% was accepted at boot")
}
}
// ⚠ THE PER-CHAPTER RATE IS GONE, and its absence is asserted rather than assumed. The variable set
// what a chapter COST — a constant measured 4.47× low (D39.179 §1), which no operator could have set
// right because the answer differs per book and per chapter, and which made the last chapters of
// every book unbuyable at any balance (PD-440). What a chapter costs is now read from the engine's
// own projection. An operator's stale `TM_PLATFORM_USD_PER_CHAPTER` must not silently do anything.
func TestThePerChapterRateIsNoLongerAConfigurationAtAll(t *testing.T) {
t.Setenv("TM_PLATFORM_USD_PER_CHAPTER", "0.05")
c, err := Load()
if err != nil {
t.Fatalf("a stale rate variable must be ignored, not refused: %v", err)
}
if c.Runner.HoldFactorPercent != pricing.DefaultHoldFactorPercent {
t.Errorf("the stale rate variable moved the hold factor to %d", c.Runner.HoldFactorPercent)
}
}
// An instance with no engine binary is a read replica, not a broken one: the library is not run
// machinery, and refusing to start would turn a partial capability into a total outage.
func TestAnInstanceWithoutAnEngineStillLoads(t *testing.T) {
t.Setenv("TM_PLATFORM_ENGINE_BIN", "")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.RunsEnabled() {
t.Error("runs are reported as enabled with no engine binary configured")
}
}
// ⚠ The one shape of the development sign-in that is dangerous: a deployment with a real identity
// provider AND a keyless door into a session. It is refused at BOOT — the service does not start —
// rather than warned about, because the way it comes about is an operator copying a dev unit file
// onto a host that carries the production environment, and a warning in a log is read by nobody.
//
// Mutation caught: turning the refusal into a log line, and checking only the full OIDC quartet.
func TestTheDevelopmentSignInCannotCoexistWithAnIdentityProvider(t *testing.T) {
full := map[string]string{
"TM_PLATFORM_OIDC_ISSUER": "https://accounts.example.org",
"TM_PLATFORM_OIDC_CLIENT_ID": "id",
"TM_PLATFORM_OIDC_CLIENT_SECRET": "secret",
"TM_PLATFORM_OIDC_REDIRECT_URL": "https://app.example.org/auth/callback",
}
// Every way an OIDC provider can be present, including the half-configured ones: any of them
// alongside the dev sign-in must stop the boot.
for name, env := range map[string]map[string]string{
"a whole provider": full,
"only an issuer": {"TM_PLATFORM_OIDC_ISSUER": full["TM_PLATFORM_OIDC_ISSUER"]},
"only a client id": {"TM_PLATFORM_OIDC_CLIENT_ID": "id"},
"only a redirect url": {"TM_PLATFORM_OIDC_REDIRECT_URL": full["TM_PLATFORM_OIDC_REDIRECT_URL"]},
"only a client secret": {"TM_PLATFORM_OIDC_CLIENT_SECRET": "secret"},
} {
t.Run(name, func(t *testing.T) {
t.Setenv("TM_PLATFORM_DEV_LOGIN", "dev@stand")
for k, v := range env {
t.Setenv(k, v)
}
if _, err := Load(); err == nil {
t.Fatal("the service started with both a real identity provider and a development sign-in")
}
})
}
// And on its own it is legitimate — the stand is the whole point.
t.Run("on its own", func(t *testing.T) {
t.Setenv("TM_PLATFORM_DEV_LOGIN", "dev@stand")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if !c.DevLoginEnabled() || c.LoginEnabled() {
t.Errorf("dev=%v oidc=%v", c.DevLoginEnabled(), c.LoginEnabled())
}
})
// Absent, nothing is mounted. This is the default and what every deployment file here sets.
t.Run("absent by default", func(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.DevLoginEnabled() {
t.Error("the development sign-in is on without anybody asking for it")
}
})
}
// The OTHER half of the same accident, which the acceptance reached with a live daemon: the stand's
// environment sets two variables, and dropping only the sign-in from it left a service issuing real
// Google sessions in cookies without Secure or __Host-, with HSTS off.
//
// What judges it is the deployment's own address rather than a guess: the OIDC callback is this
// platform's public URL, so plain http there IS the stand the switch is for, and anything else
// contradicts it.
//
// Mutation caught: removing the refusal, and judging it by anything other than the callback's scheme.
func TestCookiesWithoutSecureAreRefusedNextToAProductionProvider(t *testing.T) {
oidc := func(t *testing.T, callback string) {
t.Helper()
t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.example.org")
t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "id")
t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "secret")
t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", callback)
}
// Both cases must fail FOR THIS GUARD. An earlier draft carried a third — an empty callback —
// which passes with the guard deleted, because half a provider is refused by the partial-OIDC
// check above it (pinned separately by TestPartialOIDCConfigurationIsRefused). A row that stays
// green under the mutation it is written for is coverage that is not there; found by the
// cross-family review of this dofix.
for name, callback := range map[string]string{
"a public deployment": "https://app.example.org/auth/callback",
"a callback nothing parses": "://app.example.org",
} {
t.Run(name, func(t *testing.T) {
t.Setenv("TM_PLATFORM_INSECURE_COOKIES", "1")
oidc(t, callback)
if _, err := Load(); err == nil {
t.Fatal("the service started with a real identity provider and cookies without Secure")
}
})
}
// A stand that runs a local provider over plain http is legitimate and keeps working: Secure and
// __Host- cannot be used there at all, so refusing it would only push an operator into a worse
// arrangement.
t.Run("a local provider over http", func(t *testing.T) {
t.Setenv("TM_PLATFORM_INSECURE_COOKIES", "1")
oidc(t, "http://127.0.0.1:8080/auth/callback")
c, err := Load()
if err != nil {
t.Fatalf("a plain-http stand with a local provider was refused: %v", err)
}
if !c.InsecureCookies || !c.LoginEnabled() {
t.Errorf("insecure=%v oidc=%v", c.InsecureCookies, c.LoginEnabled())
}
})
// And with no provider at all it is the ordinary development switch.
t.Run("on its own", func(t *testing.T) {
t.Setenv("TM_PLATFORM_INSECURE_COOKIES", "1")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if !c.InsecureCookies {
t.Error("the development cookie profile did not apply")
}
})
}
// The development sign-in's identity namespace is RESERVED, and this is the guard the adversarial
// review of that feature asked for.
//
// The identity key is (provider, subject) and TM_PLATFORM_OIDC_PROVIDER is free-form operator input.
// An issuer configured under the name `dev` therefore files its subjects in the namespace a
// development stand writes into — and a `sub` that equals the dev subject resolves to the DEV
// account, with whatever credit was seeded onto it. That is the account-linking class §10a warns
// about, one env var away.
//
// Mutation caught: removing the reserved-name refusal from Load.
func TestAnIssuerCannotBeConfiguredUnderTheDevelopmentProvidersName(t *testing.T) {
for _, name := range []string{"dev"} {
t.Setenv("TM_PLATFORM_OIDC_PROVIDER", name)
t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.example.org")
t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "id")
t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "secret")
t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback")
if _, err := Load(); err == nil {
t.Fatalf("an issuer was accepted under the name %q: its subjects would share the development namespace", name)
}
}
// An ordinary provider name is untouched.
t.Setenv("TM_PLATFORM_OIDC_PROVIDER", "google")
if _, err := Load(); err != nil {
t.Fatalf("an ordinary provider name was refused: %v", err)
}
}
// "Mounted only when it NAMES a subject" has to mean a subject, not a space. A value of whitespace
// would otherwise mount a keyless sign-in whose identity is " ".
func TestAWhitespaceDevelopmentSubjectMountsNothing(t *testing.T) {
t.Setenv("TM_PLATFORM_DEV_LOGIN", " ")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.DevLoginEnabled() {
t.Error("a whitespace subject mounted the development sign-in")
}
}