1package admin
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "sort"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 in "github.com/bnema/gordon/internal/boundaries/in"
20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
21 "github.com/bnema/gordon/internal/domain"
22)
23
24func appsTestHandler(t *testing.T, appSvc *inmocks.MockAppService) *Handler {
25 t.Helper()
26 return NewHandler(HandlerDeps{
27 ConfigSvc: inmocks.NewMockConfigService(t),
28 AuthSvc: inmocks.NewMockAuthService(t),
29 ContainerSvc: inmocks.NewMockContainerService(t),
30 HealthSvc: inmocks.NewMockHealthService(t),
31 Log: testLogger(),
32 ReloadTrigger: noopReloadTrigger{},
33 AppSvc: appSvc,
34 })
35}
36
37func appsRequest(t *testing.T, handler *Handler, method, target string, body any, scopes ...string) *httptest.ResponseRecorder {
38 t.Helper()
39 var reader io.Reader
40 if body != nil {
41 raw, err := json.Marshal(body)
42 require.NoError(t, err)
43 reader = bytes.NewReader(raw)
44 }
45 req := httptest.NewRequest(method, target, reader)
46 if method != http.MethodGet {
47 req.Header.Set("Idempotency-Key", "test-operation-key")
48 }
49 req = req.WithContext(ctxWithScopes(scopes...))
50 rec := httptest.NewRecorder()
51 handler.ServeHTTP(rec, req)
52 return rec
53}
54
55// appsServerDo sends a request through a test server backed by the apps
56// handler and returns the real HTTP status code and body. The scopes are
57// injected by newScopedTestServer because the server, not the client, owns
58// the request context.
59func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, body any) (int, []byte) {
60 t.Helper()
61 var reader io.Reader
62 if body != nil {
63 raw, err := json.Marshal(body)
64 require.NoError(t, err)
65 reader = bytes.NewReader(raw)
66 }
67 req, err := http.NewRequest(method, srv.URL+target, reader)
68 require.NoError(t, err)
69 if method != http.MethodGet {
70 req.Header.Set("Idempotency-Key", "test-operation-key")
71 }
72 resp, err := srv.Client().Do(req)
73 require.NoError(t, err)
74 defer func() { require.NoError(t, resp.Body.Close()) }()
75 assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
76 raw, err := io.ReadAll(resp.Body)
77 require.NoError(t, err)
78 return resp.StatusCode, raw
79}
80
81const validAppManifest = `
82name = "blog"
83[services.web]
84image = "img:1"
85[[services.web.http]]
86host = "blog.example.com"
87port = 8080
88`
89
90func TestHandler_AppApply_Persists(t *testing.T) {
91 appSvc := inmocks.NewMockAppService(t)
92 handler := appsTestHandler(t, appSvc)
93
94 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
95 &in.AppApplyResult{App: "blog", ResultingRevision: "rev-1", Pending: true, IntentID: "apply-1"},
96 nil, nil,
97 ).Once()
98
99 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
100 dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
101 require.Equal(t, http.StatusOK, rec.Code)
102 var resp dto.AppApplyResponse
103 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
104 assert.Equal(t, "blog", resp.App)
105 assert.Equal(t, "rev-1", resp.ResultingRevision)
106 assert.True(t, resp.Pending)
107}
108
109func TestHandler_AppApply_RejectsBadManifest(t *testing.T) {
110 appSvc := inmocks.NewMockAppService(t)
111 handler := appsTestHandler(t, appSvc)
112
113 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
114 dto.AppApplyRequest{ManifestTOML: "name = \"Bad!\"\n"}, "admin:apps:write")
115 assert.Equal(t, http.StatusBadRequest, rec.Code)
116 var envelope dto.AppError
117 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
118 assert.Equal(t, "invalid-manifest", envelope.Error)
119}
120
121func TestHandler_AppDeploy_MapsOp(t *testing.T) {
122 appSvc := inmocks.NewMockAppService(t)
123 handler := appsTestHandler(t, appSvc)
124
125 op := &domain.AppOperation{
126 Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
127 Outcome: domain.AppOutcomeSuccess,
128 Steps: []domain.AppOperationStep{
129 {ID: "preflight", State: domain.AppStepSucceeded},
130 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
131 {ID: "service.runner.remove", State: domain.AppStepSucceeded, Before: "c-runner"},
132 },
133 }
134 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
135 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
136 App: "blog", Converged: true, ConvergedRevision: "rev-1",
137 Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},
138 Retained: in.AppRetainedView{Volumes: []string{"gordon-blog--web--vol--data"}, Secrets: []string{"gordon/apps/app-1/web/database-url"}},
139 }, nil).Once()
140
141 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
142 dto.AppDeployRequest{}, "admin:apps:write")
143 require.Equal(t, http.StatusOK, rec.Code)
144 var resp dto.AppDeployResponse
145 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
146 assert.Equal(t, "op-1", resp.Op)
147 assert.Equal(t, "success", resp.Outcome)
148 require.Contains(t, resp.Services, "web")
149 assert.Equal(t, "deployed", resp.Services["web"].Result)
150 assert.Equal(t, "c-new", resp.Services["web"].After)
151 require.Contains(t, resp.Services, "runner")
152 assert.Equal(t, domain.AppServiceRemoved, resp.Services["runner"].Result)
153 assert.Empty(t, resp.Services["runner"].EffectiveRevision, "a removed service has no effective revision")
154 // Effective and retained state comes from the app read model, never
155 // from the journal outcome.
156 require.NotNil(t, resp.Effective)
157 assert.True(t, resp.Effective.Converged)
158 assert.Equal(t, "rev-1", resp.Effective.Services["web"])
159 require.NotNil(t, resp.Retained)
160 assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
161}
162
163// TestHandler_AppDeploy_MapsCleanupWarnings proves bounded leftovers of a
164// successful mutation reach the wire instead of being dropped.
165func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
166 appSvc := inmocks.NewMockAppService(t)
167 handler := appsTestHandler(t, appSvc)
168
169 op := &domain.AppOperation{
170 Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
171 Outcome: domain.AppOutcomeSuccess,
172 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
173 Warnings: []domain.AppOperationWarning{{
174 Service: "web", Leftover: "ctr-old", Detail: "remove: still present",
175 }},
176 }
177 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
178 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()
179
180 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
181 dto.AppDeployRequest{}, "admin:apps:write")
182 require.Equal(t, http.StatusOK, rec.Code)
183 var resp dto.AppDeployResponse
184 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
185 require.Len(t, resp.CleanupWarnings, 1)
186 assert.Equal(t, "web", resp.CleanupWarnings[0].Service)
187 assert.Equal(t, "ctr-old", resp.CleanupWarnings[0].Leftover)
188 assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)
189}
190
191// TestHandler_AppDeploy_RunningReturns202 proves a newly owned deploy whose
192// effects are still executing answers 202 Accepted with the durable
193// operation identity and a stable running status, so the client can recover
194// completion through GET operations/by-key. The claim and its journal are
195// already persisted when the handler answers.
196func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
197 appSvc := inmocks.NewMockAppService(t)
198 handler := appsTestHandler(t, appSvc)
199
200 running := &domain.AppOperation{
201 Op: "op-run", Kind: "deploy", App: "blog", InputRevision: "rev-1",
202 Steps: []domain.AppOperationStep{
203 {ID: "preflight", State: domain.AppStepSucceeded},
204 {ID: "service.web.replace", State: domain.AppStepPending},
205 },
206 }
207 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, nil).Once()
208 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
209
210 srv := newScopedTestServer(t, handler, "admin:apps:write")
211 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
212 require.Equal(t, http.StatusAccepted, status)
213
214 var resp dto.AppDeployResponse
215 require.NoError(t, json.Unmarshal(body, &resp))
216 assert.Equal(t, "op-run", resp.Op)
217 assert.Equal(t, "running", resp.Status)
218 assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
219 require.Contains(t, resp.Services, "web")
220 assert.Equal(t, "pending", resp.Services["web"].Result)
221}
222
223// TestHandler_AppDeploy_TerminalReplayReturnsOK proves a duplicate idempotency
224// key whose stored operation already reached a successful terminal outcome
225// replays with 200 and the stored status instead of 202.
226func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
227 appSvc := inmocks.NewMockAppService(t)
228 handler := appsTestHandler(t, appSvc)
229
230 terminal := &domain.AppOperation{
231 Op: "op-done", Kind: "deploy", App: "blog", InputRevision: "rev-1",
232 Outcome: domain.AppOutcomeSuccess,
233 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
234 }
235 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(terminal, nil).Once()
236 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
237
238 srv := newScopedTestServer(t, handler, "admin:apps:write")
239 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
240 require.Equal(t, http.StatusOK, status)
241
242 var resp dto.AppDeployResponse
243 require.NoError(t, json.Unmarshal(body, &resp))
244 assert.Equal(t, "op-done", resp.Op)
245 assert.Equal(t, "success", resp.Status)
246 assert.Equal(t, "success", resp.Outcome)
247}
248
249// TestHandler_AppDeploy_TerminalFailureReplayIsVisible proves a replay of a
250// failed operation keeps its conflict mapping while still surfacing the
251// stored failure and per-service result, never a 202.
252func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
253 appSvc := inmocks.NewMockAppService(t)
254 handler := appsTestHandler(t, appSvc)
255
256 failed := &domain.AppOperation{
257 Op: "op-fail", Kind: "deploy", App: "blog", InputRevision: "rev-1",
258 Outcome: domain.AppOutcomeFailed,
259 Steps: []domain.AppOperationStep{{
260 ID: "service.web.replace", State: domain.AppStepFailed, Error: "boom",
261 }},
262 }
263 replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
264 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(failed, replayErr).Once()
265 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
266
267 srv := newScopedTestServer(t, handler, "admin:apps:write")
268 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
269 require.Equal(t, http.StatusConflict, status)
270
271 var resp dto.AppDeployResponse
272 require.NoError(t, json.Unmarshal(body, &resp))
273 assert.Equal(t, "op-fail", resp.Op)
274 assert.Equal(t, "failed", resp.Status)
275 assert.Equal(t, "failed", resp.Outcome)
276 require.Contains(t, resp.Services, "web")
277 assert.Equal(t, "failed", resp.Services["web"].Result)
278 assert.Equal(t, "boom", resp.Services["web"].Error)
279}
280
281// assertRunningReplayJournal pins the replay contract shared by a running
282// replay before and after any service step has started: 409, the operation
283// journal DTO with a running status and no terminal outcome, and never the
284// mapped preflight error envelope. The top-level key set is asserted so both
285// cases are proven to share one wire shape.
286func assertRunningReplayJournal(t *testing.T, status int, body []byte, opID, lastStep string) {
287 t.Helper()
288 require.Equal(t, http.StatusConflict, status)
289
290 var shape map[string]json.RawMessage
291 require.NoError(t, json.Unmarshal(body, &shape))
292 _, hasError := shape["error"]
293 assert.False(t, hasError, "a running replay must not degrade to the preflight error envelope")
294 keys := make([]string, 0, len(shape))
295 for key := range shape {
296 keys = append(keys, key)
297 }
298 sort.Strings(keys)
299 assert.Equal(t,
300 []string{"app", "effective", "op", "outcome", "retained", "revision", "services", "status", "steps"},
301 keys, "a running replay always returns the operation journal shape")
302
303 var resp dto.AppDeployResponse
304 require.NoError(t, json.Unmarshal(body, &resp))
305 assert.Equal(t, opID, resp.Op)
306 assert.Equal(t, "running", resp.Status)
307 assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
308 found := false
309 for _, step := range resp.Steps {
310 if step.ID == lastStep {
311 found = true
312 assert.Equal(t, domain.AppStepPending, step.State)
313 }
314 }
315 assert.True(t, found, "the journal must expose step %q", lastStep)
316}
317
318// TestHandler_AppDeploy_RunningReplayBeforeServiceSteps proves a same-key
319// replay of an operation still in flight answers 409 with the stored journal
320// even before any service step has started. A non-terminal claim is a running
321// journal, not a preflight failure, so the mapped error envelope never
322// replaces it.
323func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
324 appSvc := inmocks.NewMockAppService(t)
325 handler := appsTestHandler(t, appSvc)
326
327 running := &domain.AppOperation{
328 Op: "op-run-pre", Kind: "deploy", App: "blog", InputRevision: "rev-1",
329 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
330 }
331 replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
332 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
333 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
334
335 srv := newScopedTestServer(t, handler, "admin:apps:write")
336 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
337
338 assertRunningReplayJournal(t, status, body, "op-run-pre", "preflight")
339}
340
341// TestHandler_AppDeploy_RunningReplayAfterServiceSteps is the after-steps
342// twin: the same running replay, now with a pending service step, must answer
343// the identical 409 journal shape. Only the journal contents may differ from
344// the before-steps case, never the envelope.
345func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
346 appSvc := inmocks.NewMockAppService(t)
347 handler := appsTestHandler(t, appSvc)
348
349 running := &domain.AppOperation{
350 Op: "op-run-post", Kind: "deploy", App: "blog", InputRevision: "rev-1",
351 Steps: []domain.AppOperationStep{
352 {ID: "preflight", State: domain.AppStepSucceeded},
353 {ID: "service.web.replace", State: domain.AppStepPending},
354 },
355 }
356 replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
357 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
358 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
359
360 srv := newScopedTestServer(t, handler, "admin:apps:write")
361 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
362
363 assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
364}
365
366func TestHandler_AppDeploy_ServiceScopeRequiredIs400(t *testing.T) {
367 appSvc := inmocks.NewMockAppService(t)
368 handler := appsTestHandler(t, appSvc)
369 scopeErr := fmt.Errorf("app blog has several services (db, web): pass --service NAME or --all: %w", domain.ErrAppServiceScope)
370 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(nil, scopeErr).Once()
371
372 srv := newScopedTestServer(t, handler, "admin:apps:write")
373 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
374 require.Equal(t, http.StatusBadRequest, status)
375
376 var resp dto.AppError
377 require.NoError(t, json.Unmarshal(body, &resp))
378 assert.Equal(t, "service-scope-required", resp.Error)
379 assert.Contains(t, resp.Message, "(db, web)")
380}
381
382func TestHandler_AppDeploy_PassesAll(t *testing.T) {
383 appSvc := inmocks.NewMockAppService(t)
384 handler := appsTestHandler(t, appSvc)
385 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
386
387 srv := newScopedTestServer(t, handler, "admin:apps:write")
388 status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{All: true})
389 require.Equal(t, http.StatusConflict, status)
390}
391
392func TestHandler_AppRestart_PassesServiceAndAll(t *testing.T) {
393 appSvc := inmocks.NewMockAppService(t)
394 handler := appsTestHandler(t, appSvc)
395 appSvc.EXPECT().Restart(mock.Anything, "blog", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
396 appSvc.EXPECT().Restart(mock.Anything, "blog", "web", false, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
397
398 srv := newScopedTestServer(t, handler, "admin:apps:write")
399 status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?all=true", nil)
400 require.Equal(t, http.StatusConflict, status)
401 status, _ = appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?service=web", nil)
402 require.Equal(t, http.StatusConflict, status)
403}
404
405// TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
406// validation contract: a deploy without a valid key never reaches the
407// service.
408func TestHandler_AppDeploy_RejectsMissingIdempotencyKey(t *testing.T) {
409 appSvc := inmocks.NewMockAppService(t)
410 handler := appsTestHandler(t, appSvc)
411
412 srv := newScopedTestServer(t, handler, "admin:apps:write")
413 req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/apps/blog/deploy", nil)
414 require.NoError(t, err)
415 resp, err := srv.Client().Do(req)
416 require.NoError(t, err)
417 defer func() { require.NoError(t, resp.Body.Close()) }()
418
419 require.Equal(t, http.StatusBadRequest, resp.StatusCode)
420}
421
422func TestHandler_AppList_RequiresScope(t *testing.T) {
423 appSvc := inmocks.NewMockAppService(t)
424 handler := appsTestHandler(t, appSvc)
425
426 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:status:read")
427 assert.Equal(t, http.StatusForbidden, rec.Code)
428
429 appSvc.EXPECT().List(mock.Anything).Return(nil, nil).Once()
430 rec = appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
431 assert.Equal(t, http.StatusOK, rec.Code)
432}
433
434func TestHandler_AppSecretsSet_Forwards(t *testing.T) {
435 appSvc := inmocks.NewMockAppService(t)
436 handler := appsTestHandler(t, appSvc)
437
438 appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "web", map[string]string{"DATABASE_URL": "v"}).Return(nil).Once()
439 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set",
440 dto.AppSecretSetRequest{Service: "web", Secrets: map[string]string{"DATABASE_URL": "v"}}, "admin:apps:write")
441 assert.Equal(t, http.StatusOK, rec.Code)
442}
443
444func TestHandler_AppSecretsSet_MissingRegistrationHasNoDeployHint(t *testing.T) {
445 appSvc := inmocks.NewMockAppService(t)
446 handler := appsTestHandler(t, appSvc)
447
448 appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "runner", map[string]string{"K": "v"}).
449 Return(fmt.Errorf("apps: service not applied: %w", domain.ErrAppSecretMissing)).Once()
450 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set",
451 dto.AppSecretSetRequest{Service: "runner", Secrets: map[string]string{"K": "v"}}, "admin:apps:write")
452 assert.Equal(t, http.StatusBadRequest, rec.Code)
453 assert.Contains(t, rec.Body.String(), "secret-missing")
454 assert.NotContains(t, rec.Body.String(), "redeploy")
455}
456
457// TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle
458// mutation of a name with no app identity is a 404, not a 500 or a silent
459// success.
460func TestHandler_AppLifecycle_UnknownAppIsNotFound(t *testing.T) {
461 appSvc := inmocks.NewMockAppService(t)
462 handler := appsTestHandler(t, appSvc)
463
464 appSvc.EXPECT().Stop(mock.Anything, "ghost", "test-operation-key").
465 Return(nil, fmt.Errorf("deployment: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
466
467 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/ghost/stop", nil, "admin:apps:write")
468 require.Equal(t, http.StatusNotFound, rec.Code)
469 var envelope dto.AppError
470 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
471 assert.Equal(t, "app-not-found", envelope.Error)
472}
473
474// TestHandler_AppLifecycle_ReplayConflictCarriesJournal proves a replayed
475// key that never finished returns 409 with the stored journal, so the
476// caller can inspect what was recorded instead of guessing.
477func TestHandler_AppLifecycle_ReplayConflictCarriesJournal(t *testing.T) {
478 appSvc := inmocks.NewMockAppService(t)
479 handler := appsTestHandler(t, appSvc)
480
481 op := &domain.AppOperation{
482 Op: "test-operation-key", Kind: "remove", App: "blog",
483 Steps: []domain.AppOperationStep{{ID: "service.web.remove", State: domain.AppStepPending}},
484 }
485 appSvc.EXPECT().Remove(mock.Anything, "blog", "test-operation-key").
486 Return(op, domain.ErrAppStateConflict).Once()
487 // A removal that already retired the incarnation has no app read
488 // model left, so the response carries the journal alone.
489 appSvc.EXPECT().Show(mock.Anything, "blog").
490 Return(nil, domain.ErrAppNotFound).Once()
491
492 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/remove", nil, "admin:apps:write")
493 require.Equal(t, http.StatusConflict, rec.Code)
494 var resp dto.AppDeployResponse
495 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
496 assert.Equal(t, "test-operation-key", resp.Op)
497 assert.Nil(t, resp.Effective)
498 assert.Nil(t, resp.Retained)
499}
500
501func TestHandler_AppShow_MapsReadModel(t *testing.T) {
502 appSvc := inmocks.NewMockAppService(t)
503 handler := appsTestHandler(t, appSvc)
504
505 started := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
506 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
507 App: "blog",
508 DesiredRevision: "rev-2",
509 DesiredStatus: "pending",
510 Pending: true,
511 Converged: false,
512 ConvergedRevision: "rev-1",
513 Stopped: true,
514 Services: map[string]in.AppServiceView{
515 "web": {EffectiveRevision: "rev-1", Digest: "sha256:x", Container: "ctr-1", RestartUnsafe: true},
516 },
517 Retained: in.AppRetainedView{
518 Volumes: []string{"gordon-blog--web--vol--data"},
519 Secrets: []string{"gordon/apps/app-1/web/database-url"},
520 Images: []string{"registry.example.com/blog/web:1.4.2"},
521 },
522 LastOp: "op-9",
523 LastOpKind: "deploy",
524 LastOutcome: "partial",
525 LastOpStartedAt: started,
526 }, nil).Once()
527
528 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog", nil, "admin:apps:read")
529 require.Equal(t, http.StatusOK, rec.Code)
530 var resp dto.AppShowResponse
531 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
532
533 assert.Equal(t, "rev-2", resp.Desired.Revision)
534 assert.True(t, resp.Desired.Pending)
535 assert.False(t, resp.Active.Converged)
536 assert.Equal(t, "rev-1", resp.Active.ConvergedRevision)
537 assert.True(t, resp.Intent.Stopped)
538 assert.True(t, resp.Active.Services["web"].RestartUnsafe, "restart safety must reach the wire")
539 assert.Equal(t, "ctr-1", resp.Active.Services["web"].Container)
540 assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
541 assert.Equal(t, []string{"registry.example.com/blog/web:1.4.2"}, resp.Retained.Images)
542 require.NotNil(t, resp.LastOp)
543 assert.Equal(t, "op-9", resp.LastOp.Op)
544 assert.Equal(t, "deploy", resp.LastOp.Kind)
545 assert.Equal(t, "partial", resp.LastOp.Outcome)
546 assert.Equal(t, started, resp.LastOp.StartedAt)
547}
548
549// TestHandler_AppShow_UnknownAppIsNotFound proves showing a name with no
550// app identity is 404, not an empty success.
551func TestHandler_AppShow_UnknownAppIsNotFound(t *testing.T) {
552 appSvc := inmocks.NewMockAppService(t)
553 handler := appsTestHandler(t, appSvc)
554
555 appSvc.EXPECT().Show(mock.Anything, "ghost").
556 Return(nil, fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
557
558 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost", nil, "admin:apps:read")
559 require.Equal(t, http.StatusNotFound, rec.Code)
560 var envelope dto.AppError
561 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
562 assert.Equal(t, "app-not-found", envelope.Error)
563}
564
565func TestHandler_AppList_MapsReadModel(t *testing.T) {
566 appSvc := inmocks.NewMockAppService(t)
567 handler := appsTestHandler(t, appSvc)
568
569 appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{
570 App: "blog", Desired: "rev-2", DesiredStatus: "pending",
571 Active: "rev-1", Converged: false, Pending: true, LastOutcome: "partial",
572 }}, nil).Once()
573
574 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
575 require.Equal(t, http.StatusOK, rec.Code)
576 var envelope struct {
577 Apps []dto.AppSummaryDTO `json:"apps"`
578 }
579 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
580 require.Len(t, envelope.Apps, 1)
581 assert.Equal(t, "rev-2", envelope.Apps[0].Desired)
582 assert.Equal(t, "pending", envelope.Apps[0].DesiredStatus)
583 assert.True(t, envelope.Apps[0].Pending)
584 assert.Equal(t, "partial", envelope.Apps[0].LastOutcome)
585}
586
587func TestHandler_AppOpLookup_Recovers(t *testing.T) {
588 appSvc := inmocks.NewMockAppService(t)
589 handler := appsTestHandler(t, appSvc)
590
591 op := &domain.AppOperation{Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "success"}
592 appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Once()
593 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
594 require.Equal(t, http.StatusOK, rec.Code)
595 var resp dto.AppDeployResponse
596 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
597 assert.Equal(t, "op-9", resp.Op)
598}
599
600// TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope proves failure
601// diagnostics are returned only to callers holding the logs read scope; an
602// apps-read actor receives the stable error alone.
603func TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope(t *testing.T) {
604 appSvc := inmocks.NewMockAppService(t)
605 handler := appsTestHandler(t, appSvc)
606
607 op := &domain.AppOperation{
608 Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "failed",
609 Steps: []domain.AppOperationStep{{
610 ID: "service.web.replace", State: domain.AppStepFailed,
611 Error: "deployment: readiness failed", Diagnostics: []string{"SECRET=supersecret"},
612 }},
613 }
614 appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Twice()
615
616 appsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
617 require.Equal(t, http.StatusOK, appsRead.Code)
618 assert.NotContains(t, appsRead.Body.String(), "supersecret", "apps-read must not receive application diagnostics")
619 assert.NotContains(t, appsRead.Body.String(), "diagnostics")
620
621 logsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9",
622 nil, "admin:apps:read", "admin:logs:read")
623 require.Equal(t, http.StatusOK, logsRead.Code)
624 var resp dto.AppDeployResponse
625 require.NoError(t, json.Unmarshal(logsRead.Body.Bytes(), &resp))
626 require.Len(t, resp.Steps, 1)
627 require.Len(t, resp.Steps[0].Diagnostics, 1)
628 assert.Equal(t, "SECRET=supersecret", resp.Steps[0].Diagnostics[0])
629}
630
631// TestHandler_AppApply_DryRunOmitsRevision proves a dry-run apply never
632// reports a revision: nothing was persisted, so resulting_revision is
633// omitted instead of echoing the app name.
634func TestHandler_AppApply_DryRunOmitsRevision(t *testing.T) {
635 appSvc := inmocks.NewMockAppService(t)
636 handler := appsTestHandler(t, appSvc)
637
638 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, true).Return(
639 nil,
640 &in.AppDryRunResult{App: "blog", Valid: true},
641 nil,
642 ).Once()
643
644 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
645 dto.AppApplyRequest{ManifestTOML: validAppManifest, DryRun: true}, "admin:apps:write")
646 require.Equal(t, http.StatusOK, rec.Code)
647 var resp dto.AppApplyResponse
648 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
649 assert.Equal(t, "blog", resp.App)
650 assert.Empty(t, resp.ResultingRevision)
651 assert.NotContains(t, rec.Body.String(), "resulting_revision")
652}
653
654func TestHandler_AppSecretsList_MissingAppIs404(t *testing.T) {
655 appSvc := inmocks.NewMockAppService(t)
656 handler := appsTestHandler(t, appSvc)
657
658 appSvc.EXPECT().ListSecrets(mock.Anything, "ghost", "").Return(
659 nil,
660 fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound),
661 ).Once()
662
663 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost/secrets", nil, "admin:apps:read")
664 require.Equal(t, http.StatusNotFound, rec.Code)
665 assert.Contains(t, rec.Body.String(), "app-not-found")
666}
667
668func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
669 appSvc := inmocks.NewMockAppService(t)
670 handler := appsTestHandler(t, appSvc)
671
672 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
673 nil, nil,
674 fmt.Errorf("%w: app %q service %q device %q: refused by administrative device policy", domain.ErrDevicePolicy, "blog", "web", "test_gpu"),
675 ).Once()
676
677 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
678 dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
679 assert.Equal(t, http.StatusBadRequest, rec.Code)
680 var envelope dto.AppError
681 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
682 assert.Equal(t, "device-policy-violation", envelope.Error)
683}
684
685// TestSendAppOpError_MapsRuntimeGuard proves a runtime guard error returned
686// synchronously maps to the stable runtime-guard-violation envelope. Async
687// deploys report the guard in their operation journal instead.
688func TestSendAppOpError_MapsRuntimeGuard(t *testing.T) {
689 handler := appsTestHandler(t, inmocks.NewMockAppService(t))
690 rec := httptest.NewRecorder()
691
692 handler.sendAppOpError(rec, fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"))
693
694 assert.Equal(t, http.StatusBadRequest, rec.Code)
695 var envelope dto.AppError
696 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
697 assert.Equal(t, "runtime-guard-violation", envelope.Error)
698}
699
700func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
701 appSvc := inmocks.NewMockAppService(t)
702 handler := appsTestHandler(t, appSvc)
703
704 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
705 nil, nil,
706 fmt.Errorf("%w: podman 4.9.5 below minimum 5.4", domain.ErrRuntimeUnsupported),
707 ).Once()
708
709 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
710 dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
711 assert.Equal(t, http.StatusBadRequest, rec.Code)
712 var envelope dto.AppError
713 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
714 assert.Equal(t, "runtime-unsupported", envelope.Error)
715}