1package admin
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 in "github.com/bnema/gordon/internal/boundaries/in"
14 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18func localRequest(t *testing.T, handler http.Handler, method, target string) *httptest.ResponseRecorder {
19 t.Helper()
20 req := httptest.NewRequest(method, target, nil)
21 rec := httptest.NewRecorder()
22 handler.ServeHTTP(rec, req)
23 return rec
24}
25
26func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) {
27 appSvc := inmocks.NewMockAppService(t)
28 handler := appsTestHandler(t, appSvc)
29
30 appSvc.EXPECT().List(mock.MatchedBy(func(ctx context.Context) bool {
31 assert.Equal(t, LocalAuthoritySubject, GetSubject(ctx))
32 for _, access := range []struct{ resource, action string }{
33 {domain.AdminResourceApps, domain.AdminActionRead},
34 {domain.AdminResourceApps, domain.AdminActionWrite},
35 {domain.AdminResourceStatus, domain.AdminActionRead},
36 {domain.AdminResourceConfig, domain.AdminActionRead},
37 {domain.AdminResourceConfig, domain.AdminActionWrite},
38 {domain.AdminResourceLogs, domain.AdminActionRead},
39 {domain.AdminResourceVolumes, domain.AdminActionRead},
40 {domain.AdminResourceVolumes, domain.AdminActionWrite},
41 } {
42 assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action)
43 }
44 assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead))
45 assert.False(t, HasAccess(ctx, "secrets", domain.AdminActionRead))
46 assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite))
47 assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite))
48 assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll))
49 return true
50 })).Return([]in.AppSummary(nil), nil).Once()
51
52 rec := localRequest(t, handler.LocalAuthority(), http.MethodGet, "/admin/apps")
53 require.Equal(t, http.StatusOK, rec.Code)
54}
55
56func TestLocalAuthorityCanonicalAllowlist(t *testing.T) {
57 t.Parallel()
58 allowed := []struct{ method, path string }{
59 {http.MethodGet, "/status"},
60 {http.MethodGet, "/tls/status"},
61 {http.MethodGet, "/traffic/status"},
62 {http.MethodGet, "/config"},
63 {http.MethodGet, "/networks"},
64 {http.MethodGet, "/volumes"},
65 {http.MethodPost, "/volumes/prune"},
66 {http.MethodGet, "/images"},
67 {http.MethodPost, "/images/prune"},
68 {http.MethodPost, "/reload"},
69 {http.MethodGet, "/tags/repository"},
70 {http.MethodGet, "/logs"},
71 {http.MethodGet, "/logs/blog/web"},
72 {http.MethodGet, "/backups"},
73 {http.MethodGet, "/backups/status"},
74 {http.MethodGet, "/backups/shop"},
75 {http.MethodPost, "/backups/shop"},
76 {http.MethodGet, "/backups/volumes"},
77 {http.MethodPost, "/backups/volumes"},
78 {http.MethodGet, "/backups/volumes/status"},
79 {http.MethodGet, "/backups/volumes/blog.example.com"},
80 {http.MethodPost, "/backups/volumes/blog.example.com"},
81 {http.MethodGet, "/apps"},
82 {http.MethodPost, "/apps/apply"},
83 {http.MethodGet, "/apps/blog"},
84 {http.MethodGet, "/apps/blog/diff"},
85 {http.MethodPost, "/apps/blog/deploy"},
86 {http.MethodPost, "/apps/blog/restart"},
87 {http.MethodPost, "/apps/blog/stop"},
88 {http.MethodPost, "/apps/blog/start"},
89 {http.MethodPost, "/apps/blog/remove"},
90 {http.MethodPost, "/apps/blog/secrets/set"},
91 {http.MethodPost, "/apps/blog/secrets/delete"},
92 {http.MethodGet, "/apps/blog/operations/by-key/request-1"},
93 }
94 for _, tc := range allowed {
95 t.Run(tc.method+" "+tc.path, func(t *testing.T) {
96 assert.True(t, localPathAllowed(tc.method, tc.path))
97 })
98 }
99}
100
101func TestLocalAuthorityDeniesNearMissesAndNonCanonicalPaths(t *testing.T) {
102 t.Parallel()
103 denied := []struct{ method, path string }{
104 {http.MethodPost, "/status"},
105 {http.MethodGet, "/reload"},
106 {http.MethodPost, "/config"},
107 {http.MethodGet, "/volumes/prune"},
108 {http.MethodGet, "/images/prune"},
109 {http.MethodGet, "/auth/verify"},
110 {http.MethodGet, "/auth/tokens"},
111 {http.MethodPost, "/auth/tokens"},
112 {http.MethodGet, "/health"},
113 {http.MethodGet, "/appsX"},
114 {http.MethodGet, "/secrets"},
115 {http.MethodGet, "/secrets/blog.example.com"},
116 {http.MethodPost, "/secrets/blog.example.com"},
117 {http.MethodDelete, "/secrets/blog"},
118 {http.MethodGet, "/images/anything"},
119 {http.MethodGet, "/tags/repository/extra"},
120 {http.MethodGet, "/logs/blog"},
121 {http.MethodGet, "/backups/volumes/status/extra"},
122 {http.MethodGet, "/apps/blog/operations/by-key/key/extra"},
123 {http.MethodGet, "/"},
124 }
125 for _, tc := range denied {
126 t.Run(tc.method+" "+tc.path, func(t *testing.T) {
127 assert.False(t, localPathAllowed(tc.method, tc.path))
128 })
129 }
130}
131
132func TestLocalAuthorityRejectsNonAdminAndTraversalRequests(t *testing.T) {
133 appSvc := inmocks.NewMockAppService(t)
134 local := appsTestHandler(t, appSvc).LocalAuthority()
135 for _, target := range []string{
136 "/apps",
137 "/admin/auth/tokens",
138 "/admin/apps/",
139 "/admin/apps/../config",
140 "/admin/apps/..%2fconfig",
141 "/admin/logs/../../config",
142 } {
143 rec := localRequest(t, local, http.MethodGet, target)
144 assert.Equal(t, http.StatusForbidden, rec.Code, target)
145 }
146}