Files
felhom-controller/controller/internal/stacks/family_gate_test.go
T
admin 977665d8c0
gates / gates (push) Successful in 27s
The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00

179 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}