R-294/R-295: orphan card stops promising restorability; one name per secret
gates / gates (push) Successful in 14s

The orphan card told a customer their set-aside off-site history may be
restorable later with their recovery code. The discriminator lives on the hub
and no wire field carries it, so the box rendering that card cannot evaluate the
promise. Copy replaced per the spec: state what happens, decline what we cannot
know and say why, name a route.

The claim page called the same three-word dashboard code two different names
depending on branch, one of which collides with the ten-word escrow code.
Retired 'Visszaallito kod'; the name is now constant and the sentence changes.
Naming only - a test pins that a reset code is still accepted.

secret_in_markup_gate no longer convicts Go template comments, which are
stripped before render; still convicts a real rendered secret.
This commit is contained in:
2026-08-10 13:53:21 +02:00
parent b762a37097
commit 86a78c6767
7 changed files with 276 additions and 7 deletions
@@ -0,0 +1,107 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// ── R-295 — ONE NAME PER SECRET ─────────────────────────────────────────────────────────────────
//
// Two different secrets were both called „Visszaállító kód":
//
// - the THREE-word code that gives a person control of the dashboard (the claim/reset code), and
// - the TEN-word code that opens the sealed off-site backups (the escrow recovery code).
//
// The names are near-homographs of each other and of „Helyreállítási kód", and the collision cost a
// real code. The ruling: the dashboard code is „Beállító kód" everywhere — the name the box already
// showed on the page where it is typed — the escrow code is „Helyreállítási kód", and „Visszaállító
// kód" is retired. Where one secret serves two situations the NAME stays constant and the SENTENCE
// changes.
//
// THIS IS NAMING, NOT FUNCTION. TestResetCode_StillAcceptedOnTheSetupPage below is the pin that says
// so: the code kept working throughout, and a rename that quietly broke acceptance would be a far
// worse outcome than the collision it fixed.
// claimPageHTML renders the claim page in one of its two branches through the PRODUCTION template
// tree. IsReset is the only field varied — in production it is `s.authEnabled()` (a set password
// means this is the reset flow), and it is the branch that used to rename the secret.
func claimPageHTML(t *testing.T, isReset bool) string {
t.Helper()
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
data := map[string]interface{}{
"Title": "A szerver beállítása", "CustomerName": "Teszt Ügyfél", "Domain": "pelda.hu",
"ClaimCSRF": "t", "IsReset": isReset, "HasCode": true, "MinPassword": 12,
}
if err := s.tmpl.ExecuteTemplate(&buf, "claim", data); err != nil {
t.Fatalf("render claim (IsReset=%v): %v", isReset, err)
}
return buf.String()
}
// RED-PROOF: restore `{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}` on the label in
// claim.html and the reset branch fails here, with the retired name quoted back.
func TestClaimPage_BothBranchesNameTheSameSecretTheSameWay(t *testing.T) {
for _, isReset := range []bool{false, true} {
branch := "first-time"
if isReset {
branch = "reset"
}
html := claimPageHTML(t, isReset)
if strings.Contains(html, "isszaállító kód") {
t.Errorf("[%s branch] the retired name „Visszaállító kód" +
"\" is still on the page — it collides with the escrow „Helyreállítási kód", branch)
}
if !strings.Contains(html, "eállító kód") {
t.Errorf("[%s branch] the page no longer names the secret „Beállító kód" +
"\" at all", branch)
}
}
}
// The escrow code's name must NOT appear on the dashboard-claim page — that confusion is the whole
// finding. (Substring is ASCII-safe on purpose: an accented pattern that fails to match reads exactly
// like the string being absent.)
func TestClaimPage_DoesNotMentionTheEscrowCodeName(t *testing.T) {
for _, isReset := range []bool{false, true} {
if html := claimPageHTML(t, isReset); strings.Contains(html, "elyreállítási kód") {
t.Error("the claim page names the ESCROW code — the two secrets are different, and " +
"naming one on the other's page is how a customer types the wrong one")
}
}
}
// ── The pin the ruling explicitly asks for: acceptance did not move ─────────────────────────────
// A reset-issued code is still accepted on the setup page and still sets the password. The rename
// touched copy only; if this ever fails, a naming change has broken a recovery path.
func TestResetCode_StillAcceptedOnTheSetupPage(t *testing.T) {
s, code, sett := claimTestServer(t)
form := url.Values{
"_csrf": {s.claimCSRFToken()}, "code": {code},
"new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"},
}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("a valid code was REFUSED after the rename: got %d, body=%q — the ruling was that "+
"this is naming, not function", rr.Code, claimFirstLine(rr.Body.String()))
}
if !sett.GetClaimed() {
t.Error("the box was not marked claimed — acceptance logic moved with the copy")
}
if !s.authEnabled() {
t.Error("the password was not set — acceptance logic moved with the copy")
}
}