package infra import ( "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gopkg.in/yaml.v3" ) // TestRenderedYAMLParses guards against template-whitespace indentation bugs: every rendered // compose/config/static-config must be well-formed YAML across the token matrix. func TestRenderedYAMLParses(t *testing.T) { for i, s := range allRendered(t) { var v any if err := yaml.Unmarshal([]byte(s), &v); err != nil { t.Fatalf("rendered output #%d is not valid YAML: %v\n---\n%s", i, err, s) } } } // allComposeStrings renders every compose/config we emit, across the token/token-less matrix, so a // single ":latest" anywhere is caught. func allRendered(t *testing.T) []string { t.Helper() var out []string for _, td := range []TraefikData{ {ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}, {ACMEEmail: "admin@example.com"}, // email, no CF token → HTTP-01 {}, // token-less / LAN-only } { files, err := RenderTraefik(td) if err != nil { t.Fatalf("RenderTraefik(%+v): %v", td, err) } for _, f := range files { out = append(out, f.Content) } } cf, err := RenderCloudflared(CloudflaredData{CFTunnelToken: "tunnel-tok"}) if err != nil { t.Fatalf("RenderCloudflared: %v", err) } for _, f := range cf { out = append(out, f.Content) } out = append(out, RenderFileBrowserCompose("example.com", nil)) out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"})) out = append(out, RenderFileBrowserConfig(nil)) return out } func TestNoLatestTagSurvives(t *testing.T) { for _, c := range []string{TraefikImage, CloudflaredImage, FileBrowserImage} { if strings.HasSuffix(c, ":latest") || !strings.Contains(c, ":") { t.Fatalf("image constant is not pinned: %q", c) } } for _, s := range allRendered(t) { if strings.Contains(s, ":latest") { t.Fatalf(":latest survived in rendered output:\n%s", s) } } } func TestTraefikWithCloudflareToken(t *testing.T) { files, err := RenderTraefik(TraefikData{ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}) if err != nil { t.Fatal(err) } yml := files["traefik.yml"].Content if !strings.Contains(yml, "certResolver: letsencrypt") { t.Error("expected certResolver on websecure when ACME email set") } if !strings.Contains(yml, "dnsChallenge") || !strings.Contains(yml, "provider: cloudflare") { t.Error("expected Cloudflare DNS-01 challenge when CF API token set") } // The wildcard is NOT in traefik.yml — the entrypoint-level domains doesn't trigger issuance. if strings.Contains(yml, "domains:") { t.Error("traefik.yml must not carry the entrypoint domains block (proven not to issue)") } if strings.Contains(yml, "httpChallenge") { t.Error("HTTP-01 must NOT appear when a CF API token is set") } if !strings.Contains(yml, "email: admin@example.com") { t.Error("ACME email must appear in the cert-resolver block") } compose := files["docker-compose.yml"].Content if !strings.Contains(compose, "env_file") { t.Error("expected env_file in traefik compose when CF API token set") } if !strings.Contains(compose, TraefikImage) { t.Errorf("expected pinned traefik image %q in compose", TraefikImage) } env, ok := files[".env"] if !ok { t.Fatal("expected a .env file when CF API token is set") } if !strings.Contains(env.Content, "CF_DNS_API_TOKEN=cf-api-tok") { t.Errorf("CF API token not wired into .env: %q", env.Content) } if env.Mode != 0o600 { t.Errorf(".env must be 0600 (carries the CF token), got %o", env.Mode) } if files["traefik.yml"].Mode != 0o644 || files["docker-compose.yml"].Mode != 0o644 { t.Error("traefik.yml and docker-compose.yml must be 0644") } } func TestTraefikEmailNoCloudflareToken(t *testing.T) { files, err := RenderTraefik(TraefikData{ACMEEmail: "admin@example.com"}) if err != nil { t.Fatal(err) } yml := files["traefik.yml"].Content if !strings.Contains(yml, "httpChallenge") { t.Error("expected HTTP-01 challenge when email set but no CF token") } if strings.Contains(yml, "dnsChallenge") { t.Error("DNS-01 must NOT appear without a CF token") } if _, ok := files[".env"]; ok { t.Error("no .env should be emitted without a CF API token") } } func TestTraefikTokenless(t *testing.T) { files, err := RenderTraefik(TraefikData{}) if err != nil { t.Fatal(err) } yml := files["traefik.yml"].Content if strings.Contains(yml, "certificatesResolvers") || strings.Contains(yml, "certResolver") { t.Error("token-less node must emit no cert resolver (traefik serves self-signed)") } compose := files["docker-compose.yml"].Content if strings.Contains(compose, "env_file") { t.Error("token-less compose must not reference env_file") } if _, ok := files[".env"]; ok { t.Error("token-less node must emit no .env") } // Structural difference vs the with-token case is the whole point: the resolver section is absent. withTok, _ := RenderTraefik(TraefikData{ACMEEmail: "admin@example.com", CFAPIToken: "x"}) if withTok["traefik.yml"].Content == yml { t.Error("token-less and with-token traefik.yml must differ structurally") } } func TestCloudflaredRender(t *testing.T) { files, err := RenderCloudflared(CloudflaredData{CFTunnelToken: "tunnel-tok-123"}) if err != nil { t.Fatal(err) } compose := files["docker-compose.yml"].Content if !strings.Contains(compose, "TUNNEL_TOKEN=tunnel-tok-123") { t.Errorf("tunnel token not wired into cloudflared env: %q", compose) } if !strings.Contains(compose, CloudflaredImage) { t.Errorf("expected pinned cloudflared image %q", CloudflaredImage) } if !strings.Contains(compose, "command: tunnel run") { t.Error("expected `command: tunnel run`") } } func TestControllerRoute(t *testing.T) { // Wildcard path (DNS-01 ACME): the route anchors *. + apex proactive issuance. r := RenderControllerRoute("demo-felhom.eu", true) if !strings.Contains(r, "Host(`felhom.demo-felhom.eu`)") { t.Errorf("domain not wired into controller route rule: %q", r) } if !strings.Contains(r, "http://felhom-controller:8080") { t.Errorf("controller service URL missing: %q", r) } if !strings.Contains(r, "websecure") { t.Error("controller route must be on the websecure entrypoint") } if !strings.Contains(r, "certResolver: letsencrypt") || !strings.Contains(r, `main: "*.demo-felhom.eu"`) || !strings.Contains(r, `- "demo-felhom.eu"`) { t.Errorf("wildcard issuance anchor missing on the DNS-01 controller route:\n%s", r) } var v any if err := yaml.Unmarshal([]byte(r), &v); err != nil { t.Fatalf("controller route (wildcard) is not valid YAML: %v\n%s", err, r) } // Non-ACME path: plain TLS, no resolver/domains, still valid YAML. plain := RenderControllerRoute("demo-felhom.eu", false) if strings.Contains(plain, "certResolver") || strings.Contains(plain, "domains:") { t.Errorf("non-ACME route must not carry certResolver/domains:\n%s", plain) } if err := yaml.Unmarshal([]byte(plain), &v); err != nil { t.Fatalf("controller route (plain) is not valid YAML: %v\n%s", err, plain) } } func TestFileBrowserRender(t *testing.T) { compose := RenderFileBrowserCompose("demo-felhom.eu", nil) if !strings.Contains(compose, "Host(`files.demo-felhom.eu`)") { t.Errorf("domain not wired into filebrowser routing label: %q", compose) } if !strings.Contains(compose, FileBrowserImage) { t.Errorf("expected pinned filebrowser image %q", FileBrowserImage) } // Default config (no storage paths) → a single /srv source. def := RenderFileBrowserConfig(nil) if !strings.Contains(def, `- path: "/srv"`) { t.Errorf("empty config must default to a /srv source: %q", def) } // With paths → a named per-drive source. withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}}) if !strings.Contains(withPaths, `- path: "/srv/hdd_1"`) || !strings.Contains(withPaths, `name: "Media"`) { t.Errorf("storage path not wired into filebrowser config: %q", withPaths) } // Storage mounts wire into the compose volumes section. withMounts := RenderFileBrowserCompose("demo-felhom.eu", []string{" - /mnt/hdd_1:/srv/hdd_1"}) if !strings.Contains(withMounts, "/mnt/hdd_1:/srv/hdd_1") { t.Errorf("storage mount not wired into filebrowser compose: %q", withMounts) } }