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
170 lines
6.8 KiB
Go
170 lines
6.8 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"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"
|
|
)
|
|
|
|
// blockProvider is a StackDataProvider whose StopStack blocks on a channel — so a Tier-2 restore
|
|
// parks mid-flight while the test asserts the HANDLER already returned (async) and that a concurrent
|
|
// POST is refused without launching a second restore.
|
|
type blockProvider struct {
|
|
hdd string
|
|
release chan struct{}
|
|
stops int32 // atomic: number of StopStack calls == number of restores that actually launched
|
|
}
|
|
|
|
func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "", false }
|
|
func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
|
func (p *blockProvider) GetStackHDDMounts(string) []string { return nil }
|
|
func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd }
|
|
func (p *blockProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
|
func (p *blockProvider) GetDockerVolumes(string) []string { return nil }
|
|
func (p *blockProvider) StopStack(string) error {
|
|
atomic.AddInt32(&p.stops, 1)
|
|
<-p.release // park here until the test releases it
|
|
return nil
|
|
}
|
|
func (p *blockProvider) StartStack(string) error { return nil }
|
|
func (p *blockProvider) RefreshAndIsRunning(string) bool { return true }
|
|
func (p *blockProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
|
|
return backup.RecoveryInfo{}, false
|
|
}
|
|
func (p *blockProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
|
|
return nil, false
|
|
}
|
|
func (p *blockProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
|
func (p *blockProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *blockProvider) StartStackServices(string, []string) error { return nil }
|
|
|
|
func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manager) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
live := filepath.Join(tmp, "usb")
|
|
dest := filepath.Join(tmp, "flash")
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{live, dest} {
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", DestinationPath: dest, LastRun: "2026-07-06T03:30:00Z", LastStatus: "ok",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// a recorded v2 Tier-2 copy (marker + an hdd/ leg) so RestoreTier2Files proceeds to StopStack.
|
|
destBase := filepath.Join(dest, "backups", "secondary", "app")
|
|
if err := os.MkdirAll(filepath.Join(destBase, "hdd", "appdata"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destBase, ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
prov := &blockProvider{hdd: live, release: make(chan struct{})}
|
|
m.SetStackProvider(prov)
|
|
s := &Server{cfg: cfg, backupMgr: m, logger: lg}
|
|
return s, prov, m
|
|
}
|
|
|
|
func postTier2(s *Server) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.backupTier2RestoreHandler(w, req)
|
|
return w
|
|
}
|
|
|
|
func waitFor(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
// Budget past waitForHealthy's 3s post-restore settling sleep (+ copier).
|
|
for i := 0; i < 700; i++ {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timeout waiting for: %s", msg)
|
|
}
|
|
|
|
// B1 — the restore handler returns INSTANTLY (302) while the restore runs in the background, and the
|
|
// op-status transitions running → terminal. RED-PROOF: on the pre-fix synchronous handler (calling
|
|
// RestoreTier2Files inline) this POST blocks in StopStack until the channel is released, so the
|
|
// sub-500ms response assertion FAILS.
|
|
func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) {
|
|
s, prov, m := newAsyncRestoreServer(t)
|
|
|
|
start := time.Now()
|
|
w := postTier2(s)
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Fatalf("handler blocked %v — restore is not async (the F4 shape)", elapsed)
|
|
}
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("want 302, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elindult") {
|
|
t.Fatalf("redirect should carry the 'elindult' flash; got %q -> %q", loc, flashSentence(t, loc))
|
|
}
|
|
// the background restore is now parked in StopStack → op-status shows running.
|
|
waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running")
|
|
st := m.RestoreStatus()
|
|
if st.Op != "tier2-restore" || st.Stack != "app" {
|
|
t.Fatalf("running status = %+v", st)
|
|
}
|
|
// release → the restore completes → terminal status.
|
|
close(prov.release)
|
|
waitFor(t, func() bool { return !m.RestoreStatus().Running && m.RestoreStatus().Last != nil }, "restore terminal")
|
|
if got := atomic.LoadInt32(&prov.stops); got != 1 {
|
|
t.Fatalf("StopStack called %d times, want exactly 1", got)
|
|
}
|
|
}
|
|
|
|
// B2 — a concurrent restore POST while one runs is REFUSED (fast-path IsRunning) and does NOT launch a
|
|
// second restore. RED-PROOF: removing the IsRunning() fast-path lets the second POST launch a second
|
|
// goroutine → StopStack count becomes 2.
|
|
func TestBackupTier2Restore_DoubleClickRefused(t *testing.T) {
|
|
s, prov, m := newAsyncRestoreServer(t)
|
|
|
|
// first restore → parks in StopStack (acquires the single-flight running flag).
|
|
_ = postTier2(s)
|
|
waitFor(t, func() bool { return m.IsRunning() }, "first restore holding the single-flight lock")
|
|
|
|
// second restore while the first runs → refused with the "már fut" flash, no second launch.
|
|
w2 := postTier2(s)
|
|
if loc := w2.Header().Get("Location"); !strings.Contains(loc, "m%C3%A1r+fut") && !strings.Contains(loc, "már fut") {
|
|
t.Fatalf("second POST should be refused with 'már fut'; got %q", loc)
|
|
}
|
|
// IsRunning flips before the goroutine reaches StopStack — wait for the FIRST stop to land, so
|
|
// the ==1 assertion below measures "no second launch", not goroutine scheduling (parallel-load flake).
|
|
waitFor(t, func() bool { return atomic.LoadInt32(&prov.stops) >= 1 }, "first restore reached StopStack")
|
|
if got := atomic.LoadInt32(&prov.stops); got != 1 {
|
|
t.Fatalf("double-click launched a second restore: StopStack count = %d, want 1", got)
|
|
}
|
|
|
|
close(prov.release)
|
|
waitFor(t, func() bool { return !m.IsRunning() }, "first restore done")
|
|
}
|