e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
203 lines
9.2 KiB
Go
203 lines
9.2 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// R-254 site one — AN APP'S GENERATED FIRST-LOGIN PASSWORD MUST NOT BE IN THE RESPONSE BODY.
|
|
//
|
|
// The third instance of one pattern in two days (R-249 was the retrieval passphrase; this is a real
|
|
// per-install app credential, read live out of the running container). The markup rendered it into
|
|
// `<span id="initcred-pw-val" hidden>…</span>` — `hidden` stops a browser DRAWING the value and
|
|
// nothing else, so a `curl` of an app's info page returned it in plaintext.
|
|
//
|
|
// These assert the RAW BODY. A test that asks what the customer *sees* passes on this defect, which
|
|
// is exactly how it survived three times.
|
|
|
|
const testAppPassword = "TESTONLY-app-initial-pw-7Kq2mZ"
|
|
|
|
// renderAppInfo drives the REAL template with the page data shape the handler produces, and returns
|
|
// the bytes a browser would receive.
|
|
func renderAppInfo(t *testing.T, creds *stacks.ExtractedCreds, hasPassword bool) string {
|
|
t.Helper()
|
|
s := securityHarness(t)
|
|
s.loadTemplates()
|
|
data := map[string]interface{}{
|
|
"Page": "stacks", "Title": "Teszt app", "Domain": "example.hu",
|
|
"Stack": stacks.Stack{Name: "crafty", Deployed: true, State: "running"},
|
|
"Meta": stacks.Metadata{DisplayName: "Crafty", Slug: "crafty", Category: "media"},
|
|
"AppInfo": stacks.AppInfo{Tagline: "teszt"},
|
|
// The credentials card lives inside {{if .HasAppInfo}} — without this the card never renders
|
|
// and every assertion below would pass for the wrong reason.
|
|
"HasAppInfo": true,
|
|
}
|
|
if creds != nil {
|
|
data["InitialCreds"] = creds
|
|
data["InitialCredsHasPassword"] = hasPassword
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, "app_settings", data); err != nil {
|
|
t.Fatalf("render app_info: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// ── SCENARIO A — the app password is not in the page ────────────────────────────────────────────
|
|
|
|
// RED-PROOF: put `<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>` back into
|
|
// app_info.html AND restore `data["InitialCreds"] = creds` in the handler — this fails on the first
|
|
// assertion, showing the plaintext returning to the body. That is the defect, reproduced.
|
|
//
|
|
// NOTE the second assertion, and a CORRECTION to what v0.207.0's report claimed. It said HTML
|
|
// comments ship in the response body. **They do not, here:** this package renders with
|
|
// `html/template` (server.go), which STRIPS comments — measured: text/template keeps them,
|
|
// html/template does not. So a comment cannot leak a secret, and a red-proof planting one in a
|
|
// comment correctly does NOT fail. The assertion is kept because it catches the real regression —
|
|
// the hidden element itself coming back into the markup.
|
|
func TestAppInfoPage_DoesNotContainTheInitialPassword(t *testing.T) {
|
|
html := renderAppInfo(t, &stacks.ExtractedCreds{
|
|
Available: true, Username: "admin", Password: testAppPassword,
|
|
}, true)
|
|
|
|
if strings.Contains(html, testAppPassword) {
|
|
t.Error("R-254: the app's first-login password is in the response body of its info page — " +
|
|
"a fetch of this page returns a real per-install credential, and the reveal button only " +
|
|
"stops a browser DRAWING it")
|
|
}
|
|
if strings.Contains(html, "initcred-pw-val") {
|
|
t.Error("the old hidden-value element is back in the markup")
|
|
}
|
|
// The feature must survive: the fix removes the VALUE, not the customer's access (Scenario B).
|
|
if !strings.Contains(html, "Kezdeti belépési adatok") {
|
|
t.Error("the initial-credentials card vanished — the fix must not take the password away " +
|
|
"from the person whose app it is")
|
|
}
|
|
if !strings.Contains(html, "initial-credentials/reveal") {
|
|
t.Error("no reveal call rendered, so the customer has no way to obtain the password at all")
|
|
}
|
|
// The username is NOT a secret and must still render — otherwise the card is useless.
|
|
if !strings.Contains(html, "admin") {
|
|
t.Error("the username stopped rendering; only the password was supposed to leave the page")
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO E — an app with no generated credentials is unchanged ──────────────────────────────
|
|
|
|
// RED-PROOF: make the card unconditional (drop `{{if .InitialCreds}}`) and this fails — an app that
|
|
// has no generated credential grows a reveal control for a password that does not exist.
|
|
func TestAppInfoPage_NoCredentialsCard_WhenAppHasNone(t *testing.T) {
|
|
html := renderAppInfo(t, nil, false)
|
|
|
|
if strings.Contains(html, "Kezdeti belépési adatok") {
|
|
t.Error("the initial-credentials card rendered for an app with no generated credentials")
|
|
}
|
|
if strings.Contains(html, "initial-credentials/reveal") {
|
|
t.Error("a reveal control rendered for an app that has no password to reveal")
|
|
}
|
|
}
|
|
|
|
// ── SCENARIO B — the customer can still get it, and C — the act is recorded ─────────────────────
|
|
|
|
// credsHarness builds a Server with a REAL stack manager carrying one deployed app whose slug is
|
|
// "crafty", plus a capturing logger. Only the container READ is seamed — slug resolution is the
|
|
// production path, because a reveal answering for the wrong app is the failure mode that matters.
|
|
func credsHarness(t *testing.T) (*Server, *bytes.Buffer) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
buf := &bytes.Buffer{}
|
|
lg := log.New(io.MultiWriter(buf), "", 0)
|
|
cfg := config.Default()
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
cfg.Web.SessionSecret = "test-session-secret-abcdef"
|
|
|
|
sd := filepath.Join(cfg.Paths.StacksDir, "crafty")
|
|
if err := os.MkdirAll(sd, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.WriteFile(filepath.Join(sd, ".felhom.yml"), []byte("display_name: Crafty\nslug: crafty\n"), 0o644)
|
|
os.WriteFile(filepath.Join(sd, "docker-compose.yml"), []byte("services: {}\n"), 0o644)
|
|
os.WriteFile(filepath.Join(sd, "app.yaml"), []byte("deployed: true\n"), 0o644)
|
|
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mgr, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Discovery is a separate step — the constructor only prepares the directory.
|
|
_ = mgr.ScanStacks() // container-status refresh fails on a docker-less host; discovery is enough
|
|
if _, ok := mgr.GetStack("crafty"); !ok {
|
|
t.Fatal("crafty not discovered by ScanStacks — the fixture would prove nothing")
|
|
}
|
|
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}, buf
|
|
}
|
|
|
|
// RED-PROOF for B: delete the route case from server.go (or make the handler always 404) and the
|
|
// customer is shown unable to reach their own app password.
|
|
// RED-PROOF for C: delete the s.logger.Printf line and the "recorded" assertion fails.
|
|
func TestAppInitialCredsReveal_ReturnsThePasswordAndRecordsTheAct(t *testing.T) {
|
|
s, logBuf := credsHarness(t)
|
|
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
|
|
return &stacks.ExtractedCreds{Available: true, Username: "admin", Password: testAppPassword}, nil
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own app password", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), testAppPassword) {
|
|
t.Error("the reveal did not return the password — Scenario A's fix must not protect the " +
|
|
"secret by taking it from its owner")
|
|
}
|
|
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
|
t.Errorf("Cache-Control = %q, want no-store — a cached reveal is the same defect one layer down", got)
|
|
}
|
|
// SCENARIO C — the act is recorded, and the VALUE is not.
|
|
logged := logBuf.String()
|
|
if !strings.Contains(logged, "initial-credential password revealed") {
|
|
t.Error("the reveal was not recorded — a silent read is what the markup allowed, and why " +
|
|
"nobody can say whether any of these was ever read")
|
|
}
|
|
if strings.Contains(logged, testAppPassword) {
|
|
t.Error("the password was written to the log")
|
|
}
|
|
}
|
|
|
|
// §7.1 — a reveal that cannot read the value SAYS SO. An empty string would render as a blank
|
|
// password and read to the customer as "your password is empty".
|
|
func TestAppInitialCredsReveal_SaysWhyWhenUnreadable(t *testing.T) {
|
|
s, _ := credsHarness(t)
|
|
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
|
|
return &stacks.ExtractedCreds{Available: false}, nil // container stopped / file gone
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
|
|
|
|
if rr.Code != 404 {
|
|
t.Errorf("unreadable reveal returned %d, want 404", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "futnia kell") {
|
|
t.Errorf("the refusal does not say WHY it could not be read: %s", body)
|
|
}
|
|
if strings.Contains(body, `"password"`) {
|
|
t.Error("an unreadable reveal still carried a password field")
|
|
}
|
|
}
|