package web
import (
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-255 (operator ruling 2026-10-12, S9, `09` §3 decision 204): ONE test renders EVERY dashboard page with
// planted secret values in the data it renders from, and fails if any planted value reaches the HTML.
//
// Why a render and not only scripts/secret_in_markup_gate.py: the gate reads TEMPLATES and is blind to a
// secret that arrives under a neutral page-data key (R-254 site two was exactly that shape). A rendered body
// cannot hide it.
//
// What is planted, per page and per parity case (i18nCases — the same data the parity fixtures render):
//
// 1. every STRING in the case's data whose field or map-key NAME says secret (secretNameRe: password,
// passphrase, secret, token, API key, private key, recovery code, credential, an env var ending _KEY)
// — at any depth: maps, slices, structs, pointers;
// 2. the secret-carrying keys handlers really set (RetrievalPassword, InitialCreds.Password — R-249/R-254)
// and the names a future handler would reach for (RecoveryCode, Passphrase, SharePassword, …).
//
// Each planted value is unique, so a hit names the field it came from. The ONE deliberate exception is the
// pre-deploy form of an app not yet installed: its generated secrets are SHOWN so the household can note them
// down, and carried in a hidden input so the saved value is the one they saw (README §318; R-254 site two
// keeps both, TestDeployPage_PreDeployForm_StillCarriesTheValue_Deliberately pins it). It is allowed only
// there, only in those two exact markups.
//
// EVERY PAGE: a page template is a file whose `{{define}}` carries its own base name (launcher.html defines
// "launcher"); the test derives that list from the embedded templates and fails if any page has no case —
// a new page cannot be added without joining this sweep.
//
// RED-PROOF (REPORT.md): plant `{{.RetrievalPassword}}` into settings_security.html →
// this fails naming the page and the planted field.
var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`)
// notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry
// (session-bound, useless without the session) and a password-change ERROR MESSAGE.
var notSecretNames = map[string]bool{
"CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true,
}
const plantedPrefix = "PLANTEDSECRET"
type planter struct {
n int
where map[string]string // sentinel -> field path
}
func (p *planter) next(path string) string {
p.n++
v := fmt.Sprintf("%s%03dX", plantedPrefix, p.n)
p.where[v] = path
return v
}
func isSecretName(name string) bool {
return !notSecretNames[name] && secretNameRe.MatchString(name)
}
// plant returns a copy of v in which every string under a secret-named field or key is a sentinel.
func (p *planter) plant(v reflect.Value, path string, secretCtx bool) reflect.Value {
if !v.IsValid() {
return v
}
switch v.Kind() {
case reflect.String:
if secretCtx {
out := reflect.New(v.Type()).Elem()
out.SetString(p.next(path))
return out
}
return v
case reflect.Interface:
if v.IsNil() {
return v
}
inner := p.plant(v.Elem(), path, secretCtx)
out := reflect.New(v.Type()).Elem()
out.Set(inner)
return out
case reflect.Ptr:
if v.IsNil() {
return v
}
inner := p.plant(v.Elem(), path, secretCtx)
out := reflect.New(v.Type().Elem())
out.Elem().Set(inner)
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
for _, k := range v.MapKeys() {
name := fmt.Sprint(k.Interface())
out.SetMapIndex(k, p.plant(v.MapIndex(k), path+"."+name, isSecretName(name)))
}
return out
case reflect.Slice:
if v.IsNil() {
return v
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(p.plant(v.Index(i), fmt.Sprintf("%s[%d]", path, i), secretCtx))
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
f := v.Type().Field(i)
if f.PkgPath != "" { // unexported: cannot be set, and a template cannot read it either
continue
}
out.Field(i).Set(p.plant(v.Field(i), path+"."+f.Name, isSecretName(f.Name)))
}
return out
}
return v
}
// pageTemplates lists every template file that defines a template carrying its own base name.
func pageTemplates(t *testing.T) []string {
t.Helper()
names, err := fs.Glob(templateFS, "templates/*.html")
if err != nil {
t.Fatal(err)
}
var pages []string
for _, n := range names {
raw, err := templateFS.ReadFile(n)
if err != nil {
t.Fatal(err)
}
base := strings.TrimSuffix(path.Base(n), ".html")
src := string(raw)
// A PAGE is a whole document: its own shell, or the dashboard chrome. A partial that
// happens to carry its file's name (lang_globe, meta_badge) is rendered inside pages, not alone.
whole := strings.Contains(src, "`)
shown := regexp.MustCompile(``)
return strings.Count(html, sentinel) == len(hidden.FindAllString(html, -1))+len(shown.FindAllString(html, -1))
}
// R-255's finding, fixed in the same session: an optional-config `secret_input` (a key the household typed in
// for its app) was rendered into `value="…"` of its settings page. It is now revealed on demand. The reveal
// endpoint serves it to the household's session and still refuses a field that is not a secret_input.
//
// COMPANION RED-PROOF (REPORT.md): drop the secret_input branch in appAutoFieldRevealHandler → the first case
// 403s, and the household can no longer see the key it saved.
func TestR255_OptionalSecretIsRevealedOnDemand(t *testing.T) {
s := gateHarness(t)
app := filepath.Join(s.cfg.Paths.StacksDir, "gapp")
meta := "display_name: G\nslug: gapp\noptional_config:\n - group: Kulso\n fields:\n - env_var: META_API_KEY\n type: secret_input\n - env_var: META_REGION\n type: text\n"
if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(meta), 0o644); err != nil {
t.Fatal(err)
}
const key = "TESTONLY-optional-api-key-Q7"
if err := stacks.SaveAppConfig(app, &stacks.AppConfig{Deployed: true, Env: map[string]string{"META_API_KEY": key, "META_REGION": "eu"}}, nil, nil); err != nil {
t.Fatal(err)
}
if err := s.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
ask := func(field string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/stacks/gapp/auto-field/reveal", strings.NewReader("env_var="+field))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.appAutoFieldRevealHandler(w, r, "gapp")
return w
}
if w := ask("META_API_KEY"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), key) {
t.Fatalf("the saved optional key is not revealable: %d", w.Code)
}
if w := ask("META_REGION"); w.Code != http.StatusForbidden {
t.Fatalf("a non-secret optional field went through the secret reveal: %d", w.Code)
}
if w := ask("NOPE"); w.Code != http.StatusForbidden {
t.Fatalf("an unknown field: %d", w.Code)
}
}