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 },
132 }
133 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
134 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
135 App: "blog", Converged: true, ConvergedRevision: "rev-1",
136 Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},
137 Retained: in.AppRetainedView{Volumes: []string{"gordon-blog--web--vol--data"}, Secrets: []string{"gordon/apps/app-1/web/database-url"}},
138 }, nil).Once()
139
140 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
141 dto.AppDeployRequest{}, "admin:apps:write")
142 require.Equal(t, http.StatusOK, rec.Code)
143 var resp dto.AppDeployResponse
144 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
145 assert.Equal(t, "op-1", resp.Op)
146 assert.Equal(t, "success", resp.Outcome)
147 require.Contains(t, resp.Services, "web")
148 assert.Equal(t, "deployed", resp.Services["web"].Result)
149 assert.Equal(t, "c-new", resp.Services["web"].After)
150 // Effective and retained state comes from the app read model, never
151 // from the journal outcome.
152 require.NotNil(t, resp.Effective)
153 assert.True(t, resp.Effective.Converged)
154 assert.Equal(t, "rev-1", resp.Effective.Services["web"])
155 require.NotNil(t, resp.Retained)
156 assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
157}
158
159// TestHandler_AppDeploy_MapsCleanupWarnings proves bounded leftovers of a
160// successful mutation reach the wire instead of being dropped.
161func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
162 appSvc := inmocks.NewMockAppService(t)
163 handler := appsTestHandler(t, appSvc)
164
165 op := &domain.AppOperation{
166 Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
167 Outcome: domain.AppOutcomeSuccess,
168 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
169 Warnings: []domain.AppOperationWarning{{
170 Service: "web", Leftover: "ctr-old", Detail: "remove: still present",
171 }},
172 }
173 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
174 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()
175
176 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
177 dto.AppDeployRequest{}, "admin:apps:write")
178 require.Equal(t, http.StatusOK, rec.Code)
179 var resp dto.AppDeployResponse
180 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
181 require.Len(t, resp.CleanupWarnings, 1)
182 assert.Equal(t, "web", resp.CleanupWarnings[0].Service)
183 assert.Equal(t, "ctr-old", resp.CleanupWarnings[0].Leftover)
184 assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)
185}
186
187// TestHandler_AppDeploy_RunningReturns202 proves a newly owned deploy whose
188// effects are still executing answers 202 Accepted with the durable
189// operation identity and a stable running status, so the client can recover
190// completion through GET operations/by-key. The claim and its journal are
191// already persisted when the handler answers.
192func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
193 appSvc := inmocks.NewMockAppService(t)
194 handler := appsTestHandler(t, appSvc)
195
196 running := &domain.AppOperation{
197 Op: "op-run", Kind: "deploy", App: "blog", InputRevision: "rev-1",
198 Steps: []domain.AppOperationStep{
199 {ID: "preflight", State: domain.AppStepSucceeded},
200 {ID: "service.web.replace", State: domain.AppStepPending},
201 },
202 }
203 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, nil).Once()
204 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
205
206 srv := newScopedTestServer(t, handler, "admin:apps:write")
207 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
208 require.Equal(t, http.StatusAccepted, status)
209
210 var resp dto.AppDeployResponse
211 require.NoError(t, json.Unmarshal(body, &resp))
212 assert.Equal(t, "op-run", resp.Op)
213 assert.Equal(t, "running", resp.Status)
214 assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
215 require.Contains(t, resp.Services, "web")
216 assert.Equal(t, "pending", resp.Services["web"].Result)
217}
218
219// TestHandler_AppDeploy_TerminalReplayReturnsOK proves a duplicate idempotency
220// key whose stored operation already reached a successful terminal outcome
221// replays with 200 and the stored status instead of 202.
222func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
223 appSvc := inmocks.NewMockAppService(t)
224 handler := appsTestHandler(t, appSvc)
225
226 terminal := &domain.AppOperation{
227 Op: "op-done", Kind: "deploy", App: "blog", InputRevision: "rev-1",
228 Outcome: domain.AppOutcomeSuccess,
229 Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
230 }
231 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(terminal, nil).Once()
232 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
233
234 srv := newScopedTestServer(t, handler, "admin:apps:write")
235 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
236 require.Equal(t, http.StatusOK, status)
237
238 var resp dto.AppDeployResponse
239 require.NoError(t, json.Unmarshal(body, &resp))
240 assert.Equal(t, "op-done", resp.Op)
241 assert.Equal(t, "success", resp.Status)
242 assert.Equal(t, "success", resp.Outcome)
243}
244
245// TestHandler_AppDeploy_TerminalFailureReplayIsVisible proves a replay of a
246// failed operation keeps its conflict mapping while still surfacing the
247// stored failure and per-service result, never a 202.
248func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
249 appSvc := inmocks.NewMockAppService(t)
250 handler := appsTestHandler(t, appSvc)
251
252 failed := &domain.AppOperation{
253 Op: "op-fail", Kind: "deploy", App: "blog", InputRevision: "rev-1",
254 Outcome: domain.AppOutcomeFailed,
255 Steps: []domain.AppOperationStep{{
256 ID: "service.web.replace", State: domain.AppStepFailed, Error: "boom",
257 }},
258 }
259 replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
260 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(failed, replayErr).Once()
261 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
262
263 srv := newScopedTestServer(t, handler, "admin:apps:write")
264 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
265 require.Equal(t, http.StatusConflict, status)
266
267 var resp dto.AppDeployResponse
268 require.NoError(t, json.Unmarshal(body, &resp))
269 assert.Equal(t, "op-fail", resp.Op)
270 assert.Equal(t, "failed", resp.Status)
271 assert.Equal(t, "failed", resp.Outcome)
272 require.Contains(t, resp.Services, "web")
273 assert.Equal(t, "failed", resp.Services["web"].Result)
274 assert.Equal(t, "boom", resp.Services["web"].Error)
275}
276
277// assertRunningReplayJournal pins the replay contract shared by a running
278// replay before and after any service step has started: 409, the operation
279// journal DTO with a running status and no terminal outcome, and never the
280// mapped preflight error envelope. The top-level key set is asserted so both
281// cases are proven to share one wire shape.
282func assertRunningReplayJournal(t *testing.T, status int, body []byte, opID, lastStep string) {
283 t.Helper()
284 require.Equal(t, http.StatusConflict, status)
285
286 var shape map[string]json.RawMessage
287 require.NoError(t, json.Unmarshal(body, &shape))
288 _, hasError := shape["error"]
289 assert.False(t, hasError, "a running replay must not degrade to the preflight error envelope")
290 keys := make([]string, 0, len(shape))
291 for key := range shape {
292 keys = append(keys, key)
293 }
294 sort.Strings(keys)
295 assert.Equal(t,
296 []string{"app", "effective", "op", "outcome", "retained", "revision", "services", "status", "steps"},
297 keys, "a running replay always returns the operation journal shape")
298
299 var resp dto.AppDeployResponse
300 require.NoError(t, json.Unmarshal(body, &resp))
301 assert.Equal(t, opID, resp.Op)
302 assert.Equal(t, "running", resp.Status)
303 assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
304 found := false
305 for _, step := range resp.Steps {
306 if step.ID == lastStep {
307 found = true
308 assert.Equal(t, domain.AppStepPending, step.State)
309 }
310 }
311 assert.True(t, found, "the journal must expose step %q", lastStep)
312}
313
314// TestHandler_AppDeploy_RunningReplayBeforeServiceSteps proves a same-key
315// replay of an operation still in flight answers 409 with the stored journal
316// even before any service step has started. A non-terminal claim is a running
317// journal, not a preflight failure, so the mapped error envelope never
318// replaces it.
319func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
320 appSvc := inmocks.NewMockAppService(t)
321 handler := appsTestHandler(t, appSvc)
322
323 running := &domain.AppOperation{
324 Op: "op-run-pre", Kind: "deploy", App: "blog", InputRevision: "rev-1",
325 Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
326 }
327 replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
328 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
329 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
330
331 srv := newScopedTestServer(t, handler, "admin:apps:write")
332 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
333
334 assertRunningReplayJournal(t, status, body, "op-run-pre", "preflight")
335}
336
337// TestHandler_AppDeploy_RunningReplayAfterServiceSteps is the after-steps
338// twin: the same running replay, now with a pending service step, must answer
339// the identical 409 journal shape. Only the journal contents may differ from
340// the before-steps case, never the envelope.
341func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
342 appSvc := inmocks.NewMockAppService(t)
343 handler := appsTestHandler(t, appSvc)
344
345 running := &domain.AppOperation{
346 Op: "op-run-post", Kind: "deploy", App: "blog", InputRevision: "rev-1",
347 Steps: []domain.AppOperationStep{
348 {ID: "preflight", State: domain.AppStepSucceeded},
349 {ID: "service.web.replace", State: domain.AppStepPending},
350 },
351 }
352 replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
353 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
354 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
355
356 srv := newScopedTestServer(t, handler, "admin:apps:write")
357 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
358
359 assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
360}
361
362func TestHandler_AppDeploy_ServiceScopeRequiredIs400(t *testing.T) {
363 appSvc := inmocks.NewMockAppService(t)
364 handler := appsTestHandler(t, appSvc)
365 scopeErr := fmt.Errorf("app blog has several services (db, web): pass --service NAME or --all: %w", domain.ErrAppServiceScope)
366 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(nil, scopeErr).Once()
367
368 srv := newScopedTestServer(t, handler, "admin:apps:write")
369 status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
370 require.Equal(t, http.StatusBadRequest, status)
371
372 var resp dto.AppError
373 require.NoError(t, json.Unmarshal(body, &resp))
374 assert.Equal(t, "service-scope-required", resp.Error)
375 assert.Contains(t, resp.Message, "(db, web)")
376}
377
378func TestHandler_AppDeploy_PassesAll(t *testing.T) {
379 appSvc := inmocks.NewMockAppService(t)
380 handler := appsTestHandler(t, appSvc)
381 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
382
383 srv := newScopedTestServer(t, handler, "admin:apps:write")
384 status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{All: true})
385 require.Equal(t, http.StatusConflict, status)
386}
387
388func TestHandler_AppRestart_PassesServiceAndAll(t *testing.T) {
389 appSvc := inmocks.NewMockAppService(t)
390 handler := appsTestHandler(t, appSvc)
391 appSvc.EXPECT().Restart(mock.Anything, "blog", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
392 appSvc.EXPECT().Restart(mock.Anything, "blog", "web", false, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
393
394 srv := newScopedTestServer(t, handler, "admin:apps:write")
395 status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?all=true", nil)
396 require.Equal(t, http.StatusConflict, status)
397 status, _ = appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?service=web", nil)
398 require.Equal(t, http.StatusConflict, status)
399}
400
401// TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
402// validation contract: a deploy without a valid key never reaches the
403// service.
404func TestHandler_AppDeploy_RejectsMissingIdempotencyKey(t *testing.T) {
405 appSvc := inmocks.NewMockAppService(t)
406 handler := appsTestHandler(t, appSvc)
407
408 srv := newScopedTestServer(t, handler, "admin:apps:write")
409 req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/apps/blog/deploy", nil)
410 require.NoError(t, err)
411 resp, err := srv.Client().Do(req)
412 require.NoError(t, err)
413 defer func() { require.NoError(t, resp.Body.Close()) }()
414
415 require.Equal(t, http.StatusBadRequest, resp.StatusCode)
416}
417
418func TestHandler_AppList_RequiresScope(t *testing.T) {
419 appSvc := inmocks.NewMockAppService(t)
420 handler := appsTestHandler(t, appSvc)
421
422 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:status:read")
423 assert.Equal(t, http.StatusForbidden, rec.Code)
424
425 appSvc.EXPECT().List(mock.Anything).Return(nil, nil).Once()
426 rec = appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
427 assert.Equal(t, http.StatusOK, rec.Code)
428}
429
430func TestHandler_AppSecretsSet_Forwards(t *testing.T) {
431 appSvc := inmocks.NewMockAppService(t)
432 handler := appsTestHandler(t, appSvc)
433
434 appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "web", map[string]string{"DATABASE_URL": "v"}).Return(nil).Once()
435 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set",
436 dto.AppSecretSetRequest{Service: "web", Secrets: map[string]string{"DATABASE_URL": "v"}}, "admin:apps:write")
437 assert.Equal(t, http.StatusOK, rec.Code)
438}
439
440// TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle
441// mutation of a name with no app identity is a 404, not a 500 or a silent
442// success.
443func TestHandler_AppLifecycle_UnknownAppIsNotFound(t *testing.T) {
444 appSvc := inmocks.NewMockAppService(t)
445 handler := appsTestHandler(t, appSvc)
446
447 appSvc.EXPECT().Stop(mock.Anything, "ghost", "test-operation-key").
448 Return(nil, fmt.Errorf("deployment: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
449
450 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/ghost/stop", nil, "admin:apps:write")
451 require.Equal(t, http.StatusNotFound, rec.Code)
452 var envelope dto.AppError
453 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
454 assert.Equal(t, "app-not-found", envelope.Error)
455}
456
457// TestHandler_AppLifecycle_ReplayConflictCarriesJournal proves a replayed
458// key that never finished returns 409 with the stored journal, so the
459// caller can inspect what was recorded instead of guessing.
460func TestHandler_AppLifecycle_ReplayConflictCarriesJournal(t *testing.T) {
461 appSvc := inmocks.NewMockAppService(t)
462 handler := appsTestHandler(t, appSvc)
463
464 op := &domain.AppOperation{
465 Op: "test-operation-key", Kind: "remove", App: "blog",
466 Steps: []domain.AppOperationStep{{ID: "service.web.remove", State: domain.AppStepPending}},
467 }
468 appSvc.EXPECT().Remove(mock.Anything, "blog", "test-operation-key").
469 Return(op, domain.ErrAppStateConflict).Once()
470 // A removal that already retired the incarnation has no app read
471 // model left, so the response carries the journal alone.
472 appSvc.EXPECT().Show(mock.Anything, "blog").
473 Return(nil, domain.ErrAppNotFound).Once()
474
475 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/remove", nil, "admin:apps:write")
476 require.Equal(t, http.StatusConflict, rec.Code)
477 var resp dto.AppDeployResponse
478 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
479 assert.Equal(t, "test-operation-key", resp.Op)
480 assert.Nil(t, resp.Effective)
481 assert.Nil(t, resp.Retained)
482}
483
484func TestHandler_AppShow_MapsReadModel(t *testing.T) {
485 appSvc := inmocks.NewMockAppService(t)
486 handler := appsTestHandler(t, appSvc)
487
488 started := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
489 appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
490 App: "blog",
491 DesiredRevision: "rev-2",
492 DesiredStatus: "pending",
493 Pending: true,
494 Converged: false,
495 ConvergedRevision: "rev-1",
496 Stopped: true,
497 Services: map[string]in.AppServiceView{
498 "web": {EffectiveRevision: "rev-1", Digest: "sha256:x", Container: "ctr-1", RestartUnsafe: true},
499 },
500 Retained: in.AppRetainedView{
501 Volumes: []string{"gordon-blog--web--vol--data"},
502 Secrets: []string{"gordon/apps/app-1/web/database-url"},
503 Images: []string{"registry.example.com/blog/web:1.4.2"},
504 },
505 LastOp: "op-9",
506 LastOpKind: "deploy",
507 LastOutcome: "partial",
508 LastOpStartedAt: started,
509 }, nil).Once()
510
511 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog", nil, "admin:apps:read")
512 require.Equal(t, http.StatusOK, rec.Code)
513 var resp dto.AppShowResponse
514 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
515
516 assert.Equal(t, "rev-2", resp.Desired.Revision)
517 assert.True(t, resp.Desired.Pending)
518 assert.False(t, resp.Active.Converged)
519 assert.Equal(t, "rev-1", resp.Active.ConvergedRevision)
520 assert.True(t, resp.Intent.Stopped)
521 assert.True(t, resp.Active.Services["web"].RestartUnsafe, "restart safety must reach the wire")
522 assert.Equal(t, "ctr-1", resp.Active.Services["web"].Container)
523 assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
524 assert.Equal(t, []string{"registry.example.com/blog/web:1.4.2"}, resp.Retained.Images)
525 require.NotNil(t, resp.LastOp)
526 assert.Equal(t, "op-9", resp.LastOp.Op)
527 assert.Equal(t, "deploy", resp.LastOp.Kind)
528 assert.Equal(t, "partial", resp.LastOp.Outcome)
529 assert.Equal(t, started, resp.LastOp.StartedAt)
530}
531
532// TestHandler_AppShow_UnknownAppIsNotFound proves showing a name with no
533// app identity is 404, not an empty success.
534func TestHandler_AppShow_UnknownAppIsNotFound(t *testing.T) {
535 appSvc := inmocks.NewMockAppService(t)
536 handler := appsTestHandler(t, appSvc)
537
538 appSvc.EXPECT().Show(mock.Anything, "ghost").
539 Return(nil, fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
540
541 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost", nil, "admin:apps:read")
542 require.Equal(t, http.StatusNotFound, rec.Code)
543 var envelope dto.AppError
544 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
545 assert.Equal(t, "app-not-found", envelope.Error)
546}
547
548func TestHandler_AppList_MapsReadModel(t *testing.T) {
549 appSvc := inmocks.NewMockAppService(t)
550 handler := appsTestHandler(t, appSvc)
551
552 appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{
553 App: "blog", Desired: "rev-2", DesiredStatus: "pending",
554 Active: "rev-1", Converged: false, Pending: true, LastOutcome: "partial",
555 }}, nil).Once()
556
557 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
558 require.Equal(t, http.StatusOK, rec.Code)
559 var envelope struct {
560 Apps []dto.AppSummaryDTO `json:"apps"`
561 }
562 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
563 require.Len(t, envelope.Apps, 1)
564 assert.Equal(t, "rev-2", envelope.Apps[0].Desired)
565 assert.Equal(t, "pending", envelope.Apps[0].DesiredStatus)
566 assert.True(t, envelope.Apps[0].Pending)
567 assert.Equal(t, "partial", envelope.Apps[0].LastOutcome)
568}
569
570func TestHandler_AppOpLookup_Recovers(t *testing.T) {
571 appSvc := inmocks.NewMockAppService(t)
572 handler := appsTestHandler(t, appSvc)
573
574 op := &domain.AppOperation{Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "success"}
575 appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Once()
576 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
577 require.Equal(t, http.StatusOK, rec.Code)
578 var resp dto.AppDeployResponse
579 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
580 assert.Equal(t, "op-9", resp.Op)
581}
582
583// TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope proves failure
584// diagnostics are returned only to callers holding the logs read scope; an
585// apps-read actor receives the stable error alone.
586func TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope(t *testing.T) {
587 appSvc := inmocks.NewMockAppService(t)
588 handler := appsTestHandler(t, appSvc)
589
590 op := &domain.AppOperation{
591 Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "failed",
592 Steps: []domain.AppOperationStep{{
593 ID: "service.web.replace", State: domain.AppStepFailed,
594 Error: "deployment: readiness failed", Diagnostics: []string{"SECRET=supersecret"},
595 }},
596 }
597 appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Twice()
598
599 appsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
600 require.Equal(t, http.StatusOK, appsRead.Code)
601 assert.NotContains(t, appsRead.Body.String(), "supersecret", "apps-read must not receive application diagnostics")
602 assert.NotContains(t, appsRead.Body.String(), "diagnostics")
603
604 logsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9",
605 nil, "admin:apps:read", "admin:logs:read")
606 require.Equal(t, http.StatusOK, logsRead.Code)
607 var resp dto.AppDeployResponse
608 require.NoError(t, json.Unmarshal(logsRead.Body.Bytes(), &resp))
609 require.Len(t, resp.Steps, 1)
610 require.Len(t, resp.Steps[0].Diagnostics, 1)
611 assert.Equal(t, "SECRET=supersecret", resp.Steps[0].Diagnostics[0])
612}
613
614// TestHandler_AppApply_DryRunOmitsRevision proves a dry-run apply never
615// reports a revision: nothing was persisted, so resulting_revision is
616// omitted instead of echoing the app name.
617func TestHandler_AppApply_DryRunOmitsRevision(t *testing.T) {
618 appSvc := inmocks.NewMockAppService(t)
619 handler := appsTestHandler(t, appSvc)
620
621 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, true).Return(
622 nil,
623 &in.AppDryRunResult{App: "blog", Valid: true},
624 nil,
625 ).Once()
626
627 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
628 dto.AppApplyRequest{ManifestTOML: validAppManifest, DryRun: true}, "admin:apps:write")
629 require.Equal(t, http.StatusOK, rec.Code)
630 var resp dto.AppApplyResponse
631 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
632 assert.Equal(t, "blog", resp.App)
633 assert.Empty(t, resp.ResultingRevision)
634 assert.NotContains(t, rec.Body.String(), "resulting_revision")
635}
636
637func TestHandler_AppSecretsList_MissingAppIs404(t *testing.T) {
638 appSvc := inmocks.NewMockAppService(t)
639 handler := appsTestHandler(t, appSvc)
640
641 appSvc.EXPECT().ListSecrets(mock.Anything, "ghost", "").Return(
642 nil,
643 fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound),
644 ).Once()
645
646 rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost/secrets", nil, "admin:apps:read")
647 require.Equal(t, http.StatusNotFound, rec.Code)
648 assert.Contains(t, rec.Body.String(), "app-not-found")
649}
650
651func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
652 appSvc := inmocks.NewMockAppService(t)
653 handler := appsTestHandler(t, appSvc)
654
655 appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
656 nil, nil,
657 fmt.Errorf("%w: app %q service %q device %q: refused by administrative device policy", domain.ErrDevicePolicy, "blog", "web", "test_gpu"),
658 ).Once()
659
660 rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
661 dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
662 assert.Equal(t, http.StatusBadRequest, rec.Code)
663 var envelope dto.AppError
664 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
665 assert.Equal(t, "device-policy-violation", envelope.Error)
666}
667
668func TestHandler_AppApply_MapsRuntimeUnsupported(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: podman 4.9.5 below minimum 5.4", domain.ErrRuntimeUnsupported),
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, "runtime-unsupported", envelope.Error)
683}