1package domain_test
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/gordon/internal/domain"
11)
12
13func visibilitySpec(interfaces ...domain.AppHTTPInterface) domain.AppSpec {
14 return domain.AppSpec{
15 Name: "blog",
16 Services: []domain.AppService{{
17 Name: "web",
18 Image: "registry.example.com/blog/web:1.4.2",
19 StopGrace: 10 * time.Second,
20 Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 30 * time.Second},
21 HTTP: interfaces,
22 }},
23 }
24}
25
26// TestAppHTTPInterface_EffectiveVisibility proves the zero value normalizes
27// to public so manifests and stored state written before visibility existed
28// keep their meaning.
29func TestAppHTTPInterface_EffectiveVisibility(t *testing.T) {
30 absent := domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"}
31 assert.Equal(t, domain.AppVisibilityPublic, absent.EffectiveVisibility())
32 assert.True(t, absent.IsPublic())
33 assert.False(t, absent.IsInternal())
34
35 explicit := absent
36 explicit.Visibility = domain.AppVisibilityInternal
37 assert.Equal(t, domain.AppVisibilityInternal, explicit.EffectiveVisibility())
38 assert.True(t, explicit.IsInternal())
39 assert.False(t, explicit.IsPublic())
40}
41
42// TestAppSpec_ValidateVisibility proves public and absent visibility stay
43// valid, internal visibility accepts a port without host or tls, and every
44// malformed combination is rejected.
45func TestAppSpec_ValidateVisibility(t *testing.T) {
46 t.Run("absent visibility remains a valid public manifest", func(t *testing.T) {
47 require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
48 Host: "blog.example.com", Port: 8080, TLS: "auto",
49 }).Validate())
50 })
51
52 t.Run("explicit public remains valid", func(t *testing.T) {
53 require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
54 Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityPublic,
55 }).Validate())
56 })
57
58 t.Run("internal accepts a port without host or tls", func(t *testing.T) {
59 require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
60 Port: 8080, Visibility: domain.AppVisibilityInternal,
61 }).Validate())
62 })
63
64 reject := map[string]domain.AppHTTPInterface{
65 "internal forbids host": {Host: "blog.example.com", Port: 8080, Visibility: domain.AppVisibilityInternal},
66 "internal forbids tls": {Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityInternal},
67 "unknown visibility": {Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: "vpn"},
68 "internal without port": {Visibility: domain.AppVisibilityInternal},
69 "public without host": {Port: 8080, TLS: "auto"},
70 "public without valid host": {Host: "not a host", Port: 8080, TLS: "auto"},
71 "public without tls mode": {Host: "blog.example.com", Port: 8080},
72 "public with unknown tls mode": {Host: "blog.example.com", Port: 8080, TLS: "sometimes"},
73 "public with out of range port": {Host: "blog.example.com", Port: 0, TLS: "auto"},
74 "internal with out of range port": {Port: -1, Visibility: domain.AppVisibilityInternal},
75 }
76 for name, iface := range reject {
77 t.Run(name, func(t *testing.T) {
78 err := visibilitySpec(iface).Validate()
79 require.Error(t, err)
80 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
81 })
82 }
83}
84
85// TestAppService_InternallyOnlyPort proves a port is internal-only only
86// when internal HTTP declares it and no externally backed interface does.
87func TestAppService_InternallyOnlyPort(t *testing.T) {
88 internalOnly := domain.AppService{
89 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
90 }
91 assert.True(t, internalOnly.InternallyOnlyPort(8080))
92 assert.False(t, internalOnly.InternallyOnlyPort(8081))
93 assert.True(t, internalOnly.IsPublicHTTP() == false)
94
95 mixed := domain.AppService{
96 HTTP: []domain.AppHTTPInterface{
97 {Host: "blog.example.com", Port: 8080, TLS: "auto"},
98 {Port: 9090, Visibility: domain.AppVisibilityInternal},
99 },
100 }
101 assert.True(t, mixed.IsPublicHTTP())
102 assert.True(t, mixed.InternallyOnlyPort(9090))
103 assert.False(t, mixed.InternallyOnlyPort(8080))
104
105 // A TCP interface on the same port is externally backed: the port is
106 // never internal-only, even though an internal HTTP interface wants it.
107 // Validation rejects that combination, and the read helper must not
108 // treat it as internal-only either.
109 shadowed := domain.AppService{
110 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
111 TCP: []domain.AppTCPInterface{{Port: 8080}},
112 }
113 assert.False(t, shadowed.InternallyOnlyPort(8080))
114}
115
116// TestAppSpec_ValidateInterfacePorts proves an internal HTTP port may not
117// collide with an externally backed interface on the same container port,
118// and that internal ports stay unique.
119func TestAppSpec_ValidateInterfacePorts(t *testing.T) {
120 collide := visibilitySpec(
121 domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
122 )
123 collide.Services[0].Readiness.Port = 8080
124 collide.Services[0].TCP = []domain.AppTCPInterface{{Port: 8080}}
125 require.ErrorIs(t, collide.Validate(), domain.ErrInvalidAppSpec)
126
127 duplicate := visibilitySpec(
128 domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
129 domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
130 )
131 // An explicit port keeps this test on the duplicate-internal-port rule
132 // instead of short-circuiting on readiness-port ambiguity.
133 duplicate.Services[0].Readiness.Port = 8080
134 require.ErrorIs(t, duplicate.Validate(), domain.ErrInvalidAppSpec)
135}
136
137// TestAppSpec_ValidateMixedVisibilityReadiness proves one public HTTP
138// interface plus internal HTTP interfaces is a valid readiness target with
139// no explicit readiness.port, while interface sets that cannot select one
140// port stay strict.
141func TestAppSpec_ValidateMixedVisibilityReadiness(t *testing.T) {
142 require.NoError(t, visibilitySpec(
143 domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
144 domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
145 ).Validate())
146
147 tcpReadiness := visibilitySpec(
148 domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
149 domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
150 )
151 tcpReadiness.Services[0].Readiness.Type = domain.AppReadinessTCP
152 require.NoError(t, tcpReadiness.Validate())
153
154 explicit := visibilitySpec(
155 domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
156 domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
157 )
158 explicit.Services[0].Readiness.Port = 9090
159 require.NoError(t, explicit.Validate())
160
161 reject := map[string]func(spec domain.AppSpec){
162 "several internal ports": func(spec domain.AppSpec) {
163 spec.Services[0].HTTP = []domain.AppHTTPInterface{
164 {Port: 8080, Visibility: domain.AppVisibilityInternal},
165 {Port: 9090, Visibility: domain.AppVisibilityInternal},
166 }
167 },
168 "public http plus tcp": func(spec domain.AppSpec) {
169 spec.Services[0].TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}
170 },
171 "several public http ports": func(spec domain.AppSpec) {
172 spec.Services[0].HTTP = []domain.AppHTTPInterface{
173 {Host: "blog.example.com", Port: 8080, TLS: "auto"},
174 {Host: "www.example.com", Port: 8081, TLS: "auto"},
175 }
176 },
177 }
178 for name, mutate := range reject {
179 t.Run(name, func(t *testing.T) {
180 spec := visibilitySpec(
181 domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
182 domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
183 )
184 mutate(spec)
185 err := spec.Validate()
186 require.Error(t, err)
187 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
188 assert.Contains(t, err.Error(), "readiness.port is required")
189 })
190 }
191}