1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "testing"
10 "time"
11
12 "connectrpc.com/connect"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22const peer = "203.0.113.7:4242"
23
24func bearerHeader(v string) http.Header {
25 h := http.Header{}
26 if v != "" {
27 h.Set("Authorization", v)
28 }
29 return h
30}
31
32func TestInterceptorAuthenticate(t *testing.T) {
33 t.Parallel()
34 const procedure = ermiteconnect.RepoServiceListReposProcedure
35 tests := []struct {
36 name string
37 procedure string
38 header http.Header
39 blocked bool
40 authErr error
41 wantCode connect.Code // 0: success
42 wantAuth bool
43 wantFail bool
44 wantLimit bool // the limiter is asked at all
45 wantRetry string
46 wantOwner bool
47 }{
48 {name: "no header is anonymous", procedure: procedure, header: bearerHeader(""), wantLimit: true},
49 {name: "valid token", procedure: procedure, header: bearerHeader("Bearer good"), wantLimit: true, wantAuth: true, wantOwner: true},
50 {
51 name: "invalid token counts as a failure", procedure: procedure, header: bearerHeader("Bearer bad"),
52 authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true,
53 },
54 {
55 name: "non-bearer scheme is a failure", procedure: procedure, header: bearerHeader("Basic abc"),
56 authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true,
57 },
58 {
59 name: "authenticator outage is not a failure of the client", procedure: procedure, header: bearerHeader("Bearer good"),
60 authErr: errors.New("store down"), wantCode: connect.CodeInternal, wantLimit: true, wantAuth: true,
61 },
62 {
63 name: "blocked client is refused with Retry-After", procedure: procedure, header: bearerHeader("Bearer good"),
64 blocked: true, wantCode: connect.CodeResourceExhausted, wantLimit: true, wantRetry: "30",
65 },
66 {name: "version needs no authentication", procedure: ermiteconnect.ServerServiceGetVersionProcedure, header: bearerHeader("Bearer expired")},
67 {name: "signing key needs no authentication", procedure: ermiteconnect.ServerServiceGetSigningKeyProcedure, header: bearerHeader("Bearer expired")},
68 }
69 for _, tt := range tests {
70 t.Run(tt.name, func(t *testing.T) {
71 t.Parallel()
72 auth := mocks.NewAuthService(t)
73 limiter := mocks.NewFailureLimiter(t)
74 if tt.wantLimit {
75 wait := time.Duration(0)
76 if tt.blocked {
77 wait = 29*time.Second + 500*time.Millisecond
78 }
79 limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(tt.blocked, wait).Once()
80 }
81 if tt.wantAuth {
82 p := domain.Principal{}
83 if tt.wantOwner {
84 p = domain.Owner()
85 }
86 auth.EXPECT().Authenticate(mock.Anything, mock.Anything).Return(p, tt.authErr).Once()
87 }
88 if tt.wantFail {
89 limiter.EXPECT().Fail(mock.Anything, "203.0.113.7").Once()
90 }
91 i := newInterceptor(Config{Auth: auth, Limiter: limiter})
92 ctx, err := i.authenticate(context.Background(), tt.procedure, peer, tt.header)
93 if tt.wantCode != 0 {
94 requireCode(t, tt.wantCode, err)
95 if tt.wantRetry != "" {
96 var ce *connect.Error
97 require.ErrorAs(t, err, &ce)
98 assert.Equal(t, tt.wantRetry, ce.Meta().Get("Retry-After"))
99 }
100 return
101 }
102 require.NoError(t, err)
103 p, ok := domain.PrincipalFromContext(ctx)
104 require.True(t, ok)
105 if tt.wantOwner {
106 assert.Equal(t, domain.Owner(), p)
107 } else {
108 assert.Equal(t, domain.Anonymous(), p)
109 }
110 })
111 }
112}
113
114func TestInterceptorIgnoresForwardedForFromUntrustedPeer(t *testing.T) {
115 t.Parallel()
116 limiter := mocks.NewFailureLimiter(t)
117 limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(false, time.Duration(0)).Once()
118 h := bearerHeader("")
119 h.Set("X-Forwarded-For", "198.51.100.1")
120 i := newInterceptor(Config{Auth: mocks.NewAuthService(t), Limiter: limiter})
121 _, err := i.authenticate(context.Background(), ermiteconnect.RepoServiceListReposProcedure, peer, h)
122 require.NoError(t, err)
123}