package stacks import ( "fmt" "os" "path/filepath" "regexp" "strings" "testing" "time" ) // v0.287.0 (`09` §3 decisions 63/64) — the family gate's traefik side. const familyYml = "display_name: Family App\nfamily_gate: true\n" + "family_gate_except: [\"/api/v1/opds\", \"/api/kobo/\"]\n" + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" // Rule 5 (finding F1): every exception is anchored at a path-segment boundary — `/api/v1/opds` must not match // `/api/v1/opdsx` or `/api/v1/opds-evil`; a regex or matcher in the template is refused, never escaped into meaning. // COMPANION RED-PROOF: return "^"+QuoteMeta(p) (no boundary) → the look-alikes match and this fails. func TestFamilyExceptRegexp_Anchored(t *testing.T) { re, err := FamilyExceptRegexp("/api/v1/opds") if err != nil { t.Fatal(err) } rx := regexp.MustCompile(re) for p, want := range map[string]bool{ "/api/v1/opds": true, "/api/v1/opds/": true, "/api/v1/opds/catalog": true, "/api/v1/opdsx": false, "/api/v1/opds-evil": false, "/api/v1/opds.json": false, "/x/api/v1/opds": false, "/api/v1/opd": false, } { if rx.MatchString(p) != want { t.Errorf("%q: match=%v want %v (regexp %s)", p, !want, want, re) } } if re2, _ := FamilyExceptRegexp("/api/kobo/"); re2 != re2 || !regexp.MustCompile(re2).MatchString("/api/kobo/tok/v1/x") || regexp.MustCompile(re2).MatchString("/api/koboz") { t.Errorf("a trailing slash is the same prefix: %s", re2) } for _, bad := range []string{"", "/", "api", "/api/(.*)", "/api/v1/opds|/", "PathPrefix(`/x`)", "/a//b", "/a/../b", "/a/..", "/a b"} { if _, err := FamilyExceptRegexp(bad); err == nil { t.Errorf("%q must be refused", bad) } } } // The install writes the family door BEFORE the first start; exceptions get routers WITHOUT the door, above the family // router and below the setup gate; the record is saved. // COMPANION RED-PROOF: drop the prepareFamilyGate block in DeployStack → "the family-gate file did not exist" fails. func TestFamilyGate_WrittenBeforeTheFirstStart(t *testing.T) { m := gateManager(t, familyYml) p := m.familyGatePath("gapp") var atUp string existed := false m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) { if len(args) > 0 && args[0] == "up" { b, err := os.ReadFile(p) existed, atUp = err == nil, string(b) } return "", nil } done := make(chan bool, 1) m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok }) if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil { t.Fatal(err) } select { case <-done: case <-time.After(20 * time.Second): t.Fatal("the deploy never ended") } if !existed { t.Fatal("the family-gate file did not exist when the app was first started — it was published open") } for _, want := range []string{"http://felhom-controller:8080/__felhom_gate/family", "felhom-family-gate-gapp@file", "PathRegexp(`^/api/v1/opds(/|$)`)", "PathRegexp(`^/api/kobo(/|$)`)"} { if !strings.Contains(atUp, want) { t.Errorf("the file lacks %q:\n%s", want, atUp) } } // each except router has NO middleware; each family router has one blocks := strings.Split(atUp, "\n felhom-family-gate-gapp-") nExcept, nDoor := 0, 0 for _, b := range blocks[1:] { isExcept := strings.Contains(strings.SplitN(b, "\n", 2)[0], "-except-") hasMW := strings.Contains(b, "middlewares:") if isExcept && hasMW { t.Errorf("an exception router carries the door:\n%s", b) } if !isExcept && !hasMW { t.Errorf("a family router lacks the door:\n%s", b) } if isExcept { nExcept++ } else { nDoor++ } for _, line := range strings.Split(b, "\n") { var pr int if _, err := fmt.Sscanf(strings.TrimSpace(line), "priority: %d", &pr); err == nil && pr >= setupGatePriority { t.Errorf("a family router outranks the setup gate (%d)", pr) } } } if nDoor != 2 || nExcept != 4 { t.Errorf("want 2 door routers and 4 exception routers (2 app routers × 2 exceptions), got %d/%d", nDoor, nExcept) } cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")) if cfg == nil || cfg.FamilyGate == nil || strings.Join(cfg.FamilyGate.Hosts, ",") != "gapp.example.hu" { t.Fatalf("record: %+v", cfg) } if n, ok := m.FamilyGateHost("GAPP.example.hu"); !ok || n != "gapp" { t.Fatalf("FamilyGateHost: %q %v", n, ok) } } // An unanchorable exception in the template refuses the install — never published open. func TestFamilyGate_BadExceptionRefusesTheInstall(t *testing.T) { m := gateManager(t, strings.Replace(familyYml, `"/api/kobo/"`, `"/api/(.*)"`, 1)) m.composeExecFn = func(_ string, _ map[string]string, _ ...string) (string, error) { return "", nil } if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil { t.Fatal("a template with an unanchorable exception must be refused") } if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) { t.Fatal("no file may be left behind") } } // A REMOVED family app restored from its backup gets its door before anything starts; the loop keeps it; a stale // file of an uninstalled app goes. func TestFamilyGate_RestoreOfARemovedAppAndTheLoop(t *testing.T) { m := gateManager(t, familyYml) must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"})) cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")) if cfg == nil || cfg.FamilyGate == nil { t.Fatal("the restore of a removed family app must record its door") } if _, err := os.Stat(m.familyGatePath("gapp")); err != nil { t.Fatal("the restore must write the door before the start") } must(t, os.Remove(m.familyGatePath("gapp"))) stale := m.familyGatePath("ghost") must(t, os.WriteFile(stale, []byte("x"), 0o644)) m.SetupGateTick() if _, err := os.Stat(m.familyGatePath("gapp")); err != nil { t.Fatal("the loop must put the door back") } if _, err := os.Stat(stale); !os.IsNotExist(err) { t.Fatal("the loop must remove a stale family-gate file") } } // min_controller: a template that needs a newer box is refused before anything is written. func TestMinController(t *testing.T) { old := controllerVersion t.Cleanup(func() { controllerVersion = old }) controllerVersion = "0.286.1" if err := checkMinController(&Metadata{MinController: "0.287.0"}); err == nil { t.Fatal("0.286.1 must refuse a template needing 0.287.0") } controllerVersion = "0.287.0" if err := checkMinController(&Metadata{MinController: "0.287.0"}); err != nil { t.Fatalf("equal version must pass: %v", err) } if err := checkMinController(&Metadata{}); err != nil { t.Fatal("no field must pass") } if err := checkMinController(&Metadata{MinController: "garbage"}); err == nil { t.Fatal("an unreadable min_controller must refuse") } m := gateManager(t, familyYml+"min_controller: \"9.9.9\"\n") controllerVersion = "0.287.0" if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil { t.Fatal("DeployStack must refuse a template needing a newer controller") } if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) { t.Fatal("nothing may be written for a refused template") } }