Files
felhom-controller/controller/internal/web/poweroff_handlers_test.go
T
admin 201f936e9a
gates / gates (push) Successful in 48s
v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
2026-10-13 20:17:30 +02:00

145 lines
6.6 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-998 — „A gép kikapcsolása". The consequence is asserted: was the agent asked to switch the machine off, or not?
type fakePowerAgent struct {
support agentapi.SupportState
err error
calls int
}
func (f *fakePowerAgent) HostPowerOff(context.Context) error { f.calls++; return f.err }
func (f *fakePowerAgent) Supports(context.Context, agentapi.Feature) agentapi.SupportState {
return f.support
}
func powerServer(t *testing.T, a *fakePowerAgent) *Server {
t.Helper()
s := testPageServer(t)
s.powerOffAgentFn = func() (powerOffAgent, error) { return a, nil }
s.powerOffBusyFn = func() (bool, string) { return false, "" }
return s
}
func postPowerOff(s *Server, method, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/server/poweroff", strings.NewReader(body))
w := httptest.NewRecorder()
s.HandleServerPowerOff(w, r)
return w
}
// Without the typed word nothing is switched off; with it (either language, either spelling) the agent is asked once.
func TestPowerOff_ConfirmationWordRequired(t *testing.T) {
for _, body := range []string{``, `{}`, `{"confirm":""}`, `{"confirm":"igen"}`, `{"confirm":"KIKAPCS"}`} {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodPost, body)
if w.Code != http.StatusBadRequest || a.calls != 0 {
t.Fatalf("body %q: got %d, agent asked %d times — want 400 and no call", body, w.Code, a.calls)
}
if !strings.Contains(w.Body.String(), "KIKAPCSOLÁS") {
t.Fatalf("the refusal does not name the word: %s", w.Body.String())
}
}
for _, word := range []string{"KIKAPCSOLÁS", "kikapcsolás", " KIKAPCSOLAS ", "TURN OFF", "turn off"} {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"`+word+`"}`)
if w.Code != http.StatusAccepted || a.calls != 1 {
t.Fatalf("word %q: got %d (%s), calls %d — want 202 and one call", word, w.Code, w.Body.String(), a.calls)
}
}
}
// POST only: a GET (a link, an image tag) must never switch the machine off.
func TestPowerOff_PostOnly(t *testing.T) {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodGet, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusMethodNotAllowed || a.calls != 0 {
t.Fatalf("GET: %d, calls %d", w.Code, a.calls)
}
}
// The agent's refusal reaches the household as a sentence; the controller's own busy work refuses before the agent.
func TestPowerOff_RefusalsShown(t *testing.T) {
a := &fakePowerAgent{support: agentapi.SupportYes, err: &agentapi.PowerOffRefused{Reason: agentapi.PowerOffBackup}}
w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "Most mentés vagy visszaállítás fut") ||
!strings.Contains(w.Body.String(), `"code":"backup_running"`) {
t.Fatalf("agent refusal: %d %s", w.Code, w.Body.String())
}
a2 := &fakePowerAgent{support: agentapi.SupportYes}
s := powerServer(t, a2)
s.powerOffBusyFn = func() (bool, string) { return true, "a backup or restore is running" }
if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusConflict || a2.calls != 0 {
t.Fatalf("controller busy: %d, agent calls %d — want 409 and no call", w.Code, a2.calls)
}
}
// An agent older than the route: the card is HIDDEN with a one-line note, and the route answers agent_outdated
// without asking the agent. An agent that 404s anyway (version unknown) gets the same answer.
func TestPowerOff_HiddenForOldAgent(t *testing.T) {
old := &fakePowerAgent{support: agentapi.SupportNo}
s := powerServer(t, old)
page := getPage(t, s, "/settings").Body.String()
if strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "data-poweroff-unavailable") {
t.Fatal("an old agent: the power-off button must be hidden, with the note")
}
if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed || old.calls != 0 {
t.Fatalf("old agent: %d, calls %d", w.Code, old.calls)
}
cur := &fakePowerAgent{support: agentapi.SupportYes}
page = getPage(t, powerServer(t, cur), "/settings").Body.String()
if !strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "A gép kikapcsolása") ||
!strings.Contains(page, "nyomd meg a gép bekapcsológombját") {
t.Fatal("a current agent: the card, its title and the power-button sentence must be on the page")
}
unknown := &fakePowerAgent{support: agentapi.SupportUnknown, err: &agentapi.StatusError{Method: "POST", Path: "/host/poweroff", Code: 404}}
if w := postPowerOff(powerServer(t, unknown), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed {
t.Fatalf("404 from an unknown-version agent: %d, want 412", w.Code)
}
}
// R-983 (one of seven): the restart route must not act on a GET.
func TestServerReboot_PostOnly(t *testing.T) {
s := testServer(t)
w := httptest.NewRecorder()
s.HandleServerReboot(w, httptest.NewRequest(http.MethodGet, "/api/server/reboot", nil))
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /api/server/reboot = %d, want 405", w.Code)
}
}
// Every agent refusal reason maps to a household sentence that exists in BOTH languages (an unknown reason → the
// generic one), and an unreachable agent answers with its own sentence and asks nobody to switch anything off.
func TestPowerOff_EveryReasonHasASentence(t *testing.T) {
s := testPageServer(t)
b := s.bundle()
for _, reason := range []string{agentapi.PowerOffTooSoon, agentapi.PowerOffAlreadyAsked, agentapi.PowerOffBackup,
agentapi.PowerOffRestoreTest, agentapi.PowerOffOSUpdate, agentapi.PowerOffCtrlUpdate, agentapi.PowerOffDrive,
agentapi.PowerOffEscrow, agentapi.PowerOffBusy, agentapi.PowerOffOtherGuests, agentapi.PowerOffUnknown, "a_reason_from_the_future"} {
key := powerOffRefusalKey(reason)
for _, lang := range []string{"hu", "en"} {
if got := b.Msg(lang, key); got == "" || got == key {
t.Errorf("reason %q → key %q has no %s text", reason, key, lang)
}
}
}
down := testPageServer(t)
down.powerOffAgentFn = func() (powerOffAgent, error) { return nil, errors.New("agent not configured") }
down.powerOffBusyFn = func() (bool, string) { return false, "" }
w := postPowerOff(down, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "nem éri el a gép kezelőjét") {
t.Fatalf("agent down: %d %s", w.Code, w.Body.String())
}
}