1package remote
2
3// Tests for the v3 app mutation transport (apps.go): every mutation
4// carries a client-generated Idempotency-Key, retryable gateway statuses
5// surface OutcomeUnknownError without replaying the mutation, and
6// ambiguous outcomes recover via the by-key endpoint.
7
8import (
9 "context"
10 "encoding/json"
11 "net/http"
12 "net/http/httptest"
13 "sync/atomic"
14 "testing"
15
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18
19 "github.com/bnema/gordon/internal/adapters/dto"
20)
21
22func TestClientApplyApp_SendsIdempotencyKey(t *testing.T) {
23 var gotKey string
24 var gotBody dto.AppApplyRequest
25 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 require.Equal(t, "/admin/apps/apply", r.URL.Path)
27 require.Equal(t, http.MethodPost, r.Method)
28 gotKey = r.Header.Get("Idempotency-Key")
29 require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
30 w.Header().Set("Content-Type", "application/json")
31 _ = json.NewEncoder(w).Encode(dto.AppApplyResponse{
32 App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1",
33 })
34 }))
35 defer srv.Close()
36
37 resp, err := NewClient(srv.URL).ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "x"})
38 require.NoError(t, err)
39 assert.Equal(t, "blog", resp.App)
40 assert.NotEmpty(t, gotKey, "mutations must carry an Idempotency-Key")
41 assert.Equal(t, "x", gotBody.ManifestTOML)
42}
43
44func TestClientDeployApp_GatewayErrorIsOutcomeUnknownWithoutReplay(t *testing.T) {
45 var mutations int32
46 var gotKey string
47 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48 require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
49 atomic.AddInt32(&mutations, 1)
50 gotKey = r.Header.Get("Idempotency-Key")
51 w.WriteHeader(http.StatusServiceUnavailable)
52 _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
53 }))
54 defer srv.Close()
55
56 resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
57 require.Nil(t, resp)
58 require.Error(t, err)
59 var unknown *OutcomeUnknownError
60 require.ErrorAs(t, err, &unknown)
61 assert.EqualValues(t, 1, atomic.LoadInt32(&mutations), "mutations must never replay on ambiguity")
62 assert.NotEmpty(t, key)
63 assert.Equal(t, gotKey, key, "recovery re-queries with the SAME key")
64}
65
66func TestClientOperationByKey_RecoveryPath(t *testing.T) {
67 var gotPath string
68 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69 gotPath = r.URL.EscapedPath()
70 require.Equal(t, http.MethodGet, r.Method)
71 w.Header().Set("Content-Type", "application/json")
72 _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
73 Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success",
74 })
75 }))
76 defer srv.Close()
77
78 resp, err := NewClient(srv.URL).OperationByKey(context.Background(), "blog", "key-abc")
79 require.NoError(t, err)
80 assert.Equal(t, "op-1", resp.Op)
81 assert.Equal(t, "/admin/apps/blog/operations/by-key/key-abc", gotPath)
82}
83
84func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
85 var keys []string
86 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 keys = append(keys, r.Header.Get("Idempotency-Key"))
88 w.Header().Set("Content-Type", "application/json")
89 _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
90 }))
91 defer srv.Close()
92
93 client := NewClient(srv.URL)
94 ctx := context.Background()
95 _, _, err := client.StopApp(ctx, "blog")
96 require.NoError(t, err)
97 _, _, err = client.StartApp(ctx, "blog")
98 require.NoError(t, err)
99 _, _, err = client.RemoveApp(ctx, "blog")
100 require.NoError(t, err)
101 require.Len(t, keys, 3)
102 for _, key := range keys {
103 assert.NotEmpty(t, key)
104 }
105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106}
107
108func TestClientRestartApp_SendsServiceScope(t *testing.T) {
109 var queries []string
110 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111 queries = append(queries, r.URL.RawQuery)
112 w.Header().Set("Content-Type", "application/json")
113 _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{Op: "op-x", Outcome: "success"})
114 }))
115 defer srv.Close()
116
117 client := NewClient(srv.URL)
118 for _, tc := range []struct {
119 service string
120 all bool
121 }{{"", false}, {"web", false}, {"", true}} {
122 _, _, err := client.RestartApp(context.Background(), "blog", tc.service, tc.all)
123 require.NoError(t, err)
124 }
125 assert.Equal(t, []string{"", "service=web", "all=true"}, queries)
126}
127
128// TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts
129// the 202 running response: it decodes the journal and returns the key so the
130// caller can poll operations/by-key.
131func TestClientDeployApp_AcceptedRunningJournal(t *testing.T) {
132 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133 require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
134 require.Equal(t, http.MethodPost, r.Method)
135 require.NotEmpty(t, r.Header.Get("Idempotency-Key"))
136 w.Header().Set("Content-Type", "application/json")
137 w.WriteHeader(http.StatusAccepted)
138 _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
139 Op: "op-run", App: "blog", Revision: "rev-b", Status: dto.AppStatusRunning,
140 })
141 }))
142 defer srv.Close()
143
144 resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
145 require.NoError(t, err)
146 require.NotNil(t, resp)
147 assert.Equal(t, "op-run", resp.Op)
148 assert.Equal(t, dto.AppStatusRunning, resp.Status)
149 assert.NotEmpty(t, key)
150}
151
152func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {
153 journal := dto.AppDeployResponse{
154 Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
155 Services: map[string]dto.AppServiceResultDTO{
156 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
157 },
158 Steps: []dto.AppStepDTO{{ID: "service.web.start", State: "failed", Error: "nope"}},
159 }
160 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161 require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
162 w.Header().Set("Content-Type", "application/json")
163 w.WriteHeader(http.StatusConflict)
164 _ = json.NewEncoder(w).Encode(journal)
165 }))
166 defer srv.Close()
167
168 resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
169 require.Error(t, err)
170 require.NotNil(t, resp, "conflict must preserve the decoded journal")
171 assert.Equal(t, "op-9", resp.Op)
172 assert.Equal(t, "failed", resp.Services["web"].Result)
173 assert.NotEmpty(t, key)
174 var conflict *AppOpConflictError
175 require.ErrorAs(t, err, &conflict)
176 assert.Equal(t, http.StatusConflict, conflict.StatusCode)
177 assert.Equal(t, "op-9", conflict.Response.Op)
178 assert.Len(t, conflict.Response.Steps, 1)
179}
180
181func TestClientStopApp_ConflictDecodesJournal(t *testing.T) {
182 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183 require.Equal(t, "/admin/apps/blog/stop", r.URL.Path)
184 w.Header().Set("Content-Type", "application/json")
185 w.WriteHeader(http.StatusConflict)
186 _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
187 Op: "op-7", App: "blog", Outcome: "partial",
188 Services: map[string]dto.AppServiceResultDTO{
189 "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
190 },
191 })
192 }))
193 defer srv.Close()
194
195 resp, key, err := NewClient(srv.URL).StopApp(context.Background(), "blog")
196 require.Error(t, err)
197 require.NotNil(t, resp)
198 assert.Equal(t, "partial", resp.Outcome)
199 assert.NotEmpty(t, key)
200 var conflict *AppOpConflictError
201 require.ErrorAs(t, err, &conflict)
202}
203
204func TestClientDeployApp_ConflictWithoutJournalFallsBackToHTTPError(t *testing.T) {
205 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206 w.WriteHeader(http.StatusConflict)
207 _, _ = w.Write([]byte(`{"error":"state-conflict","message":"busy"}`))
208 }))
209 defer srv.Close()
210
211 resp, _, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
212 require.Error(t, err)
213 require.Nil(t, resp)
214 var httpErr *HTTPError
215 require.ErrorAs(t, err, &httpErr)
216 assert.Equal(t, http.StatusConflict, httpErr.StatusCode)
217}
218
219func TestClientListAppSecrets_ServiceFilterAndErrorContext(t *testing.T) {
220 var gotService string
221 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222 require.Equal(t, "/admin/apps/blog/secrets", r.URL.Path)
223 gotService = r.URL.Query().Get("service")
224 w.Header().Set("Content-Type", "application/json")
225 _ = json.NewEncoder(w).Encode([]dto.AppSecretMetadataDTO{
226 {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
227 })
228 }))
229 defer srv.Close()
230
231 entries, err := NewClient(srv.URL).ListAppSecrets(context.Background(), "blog", "web")
232 require.NoError(t, err)
233 require.Len(t, entries, 1)
234 assert.Equal(t, "web", gotService)
235
236 errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
237 w.WriteHeader(http.StatusInternalServerError)
238 _, _ = w.Write([]byte(`{"error":"internal"}`))
239 }))
240 defer errSrv.Close()
241
242 _, err = NewClient(errSrv.URL).ListAppSecrets(context.Background(), "blog", "")
243 require.Error(t, err)
244 assert.Contains(t, err.Error(), "list app secrets blog")
245}