5270bad76e
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still Hungarian literals in Go, so an English household clicked an English button and was answered in Hungarian. 226 of them move into the bundle here. A flash was the hard part: it travels inside the redirect URL and is rendered by a DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by an older controller carries prose and is shown verbatim — never a raw key, never dropped. Also converted: page data and view-model text, the internal/api JSON answers, the alert banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at display, and the four page titles built around an app name (R-566 closed). Hungarian is byte-identical, and that is measured rather than read: scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict. Its own first version filtered the capture through an ASCII-Hungarian word list and missed seven real literals — the R-565 class. The filter is gone. Nothing on the wire moved, and wire goldens now hold it there: the report's health warnings and every notify event message stay Hungarian, because the hub MAILS the controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
196 lines
7.4 KiB
Go
196 lines
7.4 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Manager) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
// The bundle is loaded here (v0.252.0, R-557) because the page view-model text now comes from it.
|
|
// Every Hungarian assertion in the tests below is therefore a measurement of hu.json against the
|
|
// sentence the page used to carry -- which is the parity property, checked where it is read.
|
|
b, err := i18n.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg, i18n: b}, sett, m
|
|
}
|
|
|
|
// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable.
|
|
func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "pending",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.OffboxConfigured() {
|
|
t.Fatal("target should be configured")
|
|
}
|
|
// run while pending → refused with the escrow-wait flash, no run launched
|
|
w := httptest.NewRecorder()
|
|
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
|
|
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(flashSentence(t, loc), "letétbe") {
|
|
t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc)
|
|
}
|
|
if m.OffboxRunnable() {
|
|
t.Fatal("must not be runnable while pending")
|
|
}
|
|
// confirm-escrow → escrowed + runnable
|
|
w2 := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w2.Code != 302 {
|
|
t.Fatalf("confirm: got %d", w2.Code)
|
|
}
|
|
if got := sett.GetOffboxTarget().EscrowState; got != "escrowed" {
|
|
t.Fatalf("confirm must set EscrowState=escrowed, got %q", got)
|
|
}
|
|
if !m.OffboxRunnable() {
|
|
t.Fatal("must be runnable after confirm")
|
|
}
|
|
}
|
|
|
|
// Part C — the status endpoint (poll source) reports the current run status/snapshots as JSON.
|
|
func TestOffboxStatusHandler(t *testing.T) {
|
|
s, sett, _ := newOffboxWebServer(t)
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
|
LastStatus: "running", SnapshotCount: 7,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.offboxStatusHandler(w, httptest.NewRequest("GET", "/backup/offbox/status", nil))
|
|
var d map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &d); err != nil {
|
|
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
|
}
|
|
if d["status"] != "running" {
|
|
t.Fatalf("status = %v, want running", d["status"])
|
|
}
|
|
if d["snapshots"].(float64) != 7 {
|
|
t.Fatalf("snapshots = %v, want 7", d["snapshots"])
|
|
}
|
|
if d["orphaned"] != false {
|
|
t.Fatalf("orphaned = %v, want false", d["orphaned"])
|
|
}
|
|
}
|
|
|
|
// Part A edge — an ORPHANED repo routes "Távoli mentés most" to the card, never attempting the write.
|
|
func TestOffboxRun_RefusedWhenOrphaned(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "escrowed", RepoState: "orphaned",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
|
|
if w.Code != 302 {
|
|
t.Fatalf("orphaned run must redirect, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elárvult") {
|
|
t.Fatalf("orphaned run must redirect to the orphan-card flash, got %q", loc)
|
|
}
|
|
}
|
|
|
|
// Scenario E — the confirm flip wipes the agent-staged secret; a wipe failure is logged loudly but does
|
|
// NOT fail the confirm (the state flip is the primary effect).
|
|
func TestOffboxWeb_ConfirmWipesStagedSecret(t *testing.T) {
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
|
EscrowState: "pending",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wipes := 0
|
|
s.wipeStagedEscrowFn = func(context.Context) error { wipes++; return nil }
|
|
w := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
|
|
t.Fatalf("confirm failed: code=%d state=%q", w.Code, sett.GetOffboxTarget().EscrowState)
|
|
}
|
|
if wipes != 1 {
|
|
t.Fatalf("confirm must wipe the staged secret exactly once, got %d", wipes)
|
|
}
|
|
|
|
// wipe failure → confirm still succeeds (best-effort), loud ERROR logged
|
|
var logbuf bytes.Buffer
|
|
s.logger = log.New(&logbuf, "", 0)
|
|
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "pending" })
|
|
s.wipeStagedEscrowFn = func(context.Context) error { return errors.New("agent unreachable") }
|
|
w2 := httptest.NewRecorder()
|
|
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
|
|
if w2.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
|
|
t.Fatal("a wipe failure must NOT fail the confirm (state flip is primary)")
|
|
}
|
|
if !strings.Contains(logbuf.String(), "NOT wiped") {
|
|
t.Fatal("a failed wipe must log the loud NOT-wiped signal")
|
|
}
|
|
}
|
|
|
|
// The inject endpoint pre-places a recovered password (DR seam).
|
|
func TestOffboxWeb_InjectPassword(t *testing.T) {
|
|
s, _, _ := newOffboxWebServer(t)
|
|
pwPath := filepath.Join(s.cfg.Paths.DataDir, "offbox", "repo_password")
|
|
const pw = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
form := url.Values{"password": {pw}}
|
|
r := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.offboxInjectPasswordHandler(w, r)
|
|
if w.Code != 302 {
|
|
t.Fatalf("inject: got %d", w.Code)
|
|
}
|
|
got, err := os.ReadFile(pwPath)
|
|
if err != nil || string(got) != pw {
|
|
t.Fatalf("injected password not placed 0600 at offboxPwPath: err=%v", err)
|
|
}
|
|
// an invalid password is refused (error flash)
|
|
bad := url.Values{"password": {"nope"}}
|
|
rb := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(bad.Encode()))
|
|
rb.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
wb := httptest.NewRecorder()
|
|
s.offboxInjectPasswordHandler(wb, rb)
|
|
if loc := wb.Header().Get("Location"); !strings.Contains(loc, "flash_error") {
|
|
t.Fatalf("invalid password must produce an error flash, got %q", loc)
|
|
}
|
|
}
|