-
Notifications
You must be signed in to change notification settings - Fork 53
Expand file tree
/
Copy pathkerberos_integration_test.go
More file actions
601 lines (540 loc) · 20.9 KB
/
Copy pathkerberos_integration_test.go
File metadata and controls
601 lines (540 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
// Copyright 2026 The Alpaca Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build e2e && darwin
// End-to-end test fixture for alpaca's multi-method proxy authentication.
//
// Spins up a single container (testdata/kerberos-e2e/Dockerfile) running
// MIT KDC + squid configured to advertise Negotiate, NTLM and Basic, then
// exercises the full alpaca pipeline against it: PAC-less direct upstream,
// the multi-auth chain, and the security invariants (downgrade refusal,
// proxy-auth allowlist enforcement, ticket re-check).
//
// Build tag is "e2e && darwin": the test exercises alpaca's macOS
// GSS.framework Negotiate path, which is the only Kerberos backend
// implemented in this PR. On other platforms newNegotiateAuthenticator
// returns nil so there's nothing to exercise; the build constraint
// keeps `go test -tags=e2e ./...` working transparently elsewhere.
//
// Run with:
//
// CGO_ENABLED=1 go test -tags=e2e -run TestKerberosE2E -v .
//
// Prerequisites on the host:
// - docker on PATH (Podman should also work via the docker shim)
// - kinit on PATH (Heimdal ships with macOS; krb5-user on Linux)
//
// The test calls t.Skip() when prerequisites are missing so a developer
// without docker doesn't see an unexplained failure.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
imageTag = "alpaca-kerberos-e2e:dev"
containerName = "alpaca-kerberos-e2e"
// All identifiers below are deliberately fictitious. EXAMPLE.TEST
// and *.example.test are reserved for testing per RFC 6761; the
// principals and passwords are baked into the test fixture and
// are NOT real credentials.
realm = "EXAMPLE.TEST"
proxyHost = "proxy.example.test"
kdcHost = "kdc.example.test"
userPrinc = "alice@" + realm
userPassword = "alicepw"
basicUser = "bob"
basicPassword = "bobpw"
// upstreamBody is what the in-container Python http.server returns
// for /. Asserted by every successful e2e sub-test so that a squid
// misconfiguration returning its own 200 page would not silently
// pass.
upstreamBody = "ok\n"
)
// e2eFixture wraps a running test container and exposes the host-side
// ports that the test needs to dial. It also remembers the temporary
// krb5.conf and credential cache so they're cleaned up on teardown.
type e2eFixture struct {
t *testing.T
dockerBin string
proxyHostPort string // e.g. 127.0.0.1:53128
kdcHostPort string // e.g. 127.0.0.1:50088
upstreamURL string // URL squid will fetch and that returns 200 OK
tempDir string
krb5ConfPath string
credCachePath string
}
func TestKerberosE2E(t *testing.T) {
fx := setupFixture(t)
defer fx.teardown()
// All sub-tests share the fixture (one container, one Kerberos
// ticket) so they run quickly. Each sub-test asserts a discrete
// invariant.
t.Run("Negotiate succeeds when ticket is present", fx.testNegotiateSucceeds)
t.Run("Basic succeeds when explicitly advertised", fx.testBasicSucceeds)
t.Run("Multi-method chain prefers Negotiate", fx.testMultiMethodPrefersNegotiate)
t.Run("Falls through to Basic when Negotiate ticket is gone", fx.testFallsThroughOnTicketLoss)
t.Run("Refuses Basic when only NTLM/Negotiate configured against Basic-only proxy", fx.testRefusesBasicDowngrade)
t.Run("proxy-auth allowlist excludes proxy", fx.testProxyAuthAllowlistExclusion)
// Note: there is no e2e sub-test for NTLM because squid's only
// container-friendly NTLM helper (ntlm_fake_auth) emits Type-2
// challenges that go-ntlmssp's strict parser rejects, and a real
// NTLM helper requires a Windows DC. NTLM iteration through the
// multi-method picker is covered at the unit level in
// multiauth_integration_test.go::TestRetryProxyRequest_FallsThroughOn407
// and the cryptographic correctness lives in samuong/go-ntlmssp.
}
// ---------------------------------------------------------------------
// Fixture lifecycle
// ---------------------------------------------------------------------
func setupFixture(t *testing.T) *e2eFixture {
t.Helper()
docker := findDocker(t)
requireBinary(t, "kinit")
fx := &e2eFixture{
t: t,
dockerBin: docker,
// The container bootstrap script starts a Python http.server
// on 127.0.0.1:8080 inside the container. squid forwards to
// it once authentication succeeds, so a 200 from this URL
// proves the auth chain reached the "request forwarded"
// stage. Keeping the upstream inside the container is the
// simplest way to get cross-platform parity: reaching a
// host-side service from the container would require
// host.docker.internal (Docker Desktop only) or
// --add-host=host.docker.internal:host-gateway (Linux Docker
// 20.10+) plus careful firewall handling for inbound
// container-originated traffic on macOS.
upstreamURL: "http://127.0.0.1:8080/",
}
fx.buildImage(t)
fx.runContainer(t)
fx.waitForSquid(t)
fx.kinit(t)
return fx
}
func findDocker(t *testing.T) string {
t.Helper()
for _, candidate := range []string{"docker", "podman"} {
if path, err := exec.LookPath(candidate); err == nil {
return path
}
}
t.Skip("e2e: neither docker nor podman found on PATH")
return ""
}
func requireBinary(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("e2e: %s not found on PATH (skipping; install krb5-user / Heimdal)", name)
}
}
func (fx *e2eFixture) buildImage(t *testing.T) {
t.Helper()
dir, err := filepath.Abs("testdata/kerberos-e2e")
require.NoError(t, err)
args := []string{"build", "-t", imageTag}
// Plumb host's HTTP_PROXY/HTTPS_PROXY through as build args so the
// image can be built behind a corporate proxy. host.docker.internal
// is mapped by Docker Desktop; no-op on Linux Docker (where the
// proxy must already be reachable from container build context).
if proxy := os.Getenv("HTTP_PROXY"); proxy != "" {
// host.docker.internal lets the container reach the host's
// proxy if alpaca itself is running there. Substitute
// localhost references accordingly.
proxy = strings.ReplaceAll(proxy, "localhost", "host.docker.internal")
proxy = strings.ReplaceAll(proxy, "127.0.0.1", "host.docker.internal")
args = append(args, "--build-arg", "HTTP_PROXY="+proxy)
args = append(args, "--build-arg", "HTTPS_PROXY="+proxy)
}
args = append(args, dir)
t.Logf("e2e: building image %s (this may take a few minutes the first time)", imageTag)
cmd := exec.Command(fx.dockerBin, args...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("e2e: docker build failed (skipping; ensure docker daemon is reachable):\n%s", output)
}
}
func (fx *e2eFixture) runContainer(t *testing.T) {
t.Helper()
// Tear down any leftover container from a previous run.
_ = exec.Command(fx.dockerBin, "rm", "-f", containerName).Run()
// Allocate two host ports (squid, KDC). Use 0 to let the kernel
// pick free ports; we'll read them back from `docker port`.
args := []string{
"run", "-d", "--rm",
"--name", containerName,
// Container hardening: defence-in-depth for a test fixture
// that runs locally on developer machines. We keep the
// capabilities the bootstrap script actually needs (CHOWN +
// FOWNER + SETUID/SETGID for the keytab + squid privilege
// drop, plus NET_BIND_SERVICE so krb5kdc can bind :88,
// DAC_OVERRIDE for some squid runtime file ops) and drop
// the rest. squid drops to user proxy:proxy at runtime so
// retained root capabilities only affect bootstrap.
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=FOWNER",
"--cap-add=SETUID",
"--cap-add=SETGID",
"--cap-add=NET_BIND_SERVICE",
"--cap-add=DAC_OVERRIDE",
"--security-opt=no-new-privileges",
"-p", "127.0.0.1::3128",
"-p", "127.0.0.1::88/tcp",
"-p", "127.0.0.1::88/udp",
imageTag,
}
output, err := exec.Command(fx.dockerBin, args...).CombinedOutput()
require.NoErrorf(t, err, "docker run failed:\n%s", output)
// Read back the dynamic ports.
fx.proxyHostPort = fx.dockerPort("3128/tcp")
fx.kdcHostPort = fx.dockerPort("88/tcp")
t.Logf("e2e: container started; squid=%s, kdc=%s",
fx.proxyHostPort, fx.kdcHostPort)
}
func (fx *e2eFixture) dockerPort(internal string) string {
out, err := exec.Command(fx.dockerBin, "port", containerName, internal).Output()
require.NoErrorf(fx.t, err, "docker port %s failed", internal)
// Output looks like "127.0.0.1:53128"
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "127.0.0.1:") {
return line
}
}
fx.t.Fatalf("could not parse docker port output: %q", out)
return ""
}
func (fx *e2eFixture) waitForSquid(t *testing.T) {
t.Helper()
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequest(http.MethodHead, "http://example.com", nil)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(&url.URL{
Scheme: "http",
Host: fx.proxyHostPort,
}),
},
Timeout: 2 * time.Second,
}
resp, err := client.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusProxyAuthRequired {
return
}
}
time.Sleep(500 * time.Millisecond)
}
t.Fatal("e2e: squid did not respond with 407 within 60s; container logs follow:\n" + fx.containerLogs())
}
func (fx *e2eFixture) containerLogs() string {
out, _ := exec.Command(fx.dockerBin, "logs", containerName).CombinedOutput()
return string(out)
}
func (fx *e2eFixture) kinit(t *testing.T) {
t.Helper()
// Build a krb5.conf that tells kinit to reach the KDC on the
// dynamic host port we got from docker. The realm and host names
// match what's inside the container; the KDC entry is the
// host-side address.
fx.tempDir = t.TempDir()
fx.krb5ConfPath = filepath.Join(fx.tempDir, "krb5.conf")
fx.credCachePath = filepath.Join(fx.tempDir, "krb5cc")
conf := fmt.Sprintf(`[libdefaults]
default_realm = %s
dns_lookup_kdc = false
dns_lookup_realm = false
rdns = false
forwardable = true
udp_preference_limit = 1
default_tkt_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
default_tgs_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
[realms]
%s = {
kdc = tcp/%s
admin_server = %s
}
[domain_realm]
.example.test = %s
example.test = %s
`, realm, realm, fx.kdcHostPort, fx.kdcHostPort, realm, realm)
require.NoError(t, os.WriteFile(fx.krb5ConfPath, []byte(conf), 0o600))
// Use t.Setenv so Go's testing framework restores the prior values
// even on panic / Fatal between here and teardown. This matters
// because the test may be invoked on a developer machine that's
// signed in to a real corporate Kerberos realm; without the
// guaranteed restore, a crashed test could leave the developer's
// shell pointing at a tempdir that's been deleted, breaking
// real-world Kerberos until they restart their session.
t.Setenv("KRB5_CONFIG", fx.krb5ConfPath)
t.Setenv("KRB5CCNAME", "FILE:"+fx.credCachePath)
// kinit -V reads from stdin when --password-file isn't supported.
// macOS Heimdal supports both --password-file and stdin via tty,
// so we use stdin which is portable across MIT and Heimdal.
cmd := exec.Command("kinit", userPrinc)
cmd.Stdin = strings.NewReader(userPassword + "\n")
cmd.Env = append(os.Environ(),
"KRB5_CONFIG="+fx.krb5ConfPath,
"KRB5CCNAME=FILE:"+fx.credCachePath,
)
output, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "kinit %s failed:\n%s\nKRB5_CONFIG=%s",
userPrinc, output, fx.krb5ConfPath)
// Verify the ticket landed.
cmd = exec.Command("klist")
cmd.Env = append(os.Environ(),
"KRB5_CONFIG="+fx.krb5ConfPath,
"KRB5CCNAME=FILE:"+fx.credCachePath,
)
output, err = cmd.CombinedOutput()
require.NoErrorf(t, err, "klist failed after kinit:\n%s", output)
require.Contains(t, string(output), realm,
"klist did not show a ticket for %s", realm)
t.Logf("e2e: kinit ok\n%s", strings.TrimSpace(string(output)))
}
func (fx *e2eFixture) teardown() {
if fx.dockerBin != "" {
_ = exec.Command(fx.dockerBin, "rm", "-f", containerName).Run()
}
// KRB5_CONFIG / KRB5CCNAME are restored automatically by t.Setenv.
}
// ---------------------------------------------------------------------
// Test helpers — drive alpaca against the fixture
// ---------------------------------------------------------------------
// proxyURL returns the URL alpaca should treat as the upstream proxy.
// The hostname must resolve to the SPN that squid's keytab signed
// (HTTP/proxy.example.test), so we use proxy.example.test in the URL
// and rely on a custom DialContext to actually connect to the host
// port that docker exposed.
func (fx *e2eFixture) proxyURL() *url.URL {
host, port, _ := net.SplitHostPort(fx.proxyHostPort)
_ = host
return &url.URL{Scheme: "http", Host: net.JoinHostPort(proxyHost, port)}
}
// dialer returns a net.Dialer-style function that rewrites
// proxy.example.test:N to 127.0.0.1:N so the SPN-bearing hostname
// reaches the actual container port.
func (fx *e2eFixture) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
hostPort := fx.proxyHostPort
return func(ctx context.Context, network, addr string) (net.Conn, error) {
if strings.HasPrefix(addr, proxyHost+":") {
addr = hostPort
}
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
}
// transportThroughAlpaca builds an *http.Transport that sends requests
// through the given alpaca chain by invoking the chain helpers
// directly. It mirrors what ProxyHandler does without needing to spin
// up the full middleware stack.
func (fx *e2eFixture) transportThroughAlpaca(chain *authChain) http.RoundTripper {
return &alpacaTestRT{
fx: fx,
chain: chain,
}
}
type alpacaTestRT struct {
fx *e2eFixture
chain *authChain
}
func (a *alpacaTestRT) RoundTrip(req *http.Request) (*http.Response, error) {
proxyURL := a.fx.proxyURL()
tr := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: a.fx.dialer(),
}
defer tr.CloseIdleConnections()
// Decorate the request with the proxy URL so applicableTo /
// negotiateAuthenticator can find it.
req = req.WithContext(context.WithValue(req.Context(),
contextKeyProxy, proxyURL))
// Buffer the body so we can replay across auth retries.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, err
}
_ = req.Body.Close()
}
rd := bytes.NewReader(bodyBytes)
req.Body = io.NopCloser(rd)
resp, err := tr.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusProxyAuthRequired {
return resp, nil
}
if a.chain == nil {
return resp, nil
}
schemes := parseProxyAuthenticateSchemes(resp.Header)
_ = resp.Body.Close()
return retryProxyRequestWithAuth(req, tr, a.chain, schemes, rd)
}
// ---------------------------------------------------------------------
// Sub-tests
// ---------------------------------------------------------------------
// instrumentedBasic wraps a basicAuthenticator with a call counter so
// tests can assert that the basic-auth path was (or was not) invoked.
type instrumentedBasic struct {
*basicAuthenticator
calls atomic.Int32
}
func (b *instrumentedBasic) do(req *http.Request, rt http.RoundTripper) (*http.Response, error) {
b.calls.Add(1)
return b.basicAuthenticator.do(req, rt)
}
func newInstrumentedBasic(creds string) *instrumentedBasic {
return &instrumentedBasic{basicAuthenticator: newBasicAuthenticator(creds)}
}
func (fx *e2eFixture) testNegotiateSucceeds(t *testing.T) {
neg := newNegotiateAuthenticator()
require.NotNil(t, neg, "expected newNegotiateAuthenticator to find the kinit'd ticket")
chain := newAuthChain(neg)
require.NotNil(t, chain)
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
require.NoError(t, err)
assertSuccessful200(t, resp)
}
func (fx *e2eFixture) testBasicSucceeds(t *testing.T) {
basic := newBasicAuthenticator(basicUser + ":" + basicPassword)
chain := newAuthChain(basic)
require.NotNil(t, chain)
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
require.NoError(t, err)
assertSuccessful200(t, resp)
}
func (fx *e2eFixture) testMultiMethodPrefersNegotiate(t *testing.T) {
// All methods configured. Negotiate should be tried first and
// should succeed; the instrumented Basic must NOT be invoked.
// This is the explicit "no fallthrough to Basic" assertion the
// previous version of this test only proved by elimination.
neg := newNegotiateAuthenticator()
require.NotNil(t, neg)
basic := newInstrumentedBasic(basicUser + ":" + basicPassword)
chain := newAuthChain(neg, basic)
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
require.NoError(t, err)
assertSuccessful200(t, resp)
assert.EqualValues(t, 0, basic.calls.Load(),
"Basic must not be invoked when Negotiate succeeded first")
}
func (fx *e2eFixture) testFallsThroughOnTicketLoss(t *testing.T) {
// Build a chain whose Negotiate "loses" its ticket between picker
// time and request time by overriding hasTicket to return false.
// applicableTo will then exclude Negotiate, picker falls through
// to Basic.
neg := newNegotiateAuthenticator()
require.NotNil(t, neg)
negotiator, ok := neg.(*negotiateAuthenticator)
require.True(t, ok)
negotiator.hasTicket = func() bool { return false }
basic := newBasicAuthenticator(basicUser + ":" + basicPassword)
chain := newAuthChain(negotiator, basic)
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
require.NoError(t, err)
assertSuccessful200(t, resp)
}
func (fx *e2eFixture) testRefusesBasicDowngrade(t *testing.T) {
// Configure ONLY Negotiate, then deliberately ineligible-ate it
// via hasTicket=false. The picker must yield zero candidates and
// the loop returns errNoMatchingAuthMethod, NOT silently send
// Basic credentials.
neg := newNegotiateAuthenticator()
require.NotNil(t, neg)
negotiator, ok := neg.(*negotiateAuthenticator)
require.True(t, ok)
negotiator.hasTicket = func() bool { return false }
chain := newAuthChain(negotiator)
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
if err == nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.ErrorIs(t, err, errNoMatchingAuthMethod)
}
func (fx *e2eFixture) testProxyAuthAllowlistExclusion(t *testing.T) {
// hostAllowlist that does NOT match proxyHost. The chain-level
// allowlist must return zero candidates from pick(), uniformly
// across all auth methods.
neg := newNegotiateAuthenticator()
require.NotNil(t, neg)
// Without a fallback method, the chain must error because the
// host is excluded outright (no method even attempted).
chain := newAuthChain(neg)
require.NotNil(t, chain)
chain.hostAllowlist = parseAuthAllowlist(".unrelated.test")
resp, err := fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
if err == nil {
_ = resp.Body.Close()
}
require.Error(t, err,
"expected proxy-auth allowlist exclusion to surface as errNoMatchingAuthMethod")
// With Basic also configured, the chain-level allowlist must
// still exclude every method (the gate is at chain level, not
// per-authenticator).
basic := newBasicAuthenticator(basicUser + ":" + basicPassword)
chain = newAuthChain(neg, basic)
require.NotNil(t, chain)
chain.hostAllowlist = parseAuthAllowlist(".unrelated.test")
resp, err = fx.transportThroughAlpaca(chain).RoundTrip(mustReq(t, fx))
if err == nil {
_ = resp.Body.Close()
}
require.Error(t, err,
"chain-level allowlist must exclude every method, including Basic")
}
// assertSuccessful200 verifies that resp is a 200 from the in-container
// upstream test server (body equals upstreamBody) — not, say, squid's own
// 200-shaped error page. Closes the body when done.
func assertSuccessful200(t *testing.T, resp *http.Response) {
t.Helper()
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, upstreamBody, string(body),
"expected upstream test server response, not a squid synthesised page")
}
// mustReq builds a fresh request to the upstream test server. Squid
// will forward this once authentication succeeds; a 200 with body
// `upstreamBody` from the in-container HTTP server is what proves the
// auth chain worked end-to-end.
func mustReq(t *testing.T, fx *e2eFixture) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fx.upstreamURL, nil)
require.NoError(t, err)
return req
}