403 lines
16 KiB
Go
403 lines
16 KiB
Go
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")
|
||
}
|
||
}
|