handler_apps_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close