1package admin
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/adapters/dto"
16 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// TestHandler_ImagesPrune_ExposesPerCandidateVerdicts proves the wire
21// response carries the same plan shape for a dry run and an execution,
22// including the reasons protected and unknown candidates were skipped.
23func TestHandler_ImagesPrune_ExposesPerCandidateVerdicts(t *testing.T) {
24 plan := domain.PruneReport{
25 Applied: false,
26 Candidates: []domain.PruneCandidateReport{
27 {Kind: domain.PruneResourceRegistryTag, Ref: "app:v1", Verdict: domain.PruneVerdictEligible,
28 Reasons: []domain.PruneReason{domain.PruneReasonEligibleRetention}},
29 {Kind: domain.PruneResourceRuntimeImage, Ref: "sha256:active", Verdict: domain.PruneVerdictProtected,
30 Reasons: []domain.PruneReason{domain.PruneReasonProtectedContainerUse}},
31 {Kind: domain.PruneResourceRuntimeImage, Ref: "sha256:opaque", Verdict: domain.PruneVerdictUnknown,
32 Reasons: []domain.PruneReason{domain.PruneReasonUnknownProvenance}},
33 },
34 Gaps: []domain.InventoryGap{{
35 Source: domain.InventorySourceRuntimeContainers,
36 Reason: domain.PruneReasonUnknownContainerUse,
37 Detail: "list containers failed",
38 }},
39 Failures: []domain.PruneFailure{{
40 Kind: domain.PruneResourceRegistryTag, Ref: "app:v9", Err: "storage unavailable",
41 }},
42 }
43
44 var dryRunSeen bool
45 imageSvc := &stubImageService{
46 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
47 dryRunSeen = opts.DryRun
48 return domain.ImagePruneReport{Plan: plan}, nil
49 },
50 }
51
52 handler := newTestHandler(t, func(d *HandlerDeps) {
53 d.ImageSvc = imageSvc
54 })
55
56 rec := httptest.NewRecorder()
57 req := httptest.NewRequest(http.MethodPost, "/admin/images/prune", bytes.NewBufferString(`{"dry_run": true}`))
58 req = req.WithContext(ctxWithScopes("admin:config:write"))
59 handler.ServeHTTP(rec, req)
60
61 require.Equal(t, http.StatusOK, rec.Code)
62 assert.True(t, dryRunSeen, "the dry-run flag must reach the use case")
63
64 var resp dto.ImagePruneResponse
65 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
66 assert.False(t, resp.Plan.Applied)
67 assert.Equal(t, 1, resp.Plan.Eligible)
68 assert.Equal(t, 1, resp.Plan.Protected)
69 assert.Equal(t, 1, resp.Plan.Unknown)
70 require.Len(t, resp.Plan.Candidates, 3)
71 assert.Equal(t, "protected-container-use", resp.Plan.Candidates[1].Reasons[0])
72 require.Len(t, resp.Plan.Failures, 1)
73 assert.Equal(t, "storage unavailable", resp.Plan.Failures[0].Error)
74 require.Len(t, resp.Plan.Gaps, 1)
75 assert.Equal(t, "runtime-containers", resp.Plan.Gaps[0].Source)
76}
77
78// TestHandler_VolumesPrune_ExposesPerCandidateVerdicts proves the volume
79// endpoint returns the same shared plan shape, including a zero-deletion
80// success.
81func TestHandler_VolumesPrune_ExposesPerCandidateVerdicts(t *testing.T) {
82 report := &domain.VolumePruneReport{
83 Plan: domain.PruneReport{
84 Applied: true,
85 Candidates: []domain.PruneCandidateReport{
86 {Kind: domain.PruneResourceVolume, Ref: "gordon-shop--web--vol--data", Verdict: domain.PruneVerdictProtected,
87 Reasons: []domain.PruneReason{domain.PruneReasonProtectedOwnership}},
88 },
89 },
90 }
91
92 volumeSvc := inmocks.NewMockVolumeService(t)
93 volumeSvc.EXPECT().PruneVolumes(mock.Anything, false).Return(report, []*domain.VolumeInfo{}, nil).Once()
94 handler := newTestHandler(t, func(d *HandlerDeps) {
95 d.VolumeSvc = volumeSvc
96 })
97
98 rec := httptest.NewRecorder()
99 req := httptest.NewRequest(http.MethodPost, "/admin/volumes/prune", bytes.NewBufferString(`{"dry_run": false}`))
100 req = req.WithContext(ctxWithScopes("admin:volumes:write"))
101 handler.ServeHTTP(rec, req)
102
103 require.Equal(t, http.StatusOK, rec.Code)
104 var resp dto.VolumePruneResponse
105 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
106 assert.True(t, resp.Plan.Applied)
107 assert.Zero(t, resp.VolumesRemoved)
108 assert.Equal(t, 1, resp.Plan.Protected)
109 assert.Zero(t, resp.Plan.Eligible, "a protected candidate is not eligible")
110 require.Len(t, resp.Plan.Candidates, 1)
111 assert.Equal(t, "protected-ownership", resp.Plan.Candidates[0].Reasons[0])
112}