@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 10, 22:30 UTC

master
go 1,428 lines 37.4 KB
Raw
1 // Copyright 2015 Matthew Holt and The Caddy Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package caddy
16
17 import (
18 "bytes"
19 "context"
20 "crypto"
21 "crypto/tls"
22 "crypto/x509"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "maps"
27 "net/http"
28 "net/http/httptest"
29 "os"
30 "reflect"
31 "sync"
32 "testing"
33 "time"
34
35 "github.com/caddyserver/certmagic"
36 "github.com/prometheus/client_golang/prometheus"
37 dto "github.com/prometheus/client_model/go"
38 "go.uber.org/zap"
39 "go.uber.org/zap/zaptest/observer"
40 )
41
42 var testCfg = []byte(`{
43 "apps": {
44 "http": {
45 "servers": {
46 "myserver": {
47 "listen": ["tcp/localhost:8080-8084"],
48 "read_timeout": "30s"
49 },
50 "yourserver": {
51 "listen": ["127.0.0.1:5000"],
52 "read_header_timeout": "15s"
53 }
54 }
55 }
56 }
57 }
58 `)
59
60 type testAdminPublicKey string
61
62 func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
63 other, ok := x.(testAdminPublicKey)
64 return ok && k == other
65 }
66
67 func TestUnsyncedConfigAccess(t *testing.T) {
68 // each test is performed in sequence, so
69 // each change builds on the previous ones;
70 // the config is not reset between tests
71 for i, tc := range []struct {
72 method string
73 path string // rawConfigKey will be prepended
74 payload string
75 expect string // JSON representation of what the whole config is expected to be after the request
76 shouldErr bool
77 }{
78 {
79 method: "POST",
80 path: "",
81 payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value
82 expect: `{"foo": "bar", "list": ["a", "b", "c"]}`,
83 },
84 {
85 method: "POST",
86 path: "/foo",
87 payload: `"jet"`,
88 expect: `{"foo": "jet", "list": ["a", "b", "c"]}`,
89 },
90 {
91 method: "POST",
92 path: "/bar",
93 payload: `{"aa": "bb", "qq": "zz"}`,
94 expect: `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`,
95 },
96 {
97 method: "DELETE",
98 path: "/bar/qq",
99 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
100 },
101 {
102 method: "DELETE",
103 path: "/bar/qq",
104 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
105 shouldErr: true,
106 },
107 {
108 method: "POST",
109 path: "/list",
110 payload: `"e"`,
111 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
112 },
113 {
114 method: "PUT",
115 path: "/list/3",
116 payload: `"d"`,
117 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`,
118 },
119 {
120 method: "DELETE",
121 path: "/list/3",
122 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
123 },
124 {
125 method: "PATCH",
126 path: "/list/3",
127 payload: `"d"`,
128 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`,
129 },
130 {
131 method: "POST",
132 path: "/list/...",
133 payload: `["e", "f", "g"]`,
134 expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e", "f", "g"]}`,
135 },
136 } {
137 err := unsyncedConfigAccess(tc.method, rawConfigKey+tc.path, []byte(tc.payload), nil)
138
139 if tc.shouldErr && err == nil {
140 t.Fatalf("Test %d: Expected error return value, but got: %v", i, err)
141 }
142 if !tc.shouldErr && err != nil {
143 t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err)
144 }
145
146 // decode the expected config so we can do a convenient DeepEqual
147 var expectedDecoded any
148 err = json.Unmarshal([]byte(tc.expect), &expectedDecoded)
149 if err != nil {
150 t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err)
151 }
152
153 // make sure the resulting config is as we expect it
154 if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) {
155 t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v",
156 i, expectedDecoded, rawCfg[rawConfigKey])
157 }
158 }
159 }
160
161 // TestLoadConcurrent exercises Load under concurrent conditions
162 // and is most useful under test with `-race` enabled.
163 func TestLoadConcurrent(t *testing.T) {
164 var wg sync.WaitGroup
165
166 for i := 0; i < 100; i++ {
167 wg.Go(func() {
168 _ = Load(testCfg, true)
169 })
170 }
171 wg.Wait()
172 }
173
174 type fooModule struct {
175 IntField int
176 StrField string
177 }
178
179 func (fooModule) CaddyModule() ModuleInfo {
180 return ModuleInfo{
181 ID: "foo",
182 New: func() Module { return new(fooModule) },
183 }
184 }
185 func (fooModule) Start() error { return nil }
186 func (fooModule) Stop() error { return nil }
187
188 func TestETags(t *testing.T) {
189 RegisterModule(fooModule{})
190
191 if err := Load([]byte(`{"admin": {"listen": "localhost:2999"}, "apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
192 t.Fatalf("loading: %s", err)
193 }
194
195 const key = "/" + rawConfigKey + "/apps/foo"
196
197 // try update the config with the wrong etag
198 err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), fmt.Sprintf(`"/%s not_an_etag"`, rawConfigKey), false)
199 if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
200 t.Fatalf("expected precondition failed; got %v", err)
201 }
202
203 // get the etag
204 hash := etagHasher()
205 if err := readConfig(key, hash); err != nil {
206 t.Fatalf("reading: %s", err)
207 }
208
209 // do the same update with the correct key
210 err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), makeEtag(key, hash), false)
211 if err != nil {
212 t.Fatalf("expected update to work; got %v", err)
213 }
214
215 // now try another update. The hash should no longer match and we should get precondition failed
216 err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), makeEtag(key, hash), false)
217 if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
218 t.Fatalf("expected precondition failed; got %v", err)
219 }
220 }
221
222 func BenchmarkLoad(b *testing.B) {
223 for b.Loop() {
224 Load(testCfg, true)
225 }
226 }
227
228 func TestAdminHandlerErrorHandling(t *testing.T) {
229 initAdminMetrics()
230
231 handler := adminHandler{
232 mux: http.NewServeMux(),
233 }
234
235 handler.mux.Handle("/error", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236 err := fmt.Errorf("test error")
237 handler.handleError(w, r, err)
238 }))
239
240 req := httptest.NewRequest(http.MethodGet, "/error", nil)
241 rr := httptest.NewRecorder()
242
243 handler.ServeHTTP(rr, req)
244
245 if rr.Code == http.StatusOK {
246 t.Error("expected error response, got success")
247 }
248
249 var apiErr APIError
250 if err := json.NewDecoder(rr.Body).Decode(&apiErr); err != nil {
251 t.Fatalf("decoding response: %v", err)
252 }
253 if apiErr.Message != "test error" {
254 t.Errorf("expected error message 'test error', got '%s'", apiErr.Message)
255 }
256 }
257
258 func TestAdminHandlerServeHTTPRedactsSensitiveHeadersInLogs(t *testing.T) {
259 core, logs := observer.New(zap.InfoLevel)
260
261 defaultLoggerMu.Lock()
262 origLogger := defaultLogger.logger
263 defaultLogger.logger = zap.New(core)
264 defaultLoggerMu.Unlock()
265 t.Cleanup(func() {
266 defaultLoggerMu.Lock()
267 defaultLogger.logger = origLogger
268 defaultLoggerMu.Unlock()
269 })
270
271 handler := adminHandler{
272 mux: http.NewServeMux(),
273 }
274 req := httptest.NewRequest(http.MethodGet, "/redaction-test", nil)
275 req.Header.Set("Authorization", "Bearer secret")
276 req.Header.Set("Cookie", "session=secret")
277 req.Header.Set("X-Test", "ok")
278 rr := httptest.NewRecorder()
279
280 Log().Info("unrelated test log")
281 handler.ServeHTTP(rr, req)
282
283 entries := logs.FilterMessage("received request").FilterField(zap.String("uri", req.RequestURI)).All()
284 if len(entries) != 1 {
285 t.Fatalf("request log entries = %d, want 1", len(entries))
286 }
287
288 ctx := entries[0].ContextMap()
289 headers, ok := ctx["headers"].(map[string]any)
290 if !ok {
291 t.Fatalf("expected headers field in log context, got %T", ctx["headers"])
292 }
293
294 if got := headers["Authorization"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
295 t.Fatalf("expected redacted Authorization header, got %#v", got)
296 }
297 if got := headers["Cookie"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
298 t.Fatalf("expected redacted Cookie header, got %#v", got)
299 }
300 if got := headers["X-Test"]; !reflect.DeepEqual(got, []any{"ok"}) {
301 t.Fatalf("expected X-Test header to remain visible, got %#v", got)
302 }
303 }
304
305 func initAdminMetrics() {
306 if adminMetrics.requestErrors != nil {
307 prometheus.Unregister(adminMetrics.requestErrors)
308 }
309 if adminMetrics.requestCount != nil {
310 prometheus.Unregister(adminMetrics.requestCount)
311 }
312
313 adminMetrics.requestErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
314 Namespace: "caddy",
315 Subsystem: "admin_http",
316 Name: "request_errors_total",
317 Help: "Number of errors that occurred handling admin endpoint requests",
318 }, []string{"handler", "path", "method"})
319
320 adminMetrics.requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{
321 Namespace: "caddy",
322 Subsystem: "admin_http",
323 Name: "requests_total",
324 Help: "Count of requests to the admin endpoint",
325 }, []string{"handler", "path", "code", "method"}) // Added code and method labels
326
327 prometheus.MustRegister(adminMetrics.requestErrors)
328 prometheus.MustRegister(adminMetrics.requestCount)
329 }
330
331 func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
332 initAdminMetrics()
333
334 cfg := &Config{
335 Admin: &AdminConfig{
336 Listen: "localhost:2019",
337 },
338 }
339
340 // Build the admin handler directly (no listener active)
341 addr, err := ParseNetworkAddress("localhost:2019")
342 if err != nil {
343 t.Fatalf("Failed to parse address: %v", err)
344 }
345 handler, err := cfg.Admin.newAdminHandler(addr, false, Context{})
346 if err != nil {
347 t.Fatalf("Failed to create admin handler: %v", err)
348 }
349
350 tests := []struct {
351 name string
352 path string
353 method string
354 expectedStatus int
355 }{
356 {
357 name: "stop endpoint wrong method",
358 path: "/stop",
359 method: http.MethodGet,
360 expectedStatus: http.StatusMethodNotAllowed,
361 },
362 {
363 name: "config endpoint wrong content-type",
364 path: "/config/",
365 method: http.MethodPost,
366 expectedStatus: http.StatusBadRequest,
367 },
368 {
369 name: "config ID missing ID",
370 path: "/id/",
371 method: http.MethodGet,
372 expectedStatus: http.StatusBadRequest,
373 },
374 }
375
376 for _, test := range tests {
377 t.Run(test.name, func(t *testing.T) {
378 req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil)
379 rr := httptest.NewRecorder()
380
381 handler.ServeHTTP(rr, req)
382
383 if rr.Code != test.expectedStatus {
384 t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code)
385 }
386
387 metricValue := testGetMetricValue(map[string]string{
388 "path": test.path,
389 "handler": "admin",
390 "method": test.method,
391 })
392 if metricValue != 1 {
393 t.Errorf("expected error metric to be incremented once, got %v", metricValue)
394 }
395 })
396 }
397 }
398
399 func testGetMetricValue(labels map[string]string) float64 {
400 promLabels := prometheus.Labels{}
401 maps.Copy(promLabels, labels)
402
403 metric, err := adminMetrics.requestErrors.GetMetricWith(promLabels)
404 if err != nil {
405 return 0
406 }
407
408 pb := &dto.Metric{}
409 metric.Write(pb)
410 return pb.GetCounter().GetValue()
411 }
412
413 type mockRouter struct {
414 routes []AdminRoute
415 }
416
417 func (m mockRouter) Routes() []AdminRoute {
418 return m.routes
419 }
420
421 type mockModule struct {
422 mockRouter
423 }
424
425 func (m *mockModule) CaddyModule() ModuleInfo {
426 return ModuleInfo{
427 ID: "admin.api.mock",
428 New: func() Module {
429 mm := &mockModule{
430 mockRouter: mockRouter{
431 routes: m.routes,
432 },
433 }
434 return mm
435 },
436 }
437 }
438
439 func TestNewAdminHandlerRouterRegistration(t *testing.T) {
440 originalModules := make(map[string]ModuleInfo)
441 maps.Copy(originalModules, modules)
442 defer func() {
443 modules = originalModules
444 }()
445
446 mockRoute := AdminRoute{
447 Pattern: "/mock",
448 Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
449 w.WriteHeader(http.StatusOK)
450 return nil
451 }),
452 }
453
454 mock := &mockModule{
455 mockRouter: mockRouter{
456 routes: []AdminRoute{mockRoute},
457 },
458 }
459 RegisterModule(mock)
460
461 addr, err := ParseNetworkAddress("localhost:2019")
462 if err != nil {
463 t.Fatalf("Failed to parse address: %v", err)
464 }
465
466 admin := &AdminConfig{
467 EnforceOrigin: false,
468 }
469 handler, err := admin.newAdminHandler(addr, false, Context{})
470 if err != nil {
471 t.Fatalf("Failed to create admin handler: %v", err)
472 }
473
474 req := httptest.NewRequest("GET", "/mock", nil)
475 req.Host = "localhost:2019"
476 rr := httptest.NewRecorder()
477
478 handler.ServeHTTP(rr, req)
479
480 if rr.Code != http.StatusOK {
481 t.Errorf("Expected status code %d but got %d", http.StatusOK, rr.Code)
482 t.Logf("Response body: %s", rr.Body.String())
483 }
484 }
485
486 type mockProvisionableRouter struct {
487 mockRouter
488 provisionErr error
489 provisioned bool
490 }
491
492 func (m *mockProvisionableRouter) Provision(Context) error {
493 m.provisioned = true
494 return m.provisionErr
495 }
496
497 type mockProvisionableModule struct {
498 *mockProvisionableRouter
499 }
500
501 func (m *mockProvisionableModule) CaddyModule() ModuleInfo {
502 return ModuleInfo{
503 ID: "admin.api.mock_provision",
504 New: func() Module {
505 mm := &mockProvisionableModule{
506 mockProvisionableRouter: &mockProvisionableRouter{
507 mockRouter: m.mockRouter,
508 provisionErr: m.provisionErr,
509 },
510 }
511 return mm
512 },
513 }
514 }
515
516 func TestAdminRouterProvisioning(t *testing.T) {
517 tests := []struct {
518 name string
519 provisionErr error
520 wantErr bool
521 }{
522 {
523 name: "successful provisioning",
524 provisionErr: nil,
525 wantErr: false,
526 },
527 {
528 name: "provisioning error",
529 provisionErr: fmt.Errorf("provision failed"),
530 wantErr: true,
531 },
532 }
533
534 for _, test := range tests {
535 t.Run(test.name, func(t *testing.T) {
536 originalModules := make(map[string]ModuleInfo)
537 maps.Copy(originalModules, modules)
538 defer func() {
539 modules = originalModules
540 }()
541
542 mockRoute := AdminRoute{
543 Pattern: "/mock",
544 Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
545 return nil
546 }),
547 }
548
549 // Create provisionable module
550 mock := &mockProvisionableModule{
551 mockProvisionableRouter: &mockProvisionableRouter{
552 mockRouter: mockRouter{
553 routes: []AdminRoute{mockRoute},
554 },
555 provisionErr: test.provisionErr,
556 },
557 }
558 RegisterModule(mock)
559
560 admin := &AdminConfig{}
561 addr, err := ParseNetworkAddress("localhost:2019")
562 if err != nil {
563 t.Fatalf("Failed to parse address: %v", err)
564 }
565
566 _, err = admin.newAdminHandler(addr, false, Context{})
567
568 if test.wantErr {
569 if err == nil {
570 t.Error("Expected error but got nil")
571 }
572 } else {
573 if err != nil {
574 t.Errorf("Expected no error but got: %v", err)
575 }
576 }
577 })
578 }
579 }
580
581 func TestAllowedOriginsUnixSocket(t *testing.T) {
582 // see comment in allowedOrigins() as to why we do not fill out allowed origins for UDS
583 tests := []struct {
584 name string
585 addr NetworkAddress
586 origins []string
587 expectOrigins []string
588 }{
589 {
590 name: "unix socket with default origins",
591 addr: NetworkAddress{
592 Network: "unix",
593 Host: "/tmp/caddy.sock",
594 },
595 origins: nil, // default origins
596 expectOrigins: []string{},
597 },
598 {
599 name: "unix socket with custom origins",
600 addr: NetworkAddress{
601 Network: "unix",
602 Host: "/tmp/caddy.sock",
603 },
604 origins: []string{"example.com"},
605 expectOrigins: []string{
606 "example.com",
607 },
608 },
609 {
610 name: "tcp socket on localhost gets all loopback addresses",
611 addr: NetworkAddress{
612 Network: "tcp",
613 Host: "localhost",
614 StartPort: 2019,
615 EndPort: 2019,
616 },
617 origins: nil,
618 expectOrigins: []string{
619 "localhost:2019",
620 "[::1]:2019",
621 "127.0.0.1:2019",
622 },
623 },
624 }
625
626 for i, test := range tests {
627 t.Run(test.name, func(t *testing.T) {
628 admin := AdminConfig{
629 Origins: test.origins,
630 }
631
632 got := admin.allowedOrigins(test.addr)
633
634 var gotOrigins []string
635 for _, u := range got {
636 gotOrigins = append(gotOrigins, u.Host)
637 }
638
639 if len(gotOrigins) != len(test.expectOrigins) {
640 t.Errorf("%d: Expected %d origins but got %d", i, len(test.expectOrigins), len(gotOrigins))
641 return
642 }
643
644 expectMap := make(map[string]struct{})
645 for _, origin := range test.expectOrigins {
646 expectMap[origin] = struct{}{}
647 }
648
649 gotMap := make(map[string]struct{})
650 for _, origin := range gotOrigins {
651 gotMap[origin] = struct{}{}
652 }
653
654 if !reflect.DeepEqual(expectMap, gotMap) {
655 t.Errorf("%d: Origins mismatch.\nExpected: %v\nGot: %v", i, test.expectOrigins, gotOrigins)
656 }
657 })
658 }
659 }
660
661 func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
662 const authorizedKey testAdminPublicKey = "authorized"
663 peerCert := &x509.Certificate{PublicKey: authorizedKey}
664
665 tests := []struct {
666 name string
667 allowedPath string
668 requestPath string
669 wantErr bool
670 }{
671 {
672 name: "exact path",
673 allowedPath: "/pki/ca/prod",
674 requestPath: "/pki/ca/prod",
675 wantErr: false,
676 },
677 {
678 name: "subpath",
679 allowedPath: "/pki/ca/prod",
680 requestPath: "/pki/ca/prod/certificates",
681 wantErr: false,
682 },
683 {
684 name: "trailing slash subpath",
685 allowedPath: "/pki/ca/prod/",
686 requestPath: "/pki/ca/prod/certificates",
687 wantErr: false,
688 },
689 {
690 name: "trailing slash scope excludes exact path without slash",
691 allowedPath: "/pki/ca/prod/",
692 requestPath: "/pki/ca/prod",
693 wantErr: true,
694 },
695 {
696 name: "trailing slash scope includes exact path with slash",
697 allowedPath: "/pki/ca/prod/",
698 requestPath: "/pki/ca/prod/",
699 wantErr: false,
700 },
701 {
702 name: "sibling with shared prefix",
703 allowedPath: "/pki/ca/prod",
704 requestPath: "/pki/ca/prod-backup",
705 wantErr: true,
706 },
707 {
708 name: "same segment plus digit",
709 allowedPath: "/pki/ca/prod",
710 requestPath: "/pki/ca/prod1",
711 wantErr: true,
712 },
713 {
714 name: "root path",
715 allowedPath: "/",
716 requestPath: "/pki/ca/prod",
717 wantErr: false,
718 },
719 {
720 name: "empty path preserves allow all",
721 allowedPath: "",
722 requestPath: "/pki/ca/prod",
723 wantErr: false,
724 },
725 {
726 name: "dot-dot traversal out of scope",
727 allowedPath: "/pki/ca/prod",
728 requestPath: "/pki/ca/prod/../../../../load",
729 wantErr: true,
730 },
731 {
732 name: "dot-dot traversal out of trailing slash scope",
733 allowedPath: "/pki/ca/prod/",
734 requestPath: "/pki/ca/prod/../../../../load",
735 wantErr: true,
736 },
737 {
738 name: "encoded traversal (net/url decodes %2e to . before path.Clean resolves ..)",
739 allowedPath: "/pki/ca/prod",
740 requestPath: "/pki/ca/prod/%2e%2e/load",
741 wantErr: true,
742 },
743 {
744 name: "sibling traversal",
745 allowedPath: "/pki/ca/prod",
746 requestPath: "/pki/ca/prod/../staging",
747 wantErr: true,
748 },
749 {
750 name: "collapsed slashes",
751 allowedPath: "/pki/ca/prod",
752 requestPath: "/pki//ca/prod",
753 wantErr: false,
754 },
755 {
756 name: "exact request with trailing slash",
757 allowedPath: "/pki/ca/prod",
758 requestPath: "/pki/ca/prod/",
759 wantErr: false,
760 },
761 }
762
763 for i, test := range tests {
764 t.Run(test.name, func(t *testing.T) {
765 remote := RemoteAdmin{
766 AccessControl: []*AdminAccess{
767 {
768 Permissions: []AdminPermissions{
769 {
770 Methods: []string{http.MethodGet},
771 Paths: []string{test.allowedPath},
772 },
773 },
774 publicKeys: []crypto.PublicKey{authorizedKey},
775 },
776 },
777 }
778
779 req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
780 req.TLS = &tls.ConnectionState{
781 VerifiedChains: [][]*x509.Certificate{{peerCert}},
782 }
783
784 err := remote.enforceAccessControls(req)
785 if test.wantErr {
786 if err == nil {
787 t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
788 return
789 }
790 var apiErr APIError
791 if !errors.As(err, &apiErr) {
792 t.Errorf("test %d (%s): allowed path %q, request path %q: expected APIError with HTTP status %d, got %T: %v", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, err, err)
793 return
794 }
795 if apiErr.HTTPStatus != http.StatusForbidden {
796 t.Errorf("test %d (%s): allowed path %q, request path %q: expected HTTP status %d, got %d", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
797 }
798 return
799 }
800
801 if err != nil {
802 t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
803 }
804 })
805 }
806 }
807
808 func TestRemoteAdminAccessControlMultiplePermissions(t *testing.T) {
809 const authorizedKey testAdminPublicKey = "authorized"
810 peerCert := &x509.Certificate{PublicKey: authorizedKey}
811
812 tests := []struct {
813 name string
814 permissions []AdminPermissions
815 // extraAccess adds further access control entries holding the same key
816 extraAccess [][]AdminPermissions
817 method string
818 requestPath string
819 wantErr string
820 }{
821 {
822 name: "no permissions grants all",
823 permissions: nil,
824 method: http.MethodDelete,
825 requestPath: "/config/",
826 },
827 {
828 name: "first entry matches",
829 permissions: []AdminPermissions{
830 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
831 {Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
832 },
833 method: http.MethodGet,
834 requestPath: "/config/",
835 },
836 {
837 name: "second entry matches",
838 permissions: []AdminPermissions{
839 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
840 {Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
841 },
842 method: http.MethodPost,
843 requestPath: "/load",
844 },
845 {
846 name: "same path different methods across entries",
847 permissions: []AdminPermissions{
848 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
849 {Methods: []string{http.MethodPatch}, Paths: []string{"/config/"}},
850 },
851 method: http.MethodPatch,
852 requestPath: "/config/",
853 },
854 {
855 name: "method and path from different entries",
856 permissions: []AdminPermissions{
857 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
858 {Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
859 },
860 method: http.MethodPost,
861 requestPath: "/config/",
862 wantErr: "not authorized to use this method",
863 },
864 {
865 name: "path allowed by no entry",
866 permissions: []AdminPermissions{
867 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
868 {Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
869 },
870 method: http.MethodGet,
871 requestPath: "/stop",
872 wantErr: "not authorized to access this path",
873 },
874 {
875 name: "later access entry with the same key matches",
876 permissions: []AdminPermissions{
877 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
878 },
879 extraAccess: [][]AdminPermissions{
880 {{Methods: []string{http.MethodPost}, Paths: []string{"/load"}}},
881 },
882 method: http.MethodPost,
883 requestPath: "/load",
884 },
885 {
886 name: "no access entry with the same key matches",
887 permissions: []AdminPermissions{
888 {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
889 },
890 extraAccess: [][]AdminPermissions{
891 {{Methods: []string{http.MethodGet}, Paths: []string{"/load"}}},
892 },
893 method: http.MethodPost,
894 requestPath: "/load",
895 wantErr: "not authorized to use this method",
896 },
897 }
898
899 for _, test := range tests {
900 t.Run(test.name, func(t *testing.T) {
901 remote := RemoteAdmin{
902 AccessControl: []*AdminAccess{
903 {
904 Permissions: test.permissions,
905 publicKeys: []crypto.PublicKey{authorizedKey},
906 },
907 },
908 }
909 for _, permissions := range test.extraAccess {
910 remote.AccessControl = append(remote.AccessControl, &AdminAccess{
911 Permissions: permissions,
912 publicKeys: []crypto.PublicKey{authorizedKey},
913 })
914 }
915
916 req := httptest.NewRequest(test.method, "https://localhost:2021"+test.requestPath, nil)
917 req.TLS = &tls.ConnectionState{
918 VerifiedChains: [][]*x509.Certificate{{peerCert}},
919 }
920
921 err := remote.enforceAccessControls(req)
922 if test.wantErr == "" {
923 if err != nil {
924 t.Errorf("%s %s: expected no error, got %v", test.method, test.requestPath, err)
925 }
926 return
927 }
928
929 var apiErr APIError
930 if !errors.As(err, &apiErr) {
931 t.Fatalf("%s %s: expected APIError, got %T: %v", test.method, test.requestPath, err, err)
932 }
933 if apiErr.HTTPStatus != http.StatusForbidden {
934 t.Errorf("%s %s: expected HTTP status %d, got %d", test.method, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
935 }
936 if apiErr.Message != test.wantErr {
937 t.Errorf("%s %s: expected message %q, got %q", test.method, test.requestPath, test.wantErr, apiErr.Message)
938 }
939 })
940 }
941 }
942
943 func TestReplaceRemoteAdminServer(t *testing.T) {
944 const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
945 BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
946 MTAwMDAwMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
947 AAOCAQ8AMIIBCgKCAQEA4O4S6BSoYcoxvRqI+h7yPOjF6KjntjzVVm9M+uHK4lzX
948 F1L3pSxJ2nDD4wZEV3FJ5yFOHVFqkG2vXG3BIczOlYG7UeNmKbQnKc5kZj3HGUrS
949 VGEktA4OJbeZhhWP15gcXN5eDM2eH3g9BFXVX6AURxLiUXzhNBUEZuj/OEyH9yEF
950 /qPCE+EjzVvWxvBXwgz/io4r4yok/Vq/bxJ6FlV6R7DX5oJSXyO0VEHZPi9DIyNU
951 kK3F/r4U1sWiJGWOs8i3YQWZ2ejh1C0aLFZpPcCGGgMNpoF31gyYP6ZuPDUyCXsE
952 g36UUw1JHNtIXYcLhnXuqj4A8TybTDpgXLqvwA9DBQIDAQABo1MwUTAdBgNVHQ4E
953 FgQUc13z30pFC63rr/HGKOE7E82vjXwwHwYDVR0jBBgwFoAUc13z30pFC63rr/HG
954 KOE7E82vjXwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHO3j
955 oeiUXXJ7xD4P8Wj5t9d+E8lE1Xv1Dk3Z+EdG5+dan+RcToE42JJp9zB7FIh5Qz8g
956 W77LAjqh5oyqz3A2VJcyVgfE3uJP1R1mJM7JfGHf84QH4TZF2Q1RZY4SZs0VQ6+q
957 5wSlIZ4NXDy4Q4XkIJBGS61wT8IzYFXYBpx4PCP1Qj0PIE4sevEGwjsBIgxK307o
958 BxF8AWe6N6e4YZmQLGjQ+SeH0iwZb6vpkHyAY8Kj2hvK+cq2P7vU3VGi0t3r1F8L
959 IvrXHCvO2BMNJ/1UK1M4YNX8LYJqQhg9hEsIROe1OE/m3VhxIYMJI+qZXk9yHfgJ
960 vq+SH04xKhtFudVBAQ==`
961
962 tests := []struct {
963 name string
964 cfg *Config
965 wantErr bool
966 }{
967 {
968 name: "nil config",
969 cfg: nil,
970 wantErr: false,
971 },
972 {
973 name: "nil admin config",
974 cfg: &Config{
975 Admin: nil,
976 },
977 wantErr: false,
978 },
979 {
980 name: "nil remote config",
981 cfg: &Config{
982 Admin: &AdminConfig{},
983 },
984 wantErr: false,
985 },
986 {
987 name: "invalid listen address",
988 cfg: &Config{
989 Admin: &AdminConfig{
990 Remote: &RemoteAdmin{
991 Listen: "invalid:address",
992 },
993 },
994 },
995 wantErr: true,
996 },
997 {
998 name: "valid config",
999 cfg: &Config{
1000 Admin: &AdminConfig{
1001 Identity: &IdentityConfig{},
1002 Remote: &RemoteAdmin{
1003 Listen: "localhost:2021",
1004 AccessControl: []*AdminAccess{
1005 {
1006 PublicKeys: []string{testCert},
1007 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
1008 },
1009 },
1010 },
1011 },
1012 },
1013 wantErr: false,
1014 },
1015 {
1016 name: "invalid certificate",
1017 cfg: &Config{
1018 Admin: &AdminConfig{
1019 Identity: &IdentityConfig{},
1020 Remote: &RemoteAdmin{
1021 Listen: "localhost:2021",
1022 AccessControl: []*AdminAccess{
1023 {
1024 PublicKeys: []string{"invalid-cert-data"},
1025 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
1026 },
1027 },
1028 },
1029 },
1030 },
1031 wantErr: true,
1032 },
1033 {
1034 name: "non-canonical root path with trailing slash",
1035 cfg: &Config{
1036 Admin: &AdminConfig{
1037 Identity: &IdentityConfig{},
1038 Remote: &RemoteAdmin{
1039 Listen: "localhost:2021",
1040 AccessControl: []*AdminAccess{
1041 {
1042 PublicKeys: []string{testCert},
1043 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"//"}}},
1044 },
1045 },
1046 },
1047 },
1048 },
1049 wantErr: true,
1050 },
1051 {
1052 name: "canonical subpath with trailing slash",
1053 cfg: &Config{
1054 Admin: &AdminConfig{
1055 Identity: &IdentityConfig{},
1056 Remote: &RemoteAdmin{
1057 Listen: "localhost:2021",
1058 AccessControl: []*AdminAccess{
1059 {
1060 PublicKeys: []string{testCert},
1061 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo/"}}},
1062 },
1063 },
1064 },
1065 },
1066 },
1067 wantErr: false,
1068 },
1069 {
1070 name: "non-canonical path /..",
1071 cfg: &Config{
1072 Admin: &AdminConfig{
1073 Identity: &IdentityConfig{},
1074 Remote: &RemoteAdmin{
1075 Listen: "localhost:2021",
1076 AccessControl: []*AdminAccess{
1077 {
1078 PublicKeys: []string{testCert},
1079 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/.."}}},
1080 },
1081 },
1082 },
1083 },
1084 },
1085 wantErr: true,
1086 },
1087 {
1088 name: "non-canonical path with double slashes",
1089 cfg: &Config{
1090 Admin: &AdminConfig{
1091 Identity: &IdentityConfig{},
1092 Remote: &RemoteAdmin{
1093 Listen: "localhost:2021",
1094 AccessControl: []*AdminAccess{
1095 {
1096 PublicKeys: []string{testCert},
1097 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//bar"}}},
1098 },
1099 },
1100 },
1101 },
1102 },
1103 wantErr: true,
1104 },
1105 {
1106 name: "non-canonical path with double trailing slashes",
1107 cfg: &Config{
1108 Admin: &AdminConfig{
1109 Identity: &IdentityConfig{},
1110 Remote: &RemoteAdmin{
1111 Listen: "localhost:2021",
1112 AccessControl: []*AdminAccess{
1113 {
1114 PublicKeys: []string{testCert},
1115 Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//"}}},
1116 },
1117 },
1118 },
1119 },
1120 },
1121 wantErr: true,
1122 },
1123 }
1124
1125 for _, test := range tests {
1126 t.Run(test.name, func(t *testing.T) {
1127 ctx := Context{
1128 Context: context.Background(),
1129 cfg: test.cfg,
1130 }
1131
1132 if test.cfg != nil {
1133 test.cfg.storage = &certmagic.FileStorage{Path: t.TempDir()}
1134 }
1135
1136 if test.cfg != nil && test.cfg.Admin != nil && test.cfg.Admin.Identity != nil {
1137 identityCertCache = certmagic.NewCache(certmagic.CacheOptions{
1138 GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) {
1139 return &certmagic.Config{}, nil
1140 },
1141 })
1142 }
1143
1144 err := replaceRemoteAdminServer(ctx, test.cfg)
1145
1146 if test.wantErr {
1147 if err == nil {
1148 t.Error("Expected error but got nil")
1149 }
1150 } else {
1151 if err != nil {
1152 t.Errorf("Expected no error but got: %v", err)
1153 }
1154 }
1155
1156 // Clean up
1157 if remoteAdminServer != nil {
1158 _ = stopAdminServer(remoteAdminServer)
1159 }
1160 })
1161 }
1162 }
1163
1164 type mockIssuer struct {
1165 configSet *certmagic.Config
1166 }
1167
1168 func (m *mockIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) {
1169 return &certmagic.IssuedCertificate{
1170 Certificate: []byte(csr.Raw),
1171 }, nil
1172 }
1173
1174 func (m *mockIssuer) SetConfig(cfg *certmagic.Config) {
1175 m.configSet = cfg
1176 }
1177
1178 func (m *mockIssuer) IssuerKey() string {
1179 return "mock"
1180 }
1181
1182 type mockIssuerModule struct {
1183 *mockIssuer
1184 }
1185
1186 func (m *mockIssuerModule) CaddyModule() ModuleInfo {
1187 return ModuleInfo{
1188 ID: "tls.issuance.acme",
1189 New: func() Module {
1190 return &mockIssuerModule{mockIssuer: new(mockIssuer)}
1191 },
1192 }
1193 }
1194
1195 func TestManageIdentity(t *testing.T) {
1196 originalModules := make(map[string]ModuleInfo)
1197 maps.Copy(originalModules, modules)
1198 defer func() {
1199 modules = originalModules
1200 }()
1201
1202 RegisterModule(&mockIssuerModule{})
1203
1204 certPEM := []byte(`-----BEGIN CERTIFICATE-----
1205 MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
1206 BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
1207 cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw
1208 WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
1209 TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp
1210 bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3lcub2pUwkjC
1211 5GJQA2ZZfJJi6d1QHhEmkX9VxKYGp6gagZuRqJWy9TXP6++1ZzQQxqZLD0TkuxZ9
1212 8i9Nz00000CCBjCCAQQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMGgG
1213 CCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29t
1214 L0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5j
1215 b20vb2NzcDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/
1216 BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHREEEDAO
1217 ggxtYWlsLmdvb2dsZTANBgkqhkiG9w0BAQUFAAOCAQEAMP6IWgNGZE8wP9TjFjSZ
1218 3mmW3A1eIr0CuPwNZ2LJ5ZD1i70ojzcj4I9IdP5yPg9CAEV4hNASbM1LzfC7GmJE
1219 tPzW5tRmpKVWZGRgTgZI8Hp/xZXMwLh9ZmXV4kESFAGj5G5FNvJyUV7R5Eh+7OZX
1220 7G4jJ4ZGJh+5jzN9HdJJHQHGYNIYOzC7+HH9UMwCjX9vhQ4RjwFZJThS2Yb+y7pb
1221 9yxTJZoXC6J0H5JpnZb7kZEJ+Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1222 -----END CERTIFICATE-----`)
1223
1224 keyPEM := []byte(`-----BEGIN PRIVATE KEY-----
1225 MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
1226 ...
1227 -----END PRIVATE KEY-----`)
1228
1229 tmpDir, err := os.MkdirTemp("", "TestManageIdentity-")
1230 if err != nil {
1231 t.Fatal(err)
1232 }
1233 testStorage := certmagic.FileStorage{Path: tmpDir}
1234 // Clean up the temp dir after the test finishes. Ensure any background
1235 // certificate maintenance is stopped first to avoid RemoveAll races.
1236 t.Cleanup(func() {
1237 if identityCertCache != nil {
1238 identityCertCache.Stop()
1239 identityCertCache = nil
1240 }
1241 // Give goroutines a moment to exit and release file handles.
1242 time.Sleep(50 * time.Millisecond)
1243 _ = os.RemoveAll(tmpDir)
1244 })
1245
1246 err = testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
1247 if err != nil {
1248 t.Fatal(err)
1249 }
1250 err = testStorage.Store(context.Background(), "localhost/localhost.key", keyPEM)
1251 if err != nil {
1252 t.Fatal(err)
1253 }
1254
1255 tests := []struct {
1256 name string
1257 cfg *Config
1258 wantErr bool
1259 checkState func(*testing.T, *Config)
1260 }{
1261 {
1262 name: "nil config",
1263 cfg: nil,
1264 },
1265 {
1266 name: "nil admin config",
1267 cfg: &Config{
1268 Admin: nil,
1269 },
1270 },
1271 {
1272 name: "nil identity config",
1273 cfg: &Config{
1274 Admin: &AdminConfig{},
1275 },
1276 },
1277 {
1278 name: "default issuer when none specified",
1279 cfg: &Config{
1280 Admin: &AdminConfig{
1281 Identity: &IdentityConfig{
1282 Identifiers: []string{"localhost"},
1283 },
1284 },
1285 storage: &testStorage,
1286 },
1287 checkState: func(t *testing.T, cfg *Config) {
1288 if len(cfg.Admin.Identity.issuers) == 0 {
1289 t.Error("Expected at least 1 issuer to be configured")
1290 return
1291 }
1292 if _, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule); !ok {
1293 t.Error("Expected mock issuer to be configured")
1294 }
1295 },
1296 },
1297 {
1298 name: "custom issuer",
1299 cfg: &Config{
1300 Admin: &AdminConfig{
1301 Identity: &IdentityConfig{
1302 Identifiers: []string{"localhost"},
1303 IssuersRaw: []json.RawMessage{
1304 json.RawMessage(`{"module": "acme"}`),
1305 },
1306 },
1307 },
1308 storage: &testStorage,
1309 },
1310 checkState: func(t *testing.T, cfg *Config) {
1311 if len(cfg.Admin.Identity.issuers) != 1 {
1312 t.Fatalf("Expected 1 issuer, got %d", len(cfg.Admin.Identity.issuers))
1313 }
1314 mockIss, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule)
1315 if !ok {
1316 t.Fatal("Expected mock issuer")
1317 }
1318 if mockIss.configSet == nil {
1319 t.Error("Issuer config was not set")
1320 }
1321 },
1322 },
1323 {
1324 name: "invalid issuer module",
1325 cfg: &Config{
1326 Admin: &AdminConfig{
1327 Identity: &IdentityConfig{
1328 Identifiers: []string{"localhost"},
1329 IssuersRaw: []json.RawMessage{
1330 json.RawMessage(`{"module": "doesnt_exist"}`),
1331 },
1332 },
1333 },
1334 },
1335 wantErr: true,
1336 },
1337 }
1338
1339 for _, test := range tests {
1340 t.Run(test.name, func(t *testing.T) {
1341 if identityCertCache != nil {
1342 // Reset the cert cache before each test
1343 identityCertCache.Stop()
1344 identityCertCache = nil
1345 }
1346 // Ensure any cache started by manageIdentity is stopped at the end
1347 defer func() {
1348 if identityCertCache != nil {
1349 identityCertCache.Stop()
1350 identityCertCache = nil
1351 }
1352 }()
1353
1354 ctx := Context{
1355 Context: context.Background(),
1356 cfg: test.cfg,
1357 moduleInstances: make(map[string][]Module),
1358 }
1359
1360 // If this test provided a FileStorage, set the package-level
1361 // testCertMagicStorageOverride so certmagicConfig will use it.
1362 if test.cfg != nil && test.cfg.storage != nil {
1363 testCertMagicStorageOverride = test.cfg.storage
1364 defer func() { testCertMagicStorageOverride = nil }()
1365 }
1366
1367 err := manageIdentity(ctx, test.cfg)
1368
1369 if test.wantErr {
1370 if err == nil {
1371 t.Error("Expected error but got nil")
1372 }
1373 return
1374 }
1375 if err != nil {
1376 t.Fatalf("Expected no error but got: %v", err)
1377 }
1378
1379 if test.checkState != nil {
1380 test.checkState(t, test.cfg)
1381 }
1382 })
1383 }
1384 }
1385
1386 func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) {
1387 rawCfg = map[string]any{
1388 rawConfigKey: map[string]any{
1389 "list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"},
1390 },
1391 }
1392
1393 tests := []struct {
1394 name string
1395 path string
1396 wantOutput string
1397 wantErr bool
1398 }{
1399 {name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"},
1400 {name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"},
1401 {name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"},
1402 {name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true},
1403 {name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true},
1404 {name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true},
1405 {name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true},
1406 }
1407
1408 for i, tc := range tests {
1409 t.Run(tc.name, func(t *testing.T) {
1410 var gotOutput bytes.Buffer
1411 err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput)
1412
1413 if tc.wantErr {
1414 if err == nil {
1415 t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String())
1416 }
1417 return
1418 }
1419
1420 if err != nil {
1421 t.Errorf("test %d (%s): input path %q: expected no error with output %q, got error %v with output %q", i, tc.name, tc.path, tc.wantOutput, err, gotOutput.String())
1422 }
1423 if gotOutput.String() != tc.wantOutput {
1424 t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String())
1425 }
1426 })
1427 }
1428 }