1package domain_test
2
3import (
4 "errors"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/domain"
12)
13
14func validSpec() domain.AppSpec {
15 return domain.AppSpec{
16 Name: "blog",
17 Env: map[string]string{"APP_ENV": "production"},
18 Services: []domain.AppService{
19 {
20 Name: "web",
21 Image: "registry.example.com/blog/web:1.4.2",
22 StopGrace: 10 * time.Second,
23 Readiness: domain.AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second},
24 HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
25 Secrets: map[string]string{"DATABASE_URL": "database-url"},
26 },
27 },
28 }
29}
30
31func TestAppSpec_ValidateOK(t *testing.T) {
32 require.NoError(t, validSpec().Validate())
33}
34
35func TestAppSpec_ValidateErrors(t *testing.T) {
36 cases := []struct {
37 name string
38 mutate func(*domain.AppSpec)
39 wantErr string
40 }{
41 {"bad app", func(s *domain.AppSpec) { s.Name = "Bad!" }, "app name"},
42 {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"},
43 {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"},
44 {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"},
45 {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"},
46 {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"},
47 {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"},
48 {"http readiness absolute URL", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "http://127.0.0.1/private" }, "origin-form path"},
49 {"http readiness scheme relative", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "//127.0.0.1/private" }, "origin-form path"},
50 {"network unknown service", func(s *domain.AppSpec) {
51 s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"ghost"}}}
52 }, "unknown service"},
53 {"network bad alias", func(s *domain.AppSpec) {
54 s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"web"}, Aliases: []string{"Bad_Alias!"}}}
55 }, "DNS label"},
56 }
57 for _, tc := range cases {
58 t.Run(tc.name, func(t *testing.T) {
59 spec := validSpec()
60 tc.mutate(&spec)
61 err := spec.Validate()
62 require.Error(t, err)
63 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
64 assert.Contains(t, err.Error(), tc.wantErr)
65 })
66 }
67}
68
69func TestAppIdentityHelpers(t *testing.T) {
70 assert.Equal(t, "gordon-blog--web", domain.LogicalServiceIdentity("blog", "web"))
71 assert.Equal(t, "gordon-blog--a-b", domain.LogicalServiceIdentity("blog", "a.b"))
72 assert.Equal(t,
73 "gordon-blog--db--vol--pgdata",
74 domain.RuntimeVolumeName("blog", "db", "pgdata"))
75 assert.Equal(t,
76 "gordon/apps/blog/web/database-url",
77 domain.AppSecretPath("blog", "web", "database-url"))
78 assert.Equal(t,
79 "gordon/apps/app-uuid-1/web/database-url",
80 domain.AppSecretPathForID("app-uuid-1", "blog", "web", "database-url"))
81 assert.Equal(t,
82 "gordon/apps/blog/web/database-url",
83 domain.AppSecretPathForID("", "blog", "web", "database-url"))
84 assert.Equal(t, "blog.example.com", domain.CanonicalHTTPHost("Blog.Example.COM."))
85}
86
87func TestParsePublish(t *testing.T) {
88 host, port, err := domain.ParsePublish("127.0.0.1:8080")
89 require.NoError(t, err)
90 assert.Equal(t, "127.0.0.1", host)
91 assert.Equal(t, 8080, port)
92
93 host, port, err = domain.ParsePublish("8080")
94 require.NoError(t, err)
95 assert.Empty(t, host)
96 assert.Equal(t, 8080, port)
97
98 _, _, err = domain.ParsePublish("example.com:8080")
99 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
100
101 _, _, err = domain.ParsePublish("8080:9090:extra")
102 require.Error(t, err)
103
104 _, _, err = domain.ParsePublish("")
105 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
106
107 _, _, err = domain.ParsePublish("0")
108 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
109}
110
111func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
112 before := validSpec()
113 after := validSpec()
114 after.Services = append(after.Services, domain.AppService{
115 Name: "worker", Image: "img:1",
116 StopGrace: 10 * time.Second,
117 Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
118 })
119 after.Services[0].Image = "registry.example.com/blog/web:1.5.0"
120 after.Services[0].StopGrace = 20 * time.Second
121 after.Services[0].Secrets = map[string]string{"DATABASE_URL": "database-url-v2"}
122 after.Services[0].Databases = []domain.AppDatabase{{Name: "main", Type: "postgres", Schedule: "daily"}}
123 after.Services[0].Backup = domain.AppBackup{Postgres: []string{"main"}}
124 after.Networks = []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}}
125 after.Env["NEW_KEY"] = "v"
126
127 diff := domain.DiffAppSpec(after, before)
128 assert.Equal(t, []string{"service/worker"}, diff.Added)
129 assert.Empty(t, diff.Removed)
130 assert.Contains(t, diff.Changed, "service/web/image")
131 assert.Contains(t, diff.Changed, "service/web/stop_grace")
132 assert.Contains(t, diff.Changed, "service/web/databases")
133 assert.Contains(t, diff.Changed, "service/web/backup")
134 assert.Contains(t, diff.Changed, "network/shared/added")
135 assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")
136 assert.Contains(t, diff.Changed, "service/web/secret/database-url")
137 assert.Contains(t, diff.Changed, "env")
138 // Sorted output.
139 assert.IsIncreasing(t, diff.Changed)
140 // No secret values leak: only names/paths.
141 for _, entry := range append(append(diff.Added, diff.Removed...), diff.Changed...) {
142 assert.NotContains(t, entry, "s3cr3t")
143 }
144
145 // Identical specs produce an empty diff.
146 empty := domain.DiffAppSpec(before, validSpec())
147 assert.Empty(t, empty.Added)
148 assert.Empty(t, empty.Removed)
149 assert.Empty(t, empty.Changed)
150
151 // Removed service.
152 removed := domain.DiffAppSpec(domain.AppSpec{Name: "x"}, before)
153 assert.Equal(t, []string{"service/web"}, removed.Removed)
154}
155
156func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) {
157 base := validSpec()
158 base.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db", "primary"}}}
159
160 reordered := validSpec()
161 reordered.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"primary", "db"}}}
162 assert.Empty(t, domain.DiffAppSpec(reordered, base).Changed)
163
164 servicesChanged := validSpec()
165 servicesChanged.Services = append(servicesChanged.Services, domain.AppService{Name: "worker", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
166 servicesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web", "worker"}, Aliases: []string{"db", "primary"}}}
167 assert.Contains(t, domain.DiffAppSpec(servicesChanged, base).Changed, "network/database/services")
168
169 aliasesChanged := base
170 aliasesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
171 assert.Equal(t, []string{"network/database/aliases"}, domain.DiffAppSpec(aliasesChanged, base).Changed)
172
173 removed := validSpec()
174 assert.Equal(t, []string{"network/database/removed"}, domain.DiffAppSpec(removed, base).Changed)
175}
176
177func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
178 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
179}