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
173 lines
6.9 KiB
Go
173 lines
6.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// Update arc slice 4 through the PRODUCTION handler: actionStack → the real stacks.Manager
|
|
// (NewManager + ScanStacks) and the real backup.Manager over real settings. No docker is reached:
|
|
// every path here refuses, or fails at the pin (the app's catalog template is absent on purpose).
|
|
|
|
type apiFakeGuards struct {
|
|
b *backup.Manager
|
|
points []stacks.UpdateRestorePoint
|
|
cannotBackUp bool
|
|
// blindToHolds makes the manager-side preflight NOT see holds, so a test can prove the ROUTER's
|
|
// own hold check refuses — the two layers are each pinned separately (the preflight's by
|
|
// TestSlice4_D_CheapRefusals/held). Without it, removing either layer passes inertly, because the
|
|
// other refuses with the same sentence (observed on the first run of red-proof 4, 2026-09-13).
|
|
blindToHolds bool
|
|
}
|
|
|
|
func (g *apiFakeGuards) HoldFor(n string) (bool, string) {
|
|
if g.blindToHolds {
|
|
return false, ""
|
|
}
|
|
return g.b.RestoreHoldFor(n)
|
|
}
|
|
func (g *apiFakeGuards) Busy(string) (bool, string) { return false, "" }
|
|
func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
|
|
for _, p := range g.points {
|
|
if accept == nil || accept(p) {
|
|
return p, true, g.points
|
|
}
|
|
}
|
|
return stacks.UpdateRestorePoint{}, false, g.points
|
|
}
|
|
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
|
|
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
|
|
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint) error { return nil }
|
|
|
|
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
|
|
|
|
func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "stacks", "app")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx:1.27\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(slice4AppYAML), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(root, "data")
|
|
cfg.Paths.SystemDataPath = filepath.Join(root, "sys")
|
|
cfg.Stacks.ComposeCommand = "docker compose"
|
|
lg := log.New(io.Discard, "", 0)
|
|
m, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ScanStacks(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := backup.NewManager(cfg, sett, lg)
|
|
g := &apiFakeGuards{b: b, points: []stacks.UpdateRestorePoint{{Tier: stacks.UpdateTierSecondDrive, ProvenAt: time.Now().Add(-time.Hour)}}}
|
|
m.SetUpdateGuards(g)
|
|
return &Router{cfg: cfg, stackMgr: m, backupMgr: b, logger: lg}, sett, g, dir
|
|
}
|
|
|
|
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
|
|
var resp apiResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
|
}
|
|
return w.Code, resp
|
|
}
|
|
|
|
// TestR439_UpdateOfAHeldAppIsRefused — R-439 closed.
|
|
//
|
|
// COMPANION RED-PROOF 4 (REPORT.md): remove `|| action == "update"` from actionStack's hold check. The
|
|
// preflight then refuses on its own grounds with a DIFFERENT sentence, and this test fails on the
|
|
// message — which is what proves the router line is the one doing it.
|
|
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
|
|
r, sett, g, dir := newSlice4Router(t)
|
|
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
|
|
g.blindToHolds = true // only the router's line can produce the hold's sentence
|
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
code, resp := postUpdate(t, r)
|
|
_, holdText := r.backupMgr.RestoreHoldFor("app")
|
|
if code != http.StatusConflict || resp.OK || resp.Error != holdText {
|
|
t.Fatalf("a HELD app's update must be refused with the hold's own sentence: code=%d ok=%v error=%q", code, resp.OK, resp.Error)
|
|
}
|
|
after, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
if string(before) != string(after) {
|
|
t.Error("a refused update must record no intent — app.yaml changed")
|
|
}
|
|
}
|
|
|
|
func TestSlice4_Router_NoBackupIs409AndRecordsNothing(t *testing.T) {
|
|
r, _, g, dir := newSlice4Router(t)
|
|
g.points, g.cannotBackUp = nil, true
|
|
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
code, resp := postUpdate(t, r)
|
|
if code != http.StatusConflict || resp.Error != fmt.Sprintf(stacks.MsgUpdateNoBackupFmt, "app") {
|
|
t.Fatalf("code=%d error=%q", code, resp.Error)
|
|
}
|
|
if after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")); string(before) != string(after) {
|
|
t.Error("the preflight refusal must come BEFORE the intent write")
|
|
}
|
|
}
|
|
|
|
// TestR443_UpdateIsNeverReportedCompleteSynchronously — R-443 closed. The handler answers 202 with
|
|
// completed:false; the job then runs (and here fails at the pin, the catalog being absent), and the
|
|
// outcome exists ONLY on GET /api/stacks/{name}.
|
|
func TestR443_UpdateIsNeverReportedCompleteSynchronously(t *testing.T) {
|
|
r, _, _, _ := newSlice4Router(t)
|
|
code, resp := postUpdate(t, r)
|
|
if code != http.StatusAccepted {
|
|
t.Fatalf("an accepted update must answer 202, got %d (%+v)", code, resp)
|
|
}
|
|
data, _ := resp.Data.(map[string]interface{})
|
|
if data["completed"] != false || data["accepted"] != true {
|
|
t.Errorf("the body must say accepted and NOT completed, got %v", resp.Data)
|
|
}
|
|
if resp.Message == "Stack app update completed" {
|
|
t.Error("the synchronous response claimed completion — R-443")
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if st, ok := r.stackMgr.GetStack("app"); ok && !st.Updating {
|
|
if st.UpdatePhase != stacks.UpdatePhaseFailed || st.UpdateError != stacks.MsgUpdatePinFailed {
|
|
t.Errorf("the job's truth must be on the stack: phase=%q err=%q", st.UpdatePhase, st.UpdateError)
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("the job never finished")
|
|
}
|