Files
felhom-controller/controller/internal/web/meta_copy_allowlist_test.go
T
admin 60f0a86bd4
gates / gates (push) Successful in 25s
v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.

`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.

Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.

Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.

MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:31:52 +02:00

109 lines
4.3 KiB
Go

package web
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strings"
"testing"
)
// TestNoDirectMetaCopyReadOnPages — the guard for R-560's whole point.
//
// Catalog copy (`Description`, `AppInfo`, the deploy-field labels, `OptionalConfig`, `Integrations`,
// the `DataPaths` labels, the initial-credentials note) is HUNGARIAN in the value the stack manager
// caches. A page reaches the household's language only by going through `Metadata.For(lang)` —
// `stacks.LocalizeStacks`, `LocalizeStack`, `LocalizeStackPtr` or `MetaFor`. Read `x.Meta.<copy>`
// straight off a manager value and an English household silently gets Hungarian: no error, no log,
// nothing that looks wrong on the page. That is precisely the failure mode this project has shipped
// nine times under a comment that read as settled.
//
// So every direct read of a copy field off a `.Meta` in this package is LISTED BELOW WITH A REASON.
// A new one fails this test, and the author then has two honest choices: route it through For(lang),
// or add it here saying why it may stay Hungarian.
//
// WHAT THIS TEST CANNOT DO: it reads the source, so it cannot tell a localised receiver from an
// unlocalised one — `found.Meta.AppInfo` looks the same either way. It catches the ARRIVAL of a new
// copy read, which is when a human has to think, and that is the whole claim made for it.
//
// RED-PROOF (2026-09-20): adding `_ = stack.Meta.Description` to handlers.go failed this test
// naming handlers.go and Description; removing it went green.
var metaCopyReadAllowlist = map[string]string{
// The app page localises `found` in one place at the top of appDetailHandler and every read
// below it — these included — is of that localised value.
"handlers.go:AppInfo": "appDetailHandler reads it off the LocalizeStackPtr'd `found`",
"handlers.go:InitialCreds": "appDetailHandler: the nil check and the note, both off localised `found`",
// buildDataPathCards is called with the same localised `found`; the labels it renders are the
// English ones when the household is on English.
"datapath_card.go:DataPaths": "buildDataPathCards is handed the localised stack by appDetailHandler",
}
// metaCopyFields are the Metadata fields that carry customer-facing TEXT. Everything else on
// Metadata — Slug, Subdomain, OpenPath, BrandColor, Category, Resources, Lifecycle, CatalogSince,
// HealthCheck, SMTPMapping, Backup — is configuration and reads the same in every language.
// DisplayName is deliberately NOT here: an app's name is not translated (10-localisation.md §11,
// operator ruling 7).
var metaCopyFields = map[string]bool{
"Description": true,
"AppInfo": true,
"DeployFields": true,
"OptionalConfig": true,
"Integrations": true,
"DataPaths": true,
"InitialCreds": true,
}
func TestNoDirectMetaCopyReadOnPages(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
var unlisted []string
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, name, nil, 0) // comments are not walked — only real reads count
if err != nil {
t.Fatalf("%s: %v", name, err)
}
ast.Inspect(f, func(n ast.Node) bool {
outer, ok := n.(*ast.SelectorExpr)
if !ok || !metaCopyFields[outer.Sel.Name] {
return true
}
inner, ok := outer.X.(*ast.SelectorExpr)
if !ok || inner.Sel.Name != "Meta" {
return true
}
key := name + ":" + outer.Sel.Name
seen[key] = true
if _, allowed := metaCopyReadAllowlist[key]; !allowed {
unlisted = append(unlisted, fmt.Sprintf("%s (%s)", key, fset.Position(outer.Pos())))
}
return true
})
}
sort.Strings(unlisted)
for _, u := range unlisted {
t.Errorf("catalog COPY read straight off .Meta: %s\n"+
" Route it through stacks.LocalizeStack(s)/LocalizeStackPtr/MetaFor(lang), or add it to\n"+
" metaCopyReadAllowlist in this file with the reason it may stay Hungarian.", u)
}
// A stale allow-list entry is a lie about what the code does — it must go when its read goes.
for key := range metaCopyReadAllowlist {
if !seen[key] {
t.Errorf("allow-list entry %q no longer matches any read — delete it", key)
}
}
}