From 9811aaabbbc2b6d8c8cd50c0e13e79f61415813b Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Tue, 18 Aug 2026 12:07:14 +0000 Subject: [PATCH] feat(P1): lexicon firewall for web surface (REQ-045) Add lexicon_meta_web/ sibling meta-test mirroring lexicon_meta_docs/. Scans web/templates/**/*.html + web/static/**/*.js + web/**/*.go using the shared lexicon.FindBannedTerm (no detection reimplementation). Includes G-009 self-test table (lexicon.SyntheticBannedStrings), banned-terms count (10), openyield/european false-positive guard, and G-013 walk-coverage (injects a synthetic banned-term fixture into web/templates/.lexicon_fixture/ and asserts the walk finds it). Firewall passes green with zero web content (closed by the walk-coverage test). ---ci--- project: oy phase: 1 milestone: v0.6 status: execute ---/ci--- --- lexicon_meta_web/lexicon_meta_web_test.go | 307 ++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 lexicon_meta_web/lexicon_meta_web_test.go diff --git a/lexicon_meta_web/lexicon_meta_web_test.go b/lexicon_meta_web/lexicon_meta_web_test.go new file mode 100644 index 0000000..dfd9357 --- /dev/null +++ b/lexicon_meta_web/lexicon_meta_web_test.go @@ -0,0 +1,307 @@ +// Package lexicon_meta_web holds the web lexicon firewall (REQ-045, D-069). +// +// It is a NEW sibling meta-test created in v0.6 P1 Wave 1 that MIRRORS the +// v0.3 docs firewall (lexicon_meta_docs/lexicon_meta_docs_test.go, package +// lexicon_meta_docs) but scans the web surface (web/templates/**/*.html + +// web/static/**/*.js + web/**/*.go) instead of README.md + docs/**/*.md. It +// uses the SAME lexicon.FindBannedTerm (word-boundary, case-insensitive) — +// NO detection reimplementation — so the three firewalls (x/*.go, docs, web) +// share a single source of truth for the 10 banned terms (bank, deposit, +// interest, yield, currency, dollar, euro, account, savings, depositor). +// +// Placement: this file lives in lexicon_meta_web/ (a subdirectory of the +// repo root) because Go does not permit two distinct packages in the same +// directory; the v0.2 firewall is package lexicon_meta at the repo root and +// the v0.3 firewall is package lexicon_meta_docs in lexicon_meta_docs/. The +// invocation `go test ./lexicon_meta_web/...` (PLANS P1-01-01) resolves to +// this package. Run via `go test ./...` from the repo root as well. +// +// G-013 walk-coverage: TestLexiconMetaWebWalkCoverage injects a synthetic +// banned-term .html into a temp web/templates/ subtree and asserts the walk +// FINDS it. This closes the "silently scans nothing and reports green" +// failure mode that the G-009 self-test table (detection) alone does not +// cover. +// +// G-014 self-test drift: the self-test table and banned-term count assertion +// reuse lexicon.BannedTerms() (the single source). A cross-reference comment +// keeps this file's table in lockstep with lexicon_meta_test.go's table and +// lexicon_meta_docs_test.go's table; if a banned term is added, all three +// firewalls update from one place. +package lexicon_meta_web + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/oy/openyield/lexicon" +) + +// repoRoot returns the absolute path to the repo root by walking up from +// this test file (the test lives at /lexicon_meta_web/). +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // file = .../oy/lexicon_meta_web/lexicon_meta_web_test.go + // repo root = filepath.Dir(filepath.Dir(file)) + return filepath.Dir(filepath.Dir(file)) +} + +// thisFile returns the absolute path of this meta-test file (to exclude it +// from its own scan — it references banned terms via the lexicon package, +// whose source assembles terms from fragments, so no banned-term literal +// appears in the firewall's own code). +func thisFile(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return file +} + +// isWebTarget reports whether path (relative to repo root) is a file the web +// firewall scans: web/templates/**/*.html, web/static/**/*.js, and +// web/**/*.go (production + test). Non-{html,js,go} files under web/ (e.g. +// vendored binary assets) are skipped. +func isWebTarget(rel string) bool { + if !strings.HasPrefix(rel, "web"+string(filepath.Separator)) { + return false + } + return strings.HasSuffix(rel, ".html") || strings.HasSuffix(rel, ".js") || strings.HasSuffix(rel, ".go") +} + +// TestLexiconMetaWebNoBannedTermsInWeb is the web firewall (D-069). It walks +// the repo root, targets web/templates/**/*.html + web/static/**/*.js + +// web/**/*.go (production + test), reads each file's source, and asserts no +// banned term is present (word-boundary, case-insensitive). Excludes +// .ciagent/ (firewall meta-files discuss banned terms by name for +// governance; not user-facing), .git/ (VCS), and this test file itself +// (self-exclusion via runtime.Caller(0)). +// +// Passes at P1 Wave 1 with zero web content (a walk that scans nothing +// reports green on zero hits — closed by TestLexiconMetaWebWalkCoverage +// below). With the Wave 2..4 web content present (templates, static assets, +// handlers, store), all are lexicon-clean by construction. +func TestLexiconMetaWebNoBannedTermsInWeb(t *testing.T) { + root := repoRoot(t) + this := thisFile(t) + hits := []string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + base := filepath.Base(path) + if base == ".ciagent" || base == ".git" { + return filepath.SkipDir + } + return nil + } + // Self-exclusion: skip this meta-test file. + if path == this { + return nil + } + rel, rerr := filepath.Rel(root, path) + if rerr != nil { + return rerr + } + if !isWebTarget(rel) { + return nil + } + bz, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + hits = append(hits, rel+" contains banned term "+found) + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + if len(hits) > 0 { + t.Errorf("REQ-045 web lexicon firewall violations:\n %s", + strings.Join(hits, "\n ")) + } +} + +// TestLexiconMetaWebSelfTestTable (G-009 for web) is the firewall's own +// detection-coverage guard. Each synthetic string embeds exactly one banned +// term in a plausible sentence context and is asserted to trigger detection, +// so the firewall's detection logic is durably verified — if detection ever +// breaks, this test fails before the firewall silently passes a real +// violation in a web template or handler. +// +// REQ-029 (GRILL G-014): the synthetic strings are sourced from +// lexicon.SyntheticBannedStrings(), the single source of truth shared with +// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable and +// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable. Before +// REQ-029, each meta-test DUPLICATED its own 10-string table (byte-identical), +// creating a drift risk; the shared helper closes it. This file no longer +// builds its own synthetic table — all three meta-tests consume the same +// helper, so a future banned-term addition updates all firewalls from one +// place. +func TestLexiconMetaWebSelfTestTable(t *testing.T) { + terms := lexicon.BannedTerms() + // The spec lists 10 banned terms (plan docs say "9", counting dollar/euro + // as a pair): bank, deposit, interest, yield, currency, dollar, euro, + // account, savings, depositor. + if len(terms) != 10 { + t.Fatalf("BannedTerms() len = %d, want 10", len(terms)) + } + // REQ-029: consume the shared synthetic-string helper (G-014 single source). + synthetic := lexicon.SyntheticBannedStrings() + if len(synthetic) != len(terms) { + t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms)) + } + for i, s := range synthetic { + found, ok := lexicon.FindBannedTerm(s) + if !ok { + t.Errorf("G-009 web self-test [%d]: synthetic string did not trigger detection: %q", i, s) + continue + } + if found != terms[i] { + t.Errorf("G-009 web self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s) + } + } +} + +// TestLexiconMetaWebBannedTermsCount asserts exactly 10 banned terms are +// configured (locked-const for the firewall's scope; spec lists 10, plan docs +// say "9" counting dollar/euro as a pair). Derived from lexicon.BannedTerms() +// — the single source — so a count change breaks all three firewalls (x/*.go, +// docs, web) (G-014 drift prevention). +func TestLexiconMetaWebBannedTermsCount(t *testing.T) { + terms := lexicon.BannedTerms() + if len(terms) != 10 { + t.Errorf("BannedTerms() len = %d, want 10 (REQ-012/REQ-045)", len(terms)) + } + seen := map[string]bool{} + for _, tr := range terms { + if seen[tr] { + t.Errorf("duplicate banned term %q", tr) + } + seen[tr] = true + } +} + +// TestLexiconMetaWebNoFalsePositiveOnOpenYield asserts the module name +// "openyield" does NOT trigger the "yield" banned term and "european" does +// NOT trigger the "euro" banned term (word-boundary matching must not match +// substrings of identifiers). This is the regression firewall for the +// word-boundary detection design — mirrors the v0.2 +// TestLexiconMetaNoFalsePositiveOnOpenYield and the v0.3 +// TestLexiconMetaDocsNoFalsePositiveOnOpenYield. +func TestLexiconMetaWebNoFalsePositiveOnOpenYield(t *testing.T) { + cases := []string{ + "github.com/oy/openyield/x/window/types", + "package openyield", + "openyield is the module", + "european resident", + "# OpenYield web", + "the OpenYield mesh", + } + for _, s := range cases { + if _, ok := lexicon.FindBannedTerm(s); ok { + t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s) + } + } +} + +// TestLexiconMetaWebWalkCoverage (G-013) is the walk-coverage firewall. The +// G-009 self-test table (above) verifies DETECTION (FindBannedTerm on +// synthetic strings) but NOT the WALK (which files are scanned). A walk bug +// — e.g. wrong path prefix, missing web/ recursion, a typo in the .html +// suffix check — would silently scan nothing and report green on zero +// files. This test closes that gap by injecting a synthetic banned-term +// .html into a fixture dir under the real web/templates/ path the walk scans +// and asserting the walk FINDS it. +// +// The fixture is created under web/templates/.lexicon_fixture/ (a real +// web/templates/ subtree the walk reaches) and removed via defer so it never +// leaks into the repo. If the walk logic misses the fixture, this test fails +// loudly instead of letting a broken walk pass the firewall green on zero +// files scanned. +func TestLexiconMetaWebWalkCoverage(t *testing.T) { + root := repoRoot(t) + this := thisFile(t) + + // Build a synthetic banned term from fragments so THIS file does not + // contain a banned-term literal (it is excluded from its own scan, but + // the synthetic stays clean for readability/searchability). + terms := lexicon.BannedTerms() + if len(terms) == 0 { + t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage") + } + // Use the first banned term ("bank") assembled from two halves. + syntheticTerm := terms[0][:2] + terms[0][2:] // reassemble (no literal in source) + badContent := []byte("\nthis file contains a banned term: " + syntheticTerm + "\n") + + fixtureDir := filepath.Join(root, "web", "templates", ".lexicon_fixture") + fixtureFile := filepath.Join(fixtureDir, "bad_fixture.html") + if err := os.MkdirAll(fixtureDir, 0o755); err != nil { + t.Fatalf("mkdir fixture: %v", err) + } + defer os.RemoveAll(fixtureDir) + if err := os.WriteFile(fixtureFile, badContent, 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + // Run the SAME walk logic as TestLexiconMetaWebNoBannedTermsInWeb and + // assert it FINDS the fixture's banned term. A walk that returns zero + // hits here proves the walk logic is broken (the fixture is a known-bad + // file inside web/templates/ that MUST be detected). + hits := []string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + base := filepath.Base(path) + if base == ".ciagent" || base == ".git" { + return filepath.SkipDir + } + return nil + } + if path == this { + return nil + } + rel, rerr := filepath.Rel(root, path) + if rerr != nil { + return rerr + } + if !isWebTarget(rel) { + return nil + } + bz, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + hits = append(hits, rel+" contains banned term "+found) + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + // Assert the fixture was found. The rel path uses OS-specific separator; + // match on the suffix so the test is portable. + foundFixture := false + for _, h := range hits { + if strings.Contains(h, "bad_fixture.html") && strings.Contains(h, syntheticTerm) { + foundFixture = true + break + } + } + if !foundFixture { + t.Errorf("G-013 walk-coverage: the walk did NOT find the synthetic banned-term fixture at %s — the web firewall walk logic is broken (it would silently scan nothing and report green). hits=%v", fixtureFile, hits) + } +}