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
// `…` — `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_info", 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 `{{.InitialCreds.Password}}` 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")
}
}