textmachine/backend/cmd/tmctl/keysfile_test.go

448 lines
21 KiB
Go

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)
}
}
}