package domain_test import ( "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/domain" ) func validSpec() domain.AppSpec { return domain.AppSpec{ Name: "blog", Env: map[string]string{"APP_ENV": "production"}, Services: []domain.AppService{ { Name: "web", Image: "registry.example.com/blog/web:1.4.2", StopGrace: 10 * time.Second, Readiness: domain.AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}}, Secrets: map[string]string{"DATABASE_URL": "database-url"}, }, }, } } func TestAppSpec_ValidateOK(t *testing.T) { require.NoError(t, validSpec().Validate()) } func TestAppSpec_ValidateErrors(t *testing.T) { cases := []struct { name string mutate func(*domain.AppSpec) wantErr string }{ {"bad app", func(s *domain.AppSpec) { s.Name = "Bad!" }, "app name"}, {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"}, {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"}, {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"}, {"service env newline", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "a\nb"} }, "newlines"}, {"service env secret ref", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "${sops:y}"} }, "secret references"}, {"service env empty value", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": ""} }, "1-"}, {"service env bad key", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"1bad": "v"} }, "env key"}, {"service env secret collision", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"DATABASE_URL": "x"} }, "collides with a secret key"}, {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"}, {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"}, {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"}, {"http readiness absolute URL", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "http://127.0.0.1/private" }, "origin-form path"}, {"http readiness scheme relative", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "//127.0.0.1/private" }, "origin-form path"}, {"network unknown service", func(s *domain.AppSpec) { s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"ghost"}}} }, "unknown service"}, {"network bad alias", func(s *domain.AppSpec) { s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"web"}, Aliases: []string{"Bad_Alias!"}}} }, "DNS label"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { spec := validSpec() tc.mutate(&spec) err := spec.Validate() require.Error(t, err) assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) assert.Contains(t, err.Error(), tc.wantErr) }) } } func TestAppIdentityHelpers(t *testing.T) { assert.Equal(t, "gordon-blog--web", domain.LogicalServiceIdentity("blog", "web")) assert.Equal(t, "gordon-blog--a-b", domain.LogicalServiceIdentity("blog", "a.b")) assert.Equal(t, "gordon-blog--db--vol--pgdata", domain.RuntimeVolumeName("blog", "db", "pgdata")) assert.Equal(t, "gordon/apps/blog/web/database-url", domain.AppSecretPath("blog", "web", "database-url")) assert.Equal(t, "gordon/apps/app-uuid-1/web/database-url", domain.AppSecretPathForID("app-uuid-1", "blog", "web", "database-url")) assert.Equal(t, "gordon/apps/blog/web/database-url", domain.AppSecretPathForID("", "blog", "web", "database-url")) assert.Equal(t, "blog.example.com", domain.CanonicalHTTPHost("Blog.Example.COM.")) } func TestParsePublish(t *testing.T) { host, port, err := domain.ParsePublish("127.0.0.1:8080") require.NoError(t, err) assert.Equal(t, "127.0.0.1", host) assert.Equal(t, 8080, port) host, port, err = domain.ParsePublish("8080") require.NoError(t, err) assert.Empty(t, host) assert.Equal(t, 8080, port) _, _, err = domain.ParsePublish("example.com:8080") require.ErrorIs(t, err, domain.ErrInvalidAppSpec) _, _, err = domain.ParsePublish("8080:9090:extra") require.Error(t, err) _, _, err = domain.ParsePublish("") require.ErrorIs(t, err, domain.ErrInvalidAppSpec) _, _, err = domain.ParsePublish("0") require.ErrorIs(t, err, domain.ErrInvalidAppSpec) } func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) { before := validSpec() after := validSpec() after.Services = append(after.Services, domain.AppService{ Name: "worker", Image: "img:1", StopGrace: 10 * time.Second, Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}, }) after.Services[0].Image = "registry.example.com/blog/web:1.5.0" after.Services[0].StopGrace = 20 * time.Second after.Services[0].Secrets = map[string]string{"DATABASE_URL": "database-url-v2"} after.Services[0].Databases = []domain.AppDatabase{{Name: "main", Type: "postgres", Schedule: "daily"}} after.Services[0].Backup = domain.AppBackup{Postgres: []string{"main"}} after.Networks = []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}} after.Env["NEW_KEY"] = "v" diff := domain.DiffAppSpec(after, before) assert.Equal(t, []string{"service/worker"}, diff.Added) assert.Empty(t, diff.Removed) assert.Contains(t, diff.Changed, "service/web/image") assert.Contains(t, diff.Changed, "service/web/stop_grace") assert.Contains(t, diff.Changed, "service/web/databases") assert.Contains(t, diff.Changed, "service/web/backup") assert.Contains(t, diff.Changed, "network/shared/added") assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2") assert.Contains(t, diff.Changed, "service/web/secret/database-url") assert.Contains(t, diff.Changed, "env/NEW_KEY") // Sorted output. assert.IsIncreasing(t, diff.Changed) // No secret values leak: only names/paths. for _, entry := range append(append(diff.Added, diff.Removed...), diff.Changed...) { assert.NotContains(t, entry, "s3cr3t") } // Identical specs produce an empty diff. empty := domain.DiffAppSpec(before, validSpec()) assert.Empty(t, empty.Added) assert.Empty(t, empty.Removed) assert.Empty(t, empty.Changed) // Removed service. removed := domain.DiffAppSpec(domain.AppSpec{Name: "x"}, before) assert.Equal(t, []string{"service/web"}, removed.Removed) } func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) { base := validSpec() base.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db", "primary"}}} reordered := validSpec() reordered.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"primary", "db"}}} assert.Empty(t, domain.DiffAppSpec(reordered, base).Changed) servicesChanged := validSpec() servicesChanged.Services = append(servicesChanged.Services, domain.AppService{Name: "worker", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}}) servicesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web", "worker"}, Aliases: []string{"db", "primary"}}} assert.Contains(t, domain.DiffAppSpec(servicesChanged, base).Changed, "network/database/services") aliasesChanged := base aliasesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}} assert.Equal(t, []string{"network/database/aliases"}, domain.DiffAppSpec(aliasesChanged, base).Changed) removed := validSpec() assert.Equal(t, []string{"network/database/removed"}, domain.DiffAppSpec(removed, base).Changed) } func TestDiffAppSpec_ErrorsWrapped(t *testing.T) { assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec)) } func TestAppSpec_ServiceEnvOverridesAppEnvAllowed(t *testing.T) { spec := validSpec() spec.Services[0].Env = map[string]string{"APP_ENV": "staging"} require.NoError(t, spec.Validate()) } func TestDiffAppSpec_ServiceEnv(t *testing.T) { base := validSpec() changed := validSpec() changed.Services[0].Env = map[string]string{"LOG": "debug"} assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(changed, base).Changed) assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(base, changed).Changed) assert.Empty(t, domain.DiffAppSpec(changed, changed).Changed) assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0])) changed.Services[0].Env["LOG"] = "info" other := validSpec() other.Services[0].Env = map[string]string{"LOG": "debug"} assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(changed, other).Changed) } func TestDiffAppSpec_EnvListsChangedKeyNamesOnly(t *testing.T) { before := validSpec() before.Env = map[string]string{"KEEP": "1", "EDIT": "old-value", "DROP": "x"} after := validSpec() after.Env = map[string]string{"KEEP": "1", "EDIT": "new-value", "ADD": "y"} diff := domain.DiffAppSpec(after, before) assert.Equal(t, []string{"env/ADD", "env/DROP", "env/EDIT"}, diff.Changed) for _, entry := range diff.Changed { assert.NotContains(t, entry, "value") } } func TestActiveAppSpec_UsesActivatedRevisionEnv(t *testing.T) { web := validSpec().Services[0] worker := web worker.Name = "worker" active := domain.AppActive{ App: "blog", Services: map[string]domain.AppEffectiveService{ "web": {EffectiveRevision: "rev-1", Spec: web}, "worker": {EffectiveRevision: "rev-2", Spec: worker}, }, } envs := map[string]map[string]string{ "rev-1": {"MODE": "prod"}, "rev-2": {"MODE": "old"}, } load := func(revision string) (map[string]string, error) { return envs[revision], nil } // Image-only change: desired env equals what every service runs. envs["rev-2"] = map[string]string{"MODE": "prod"} spec, err := domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, load) require.NoError(t, err) assert.Equal(t, map[string]string{"MODE": "prod"}, spec.Env) assert.Equal(t, []string{"web", "worker"}, []string{spec.Services[0].Name, spec.Services[1].Name}) // One service still runs a stale env: the diff must not hide it. envs["rev-2"] = map[string]string{"MODE": "old"} spec, err = domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, load) require.NoError(t, err) assert.Equal(t, map[string]string{"MODE": "old"}, spec.Env) // A garbage-collected revision counts as an empty env. spec, err = domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, func(string) (map[string]string, error) { return nil, nil }) require.NoError(t, err) assert.Empty(t, spec.Env) _, err = domain.ActiveAppSpec(active, nil, func(string) (map[string]string, error) { return nil, errors.New("boom") }) require.Error(t, err) } func TestActiveAppSpec_ReportsStaleKeysFromEveryRevision(t *testing.T) { web := validSpec().Services[0] worker := web worker.Name = "worker" active := domain.AppActive{ App: "blog", Services: map[string]domain.AppEffectiveService{ "web": {EffectiveRevision: "rev-1", Spec: web}, "worker": {EffectiveRevision: "rev-2", Spec: worker}, }, } envs := map[string]map[string]string{ "rev-1": {"A": "old", "B": "new", "C": "same"}, "rev-2": {"A": "new", "C": "same", "D": "gone"}, } desired := domain.AppSpec{Name: "blog", Env: map[string]string{"A": "new", "B": "new", "C": "same"}} desired.Services = []domain.AppService{web, worker} effective, err := domain.ActiveAppSpec(active, desired.Env, func(revision string) (map[string]string, error) { return envs[revision], nil }) require.NoError(t, err) diff := domain.DiffAppSpec(desired, effective) assert.Equal(t, []string{"env/A", "env/B", "env/D"}, diff.Changed) assert.Equal(t, map[string]string{"A": "new", "B": "new", "C": "same"}, desired.Env, "desired env must not be mutated") } func TestActiveAppSpec_EmptyActiveHasEmptyEnv(t *testing.T) { spec, err := domain.ActiveAppSpec(domain.AppActive{App: "blog"}, map[string]string{"A": "x"}, func(string) (map[string]string, error) { t.Fatal("no revision should be loaded") return nil, nil }) require.NoError(t, err) assert.Empty(t, spec.Env) assert.NotNil(t, spec.Env) } func TestAppService_BackupOnlyChange(t *testing.T) { base := validSpec() changed := validSpec() changed.Services[0].Backup = domain.AppBackup{Volume: []string{"web-data"}} // The user-facing diff keeps reporting the backup change. assert.Equal(t, []string{"service/web/backup"}, domain.DiffAppSpec(changed, base).Changed) assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0])) // The runtime comparison ignores it. assert.True(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0])) // A real runtime change is still detected alongside a backup change. changed.Services[0].Command = []string{"serve"} assert.False(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0])) }