Files
felhom-controller/controller/internal/infra/infra_test.go
T
admin 84c3e84641 v0.42.0: real Let's Encrypt cert via wildcard proactive issuance
traefik's websecure entrypoint now declares http.tls.domains *.<domain>+apex so
it proactively obtains the wildcard via Cloudflare DNS-01 at startup (cert ready
before first client, every router serves it by SNI). Gated on CFAPIToken (DNS-01).
TraefikData gains Domain; ensureTraefik wires cfg.Customer.Domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:48:15 +02:00

216 lines
7.7 KiB
Go

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{
{Domain: "example.com", ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"},
{Domain: "example.com", ACMEEmail: "admin@example.com"}, // email, no CF token → HTTP-01
{Domain: "example.com"}, // 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{Domain: "example.com", 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")
}
// Wildcard proactive issuance (DNS-01 path): the entrypoint must request *.<domain> + apex.
if !strings.Contains(yml, `main: "*.example.com"`) || !strings.Contains(yml, `- "example.com"`) {
t.Errorf("expected wildcard domains block (*.example.com + apex) on the DNS-01 path:\n%s", yml)
}
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 strings.Contains(yml, "main: \"*.") {
t.Error("wildcard domains block must NOT appear on the HTTP-01 path (wildcards need DNS-01)")
}
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) {
r := RenderControllerRoute("demo-felhom.eu")
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")
}
var v any
if err := yaml.Unmarshal([]byte(r), &v); err != nil {
t.Fatalf("controller route is not valid YAML: %v\n%s", err, r)
}
}
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)
}
}