package stacks import ( "io" "log" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) func allowlistMgr(t *testing.T) *Manager { t.Helper() m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), sysDataPath: "/mnt/sys_drive", stacks: map[string]*Stack{}} return m } const refusedCompose = "services:\n web:\n image: x/y:1\n privileged: true\n" // One operator event per DISTINCT refused definition — the same definition on the next 15-minute cycle raises none; // a different one raises again; an admitted definition clears the page line. RED-PROOF: with the sha comparison // removed, the second identical refusal raises a second event and this test fails. func TestAllowList_OneEventPerRefusedDefinition(t *testing.T) { m := allowlistMgr(t) var events []string m.SetDefinitionRefusedHook(func(name, detail string) { events = append(events, name+"|"+detail) }) env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data"} for i := 0; i < 3; i++ { if err := m.admitCompose("web", []byte(refusedCompose), env); err == nil { t.Fatal("privileged was admitted") } } if len(events) != 1 { t.Fatalf("events = %d, want 1 for one definition seen three times: %v", len(events), events) } if !strings.Contains(events[0], "privileged true") { t.Fatalf("the event does not name the refused setting: %v", events) } if m.definitionRefused("web") == "" { t.Fatal("the app page line is empty while a refusal stands") } if err := m.admitCompose("web", []byte(strings.Replace(refusedCompose, "privileged: true", "cap_add: [NET_ADMIN]", 1)), env); err == nil { t.Fatal("cap_add was admitted") } if len(events) != 2 { t.Fatalf("a different refused definition raised %d events in all, want 2", len(events)) } if err := m.admitCompose("web", []byte("services:\n web:\n image: x/y:2\n"), env); err != nil { t.Fatalf("a clean definition was refused: %v", err) } if m.definitionRefused("web") != "" { t.Fatal("an admitted definition did not clear the page line") } } // A refusal before the hook is wired (a sync at start-up) still raises the event at the first wired sight. func TestAllowList_UnwiredFirstSightIsNotSwallowed(t *testing.T) { m := allowlistMgr(t) _ = m.admitCompose("web", []byte(refusedCompose), nil) n := 0 m.SetDefinitionRefusedHook(func(string, string) { n++ }) _ = m.admitCompose("web", []byte(refusedCompose), nil) if n != 1 { t.Fatalf("events after wiring = %d, want 1", n) } } // The refusal text (logged, mailed to the operator) never carries an env VALUE: a secret in a privileged key or a // non-root bind variable is shown as the template text. func TestAllowList_RefusalNeverPrintsASecret(t *testing.T) { m := allowlistMgr(t) const secret = "S3cr3t-value-never-shown" env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "DB_PASSWORD": secret} compose := "services:\n web:\n image: x/y:1\n sysctls:\n net.x: ${DB_PASSWORD}\n volumes:\n - ${DB_PASSWORD}:/x\n env_file: /etc/${DB_PASSWORD}\n" err := m.admitCompose("web", []byte(compose), env) if err == nil { t.Fatal("admitted") } if strings.Contains(err.Error(), secret) || strings.Contains(m.definitionRefused("web"), secret) { t.Fatalf("the refusal printed a secret value: %v", err) } } // The env the check sees is the env compose sees: the controller-derived USERDATA_PATH wins over an app value. func TestAllowList_PolicyEnvDerivesTheRootsLikeComposeExec(t *testing.T) { m := allowlistMgr(t) e := m.policyEnv(map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "USERDATA_PATH": "/"}) if e["USERDATA_PATH"] == "/" || !strings.HasPrefix(e["USERDATA_PATH"], "/mnt/felhom-drives/a/") { t.Fatalf("USERDATA_PATH = %q — the app's own value must not become a data root", e["USERDATA_PATH"]) } } // The install / settings / restore refusal is a bundle key: the reader gets it in their language, with the app and // the refused setting named, and the sentence says nothing changed. func TestDefinitionRefused_MessagesFollowTheReader(t *testing.T) { err := util.MsgError("err.stacks.definition_refused", "homepage", "homepage: privileged true") msg, ok := util.AsMsg(err) if !ok { t.Fatal("not a message error") } for lang, want := range map[string]string{"hu": "Semmi nem valtozott", "en": "Nothing was changed"} { got := msg.Text(lang) ascii := strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(got) if !strings.Contains(ascii, want) || !strings.Contains(got, "homepage: privileged true") { t.Errorf("[%s] %q", lang, got) } } if msg.Text("hu") == msg.Text("en") { t.Fatal("the two languages render the same text — a key is missing from one bundle") } }