package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" "textmachine/backend/internal/membank" ) // keysfile_test.go: `--keys-file` (backlog row 211) and the `bank-apply` half of the CLI surface. // // Row 211 is a dev/prod PARITY trap, which is why the pins are about who may pass the flag and in what // order it is read rather than about a provider call. On the SaaS path the engine's unit gets exactly one // environment variable and no key file is written anywhere, while the dev supervisor spawns the engine as // an ordinary child that inherits the platform's own environment — so every gate ran green on a path that // had keys while production starved. Nothing here spends a cent: the values are fake by construction and // the assertions are about the CHAIN, not about a call. // setEnvKey sets a variable for the duration of one test. func setEnvKey(t *testing.T, k, v string) { t.Helper() t.Setenv(k, v) } func writeKeys(t *testing.T, dir, name, body string) string { t.Helper() p := filepath.Join(dir, name) writeCLIFile(t, p, body) return p } // TestKeysFileWinsOverTheConventionalEnv pins the ORDER of the chain: the deployment's file is loaded // FIRST and therefore wins, because no link overrides a variable that is already set. Explicit beats // convention, and a stand book that passes no flag keeps exactly the behaviour it had. func TestKeysFileWinsOverTheConventionalEnv(t *testing.T) { dir := t.TempDir() deploy := writeKeys(t, dir, "deploy.env", "FAKE_PROVIDER_KEY=from-deploy\nDEPLOY_ONLY=yes\n") bookEnv := writeKeys(t, dir, ".env", "FAKE_PROVIDER_KEY=from-book-dir\nBOOK_ONLY=yes\n") setEnvKey(t, "FAKE_PROVIDER_KEY", "") setEnvKey(t, "DEPLOY_ONLY", "") setEnvKey(t, "BOOK_ONLY", "") var warn bytes.Buffer if err := loadKeysFile(deploy, &warn); err != nil { t.Fatalf("a readable key file must load: %v", err) } loadDotEnv(bookEnv, &warn) if got := os.Getenv("FAKE_PROVIDER_KEY"); got != "from-deploy" { t.Errorf("FAKE_PROVIDER_KEY = %q, want the DEPLOYMENT's value — the explicit file is first in the chain", got) } // …and the conventional file is still read for everything the deployment did not name, so a book // directory carrying an extra variable is not silently ignored. if got := os.Getenv("BOOK_ONLY"); got != "yes" { t.Errorf("BOOK_ONLY = %q: the conventional .env must still load its own keys", got) } if got := os.Getenv("DEPLOY_ONLY"); got != "yes" { t.Errorf("DEPLOY_ONLY = %q", got) } } // TestKeysFileFailsLoud: a file the caller NAMED and that cannot be read is a deployment fault. The // alternative is a paid run that starts, calls a provider and gets 401 — the failure row 211 describes. func TestKeysFileFailsLoud(t *testing.T) { dir := t.TempDir() if err := loadKeysFile(filepath.Join(dir, "not-there.env"), &bytes.Buffer{}); err == nil { t.Error("an absent named key file must be an error, never a silent skip") } empty := writeKeys(t, dir, "empty.env", "# only a comment\n") if err := loadKeysFile(empty, &bytes.Buffer{}); err == nil { t.Error("a named key file with no KEY=VALUE line is the same fault one step further along") } } // TestOnlyTranslateAcceptsKeys is the mutation pin for "a reading verb demands keys" (D20.4). The $0 // projections must not merely tolerate the flag: accepting it teaches a caller to pass it everywhere, and // the next reader of that wiring concludes the read commands need provider keys. func TestOnlyTranslateAcceptsKeys(t *testing.T) { for _, cmd := range []string{"status", "report", "export", "manifest", "redrive", "backup", "migrate", "seed-lint", "bank-apply"} { _, err := parseInvocation([]string{cmd, "--config", "book.yaml", "--keys-file", "k.env"}, &bytes.Buffer{}) if err == nil { t.Errorf("%s accepted --keys-file", cmd) continue } if !strings.Contains(err.Error(), "--keys-file") { t.Errorf("%s: the refusal must name the flag, got: %v", cmd, err) } } inv, err := parseInvocation([]string{"translate", "--config", "book.yaml", "--keys-file", "k.env"}, &bytes.Buffer{}) if err != nil || inv.keysFile != "k.env" { t.Fatalf("translate must take the flag: inv=%+v err=%v", inv, err) } } // TestOnlyBankApplyAcceptsDecisions is the same rule as TestOnlyTranslateAcceptsKeys for the pack's own // new flag, and the silent version of THIS one costs money: every flag of this CLI lives in one FlagSet, // so `tmctl translate --decisions d.json` parsed cleanly and ran a PAID translation while its caller // believed it was applying a user's bank corrections. Found by re-checking the pack's own scoping after // the reviews — the keys flag had the guard from the start and the decisions flag did not. func TestOnlyBankApplyAcceptsDecisions(t *testing.T) { for _, cmd := range []string{"translate", "status", "report", "export", "redrive", "manifest", "backup", "migrate"} { _, err := parseInvocation([]string{cmd, "--config", "book.yaml", "--decisions", "d.json"}, &bytes.Buffer{}) if err == nil { t.Errorf("%s accepted --decisions and would have done something other than apply decisions", cmd) continue } if !strings.Contains(err.Error(), "--decisions") { t.Errorf("%s: the refusal must name the flag, got: %v", cmd, err) } } inv, err := parseInvocation([]string{"bank-apply", "--config", "book.yaml", "--decisions", "d.json"}, &bytes.Buffer{}) if err != nil || inv.decisionsPath != "d.json" { t.Fatalf("bank-apply must take the flag: inv=%+v err=%v", inv, err) } } // TestBankApplySurvivesABrokenModelsFile pins the reason the verb is a package-level function rather // than a Runner method: a $0 decisions verb must not stop working because of a file its decisions have // nothing to do with. Asserted rather than assumed — it was a claim in a comment until this test. func TestBankApplySurvivesABrokenModelsFile(t *testing.T) { if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) bookPath := setupCLIProject(t, "http://127.0.0.1:1") dir := filepath.Dir(bookPath) if err := os.Remove(filepath.Join(dir, "models.yaml")); err != nil { t.Fatal(err) } body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book", Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}}) if err != nil { t.Fatal(err) } doc := filepath.Join(dir, "decisions.json") writeCLIFile(t, doc, string(body)) out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput() if got := exitCodeOf(t, err); got != 0 { t.Fatalf("a decisions verb must not depend on models.yaml: exit %d\n%s", got, out) } // …and the comparison that gives the claim its force: a command that DOES build a Runner refuses the // same book, so the test is not passing for want of a gate. _, serr := exec.Command(bin, "status", "--config", bookPath, "--json").CombinedOutput() if got := exitCodeOf(t, serr); got == 0 { t.Fatal("status was expected to refuse this book; the comparison proves nothing if it does not") } } // TestAnUnreadableKeysFileRefusesWithTheConfigClass drives the REAL binary: the shell contract is only a // contract at the shell. Exit 10 is "this deployment will not run and a human fixes it", which is exactly // what a named-but-absent key file is. func TestAnUnreadableKeysFileRefusesWithTheConfigClass(t *testing.T) { if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) bookPath := setupCLIProject(t, "http://127.0.0.1:1") missing := filepath.Join(filepath.Dir(bookPath), "no-such-keys.env") out, err := exec.Command(bin, "translate", "--config", bookPath, "--keys-file", missing).CombinedOutput() if got := exitCodeOf(t, err); got != exitConfigInvalid { t.Fatalf("exit %d, want %d\n%s", got, exitConfigInvalid, out) } if !strings.Contains(string(out), "--keys-file") { t.Errorf("the operator must be told which file: %s", out) } } // TestBankApplyIsAZeroDollarVerbAtTheShell walks the whole command through the real binary, on a book // whose provider is unreachable — if the verb touched a provider or demanded a key, this is where it // would show. It also pins the projection: `--dry-run` writes nothing. func TestBankApplyIsAZeroDollarVerbAtTheShell(t *testing.T) { if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) bookPath := setupCLIProject(t, "http://127.0.0.1:1") dir := filepath.Dir(bookPath) body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book", Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}}) if err != nil { t.Fatal(err) } doc := filepath.Join(dir, "decisions.json") writeCLIFile(t, doc, string(body)) delta := filepath.Join(dir, "cli-book.mined-delta.yaml") out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc, "--dry-run").CombinedOutput() if got := exitCodeOf(t, err); got != 0 { t.Fatalf("projection exit %d\n%s", got, out) } var rep struct { Version string `json:"report_version"` Mode string `json:"mode"` Depth string `json:"depth"` Changed bool `json:"changed"` } if jerr := json.Unmarshal(out, &rep); jerr != nil { t.Fatalf("the report must be the whole of stdout and must parse: %v\n%s", jerr, out) } if rep.Version != membank.DecisionsReportVersion || rep.Mode != "projection" || rep.Depth != membank.DecisionDepth || !rep.Changed { t.Fatalf("report = %+v", rep) } if _, serr := os.Stat(delta); serr == nil { t.Fatal("the projection wrote the delta") } if out, err = exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput(); err != nil { t.Fatalf("apply: %v\n%s", err, out) } raw, rerr := os.ReadFile(delta) if rerr != nil || !strings.Contains(string(raw), "status: approved") { t.Fatalf("delta after apply: err=%v\n%s", rerr, raw) } } // TestTheKeysFileActuallyReachesTheProvider is the strongest proof available for $0 (promt §4.7): a // project whose provider DECLARES an api_key_env, a fake HTTP provider that asserts the Authorization // header, and the key delivered ONLY through --keys-file. // // Two halves, and the first is what makes the second mean anything: without the flag the run is refused // at config validation naming the missing env, so the pass with it cannot be an accident of an inherited // environment — which is the exact dev/prod parity trap row 211 records. // // ⚠ The key is FAKE and the provider is an httptest server. Nothing here proves anything about a REAL // provider call; the runtime verdict on the paid path stays PLAUSIBLE. func TestTheKeysFileActuallyReachesTheProvider(t *testing.T) { if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) const envName = "TM_FAKE_PROVIDER_KEY" seen := make(chan string, 8) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case seen <- r.Header.Get("Authorization"): default: } fmt.Fprint(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":"ПЕРЕВОД"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":5}}`) })) defer srv.Close() bookPath := setupCLIProject(t, srv.URL) dir := filepath.Dir(bookPath) // Re-declare the provider WITH a key env. Everything else is the fixture's own models.yaml. writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(` prices_checked: %q default_model: fake-model providers: fake: kind: openai base_url: %q api_key_env: %s timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 } models: fake-model: provider: fake price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } `, time.Now().UTC().Format("2006-01-02"), srv.URL, envName)) // The engine must not inherit the key from the test process, or the assertion below proves nothing. env := append(os.Environ(), envName+"=") bare := exec.Command(bin, "translate", "--config", bookPath) bare.Env = env out, err := bare.CombinedOutput() if got := exitCodeOf(t, err); got != exitConfigInvalid { t.Fatalf("without a key the run must be refused as a config: exit %d\n%s", got, out) } if !strings.Contains(string(out), envName) { t.Fatalf("the refusal must name the missing env: %s", out) } keys := writeKeys(t, dir, "deploy.env", envName+"=fake-deploy-key\n") withKeys := exec.Command(bin, "translate", "--config", bookPath, "--keys-file", keys) withKeys.Env = env if out, err = withKeys.CombinedOutput(); err != nil { t.Fatalf("the run must start once the deployment names its key file: %v\n%s", err, out) } select { case auth := <-seen: if auth != "Bearer fake-deploy-key" { t.Fatalf("Authorization = %q — the key from --keys-file did not reach the wire", auth) } default: t.Fatal("the provider was never called, so nothing was proved about the key") } } // TestARejectedDecisionSetHasItsOwnNumberInTheBand drives the real binary for the new class. // // Its own number and not 10, on the axis a caller ACTS on: the platform's reader files 10 as a // deployment fault and exempts it from a book's attempt budget as "a condition every book on the host // shares" (platform/internal/ingest/exit.go DeploymentFault) — while a rejected decision set is one // user's document on one book, with retry futile until they re-decide. func TestARejectedDecisionSetHasItsOwnNumberInTheBand(t *testing.T) { if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) bookPath := setupCLIProject(t, "http://127.0.0.1:1") dir := filepath.Dir(bookPath) body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "cli-book", Decisions: []membank.Decision{{Action: membank.ActionApprove, Src: "図書館"}}}) // no dst if err != nil { t.Fatal(err) } doc := filepath.Join(dir, "bad.json") writeCLIFile(t, doc, string(body)) out, err := exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", doc).CombinedOutput() if got := exitCodeOf(t, err); got != exitDecisionsRejected { t.Fatalf("exit %d, want %d\n%s", got, exitDecisionsRejected, out) } if exitDecisionsRejected < refusalFirst || exitDecisionsRejected > refusalLast { t.Fatalf("the new class must live INSIDE the band [%d,%d] every consumer keys on", refusalFirst, refusalLast) } // The report is the product of a refused call and must still be on stdout, whole. var rep struct { Rejected []struct { Reason string `json:"reason"` } `json:"rejected"` } if jerr := json.Unmarshal(out[:strings.LastIndex(string(out), "}")+1], &rep); jerr != nil || len(rep.Rejected) != 1 { t.Fatalf("a refusal must still print the report naming each rejection: %v\n%s", jerr, out) } // A document that never parsed AS a decisions document stays the CONFIG class: the caller's wiring // is what is wrong there, not their decisions. junk := filepath.Join(dir, "junk.json") writeCLIFile(t, junk, `{"decisions_version":"tm-bank-decisions-v99"}`) _, err = exec.Command(bin, "bank-apply", "--config", bookPath, "--decisions", junk).CombinedOutput() if got := exitCodeOf(t, err); got != exitConfigInvalid { t.Fatalf("an undecodable document: exit %d, want %d", got, exitConfigInvalid) } } // TestBankApplyNeedsItsDocument: defaulting the decision document to some conventional path would let an // argument-less invocation mutate a book's canon. func TestBankApplyNeedsItsDocument(t *testing.T) { if _, err := parseInvocation([]string{"bank-apply", "--config", "book.yaml"}, &bytes.Buffer{}); err == nil { t.Fatal("bank-apply without --decisions must be refused") } } // --- the dofix pack --------------------------------------------------------------------------------- func TestAFlagIsRefusedForBEINGTHERE_NotForCarryingAValue(t *testing.T) { // The money hole that survived in one spelling. Both guards asked whether the VALUE was non-empty, so // `--decisions=` slipped past and `tmctl translate --decisions=` went down the PAID path exactly as if // the flag were absent. The live trigger is not a typist: it is `--decisions=$DOC` in a unit file or a // deploy script with the variable unset, which is the ordinary way an empty value is produced. // // The technique is the only one that answers the question: fs.Visit reports the flags actually PASSED. // fs.Lookup returns the DECLARED flag whether or not anybody passed it, so it cannot tell these apart. for _, form := range [][]string{ {"translate", "--config", "b.yaml", "--decisions="}, {"translate", "--config", "b.yaml", "--decisions", ""}, {"redrive", "--config", "b.yaml", "--keys-file="}, {"status", "--config", "b.yaml", "--keys-file", ""}, } { if _, err := parseInvocation(form, &bytes.Buffer{}); err == nil { t.Errorf("%v was accepted — an empty value on a flag the command does not act on is still the flag", form) } } // The same rule for the command that DOES take the flag: an empty value there is an unset variable // too, and «apply nothing» is not a thing this verb does. if _, err := parseInvocation([]string{"bank-apply", "--config", "b.yaml", "--decisions="}, &bytes.Buffer{}); err == nil { t.Error("bank-apply accepted an empty --decisions") } if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--keys-file="}, &bytes.Buffer{}); err == nil { t.Error("translate accepted an empty --keys-file and would fall back to the conventional .env") } // …and an ABSENT flag is untouched, which is what keeps every ordinary invocation working. if _, err := parseInvocation([]string{"translate", "--config", "b.yaml"}, &bytes.Buffer{}); err != nil { t.Errorf("an absent flag must change nothing: %v", err) } } func TestTheKeysFileRefusalDescribesEveryoneItTurnsAway(t *testing.T) { // The branch rejects EIGHT commands and its reason described three of them. An operator who ran // `redrive --keys-file=...` was told that «the $0 read commands must not demand provider keys» — about // a command that is the paid re-attack path, and that has a key channel, just not this one. A refusal // whose reason does not describe the reader teaches them the wrong thing about the system. // // Deliberately NOT decided here: whether `redrive` should get the flag. That fork is a ping; this is // only about the sentence being true of everyone who reads it. // Enumerated from dispatchCommands, NOT from a list written here. A hand list is the same weakness // §4.11(в) was raised against, and it reproduced immediately: the first version of this test named // four commands and missed `bank-apply` — the verb this pack itself added — which the refusal then // did not describe either. for _, cmd := range dispatchCommands { if cmd == "translate" { continue } _, err := parseInvocation([]string{cmd, "--config", "b.yaml", "--keys-file", "k.env"}, &bytes.Buffer{}) if err == nil { t.Fatalf("%s must be refused the flag", cmd) } // ⚠ The name is stripped from the «not by %q» clause FIRST. Without that this assertion is // vacuous: every refusal quotes the command it is refusing, so `strings.Contains` was satisfied by // the accusation rather than by the explanation — and it passed with `bank-apply` deleted from the // reason. Caught by planting exactly that. rest := strings.Replace(err.Error(), fmt.Sprintf("%q", cmd), "", 1) if !strings.Contains(rest, cmd) { t.Errorf("the reason given to `%s` names it only to refuse it, and then describes somebody else: %v", cmd, err) } } } // TestDryRunIsRefusedWhereNothingProjects closes the third spelling of the same money hole, found by // the fix2 review workflow: `tmctl translate --dry-run` parsed cleanly and ran a PAID translation while // its caller believed it was asking for a $0 projection. The rule is this file's own: a flag a command // does not act on is refused, never ignored — and this flag's silent version is the most expensive, // because it inverts the one promise (--dry-run = spends nothing) the caller is relying on. func TestDryRunIsRefusedWhereNothingProjects(t *testing.T) { for _, form := range [][]string{ {"translate", "--config", "b.yaml", "--dry-run"}, {"status", "--config", "b.yaml", "--dry-run"}, {"export", "--config", "b.yaml", "--dry-run"}, {"migrate", "--config", "b.yaml", "--dry-run"}, } { if _, err := parseInvocation(form, &bytes.Buffer{}); err == nil { t.Errorf("%v was accepted — the caller believes this call is free, and it is not", form) } else if !strings.Contains(err.Error(), "dry-run") { t.Errorf("%v: the refusal must name the flag: %v", form, err) } } // The two commands that DO project keep the flag. for _, form := range [][]string{ {"bank-apply", "--config", "b.yaml", "--decisions", "d.json", "--dry-run"}, {"redrive", "--config", "b.yaml", "--chapter", "1", "--chunk", "0", "--reason", "x", "--dry-run"}, } { if _, err := parseInvocation(form, &bytes.Buffer{}); err != nil { t.Errorf("%v must keep its projection mode: %v", form, err) } } }