go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/client_test.go (about) 1 // Copyright 2016 The LUCI 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 auth 16 17 import ( 18 "bytes" 19 "context" 20 "io" 21 "net/http" 22 "strings" 23 "testing" 24 "time" 25 26 "golang.org/x/oauth2" 27 28 "go.chromium.org/luci/auth" 29 "go.chromium.org/luci/auth/identity" 30 "go.chromium.org/luci/common/clock" 31 32 "go.chromium.org/luci/server/auth/signing" 33 "go.chromium.org/luci/server/auth/signing/signingtest" 34 35 . "github.com/smartystreets/goconvey/convey" 36 ) 37 38 func TestGetRPCTransport(t *testing.T) { 39 t.Parallel() 40 41 const ownServiceAccountName = "service-own-sa@example.com" 42 43 Convey("GetRPCTransport works", t, func() { 44 ctx := context.Background() 45 mock := &clientRPCTransportMock{} 46 ctx = ModifyConfig(ctx, func(cfg Config) Config { 47 cfg.AccessTokenProvider = mock.getAccessToken 48 cfg.AnonymousTransport = mock.getTransport 49 cfg.Signer = signingtest.NewSigner(&signing.ServiceInfo{ 50 ServiceAccountName: ownServiceAccountName, 51 }) 52 return cfg 53 }) 54 55 Convey("in NoAuth mode", func(c C) { 56 t, err := GetRPCTransport(ctx, NoAuth) 57 So(err, ShouldBeNil) 58 _, err = t.RoundTrip(makeReq("https://example.com")) 59 So(err, ShouldBeNil) 60 61 So(len(mock.calls), ShouldEqual, 0) 62 So(len(mock.reqs[0].Header), ShouldEqual, 0) 63 }) 64 65 Convey("in AsSelf mode", func(c C) { 66 t, err := GetRPCTransport(ctx, AsSelf, WithScopes("A", "B")) 67 So(err, ShouldBeNil) 68 _, err = t.RoundTrip(makeReq("https://example.com")) 69 So(err, ShouldBeNil) 70 71 So(mock.calls[0], ShouldResemble, []string{"A", "B"}) 72 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 73 "Authorization": {"Bearer as-self-token:A,B"}, 74 }) 75 }) 76 77 Convey("in AsSelf mode with default scopes", func(c C) { 78 t, err := GetRPCTransport(ctx, AsSelf) 79 So(err, ShouldBeNil) 80 _, err = t.RoundTrip(makeReq("https://example.com")) 81 So(err, ShouldBeNil) 82 83 So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"}) 84 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 85 "Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"}, 86 }) 87 }) 88 89 Convey("in AsSelf mode with ID token, static aud", func(c C) { 90 mocks := &rpcMocks{ 91 MintIDTokenForServiceAccount: func(ic context.Context, p MintIDTokenParams) (*Token, error) { 92 So(p, ShouldResemble, MintIDTokenParams{ 93 ServiceAccount: ownServiceAccountName, 94 Audience: "https://example.com/aud", 95 MinTTL: 2 * time.Minute, 96 }) 97 return &Token{ 98 Token: "id-token", 99 Expiry: clock.Now(ic).Add(time.Hour), 100 }, nil 101 }, 102 } 103 104 t, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("https://example.com/aud"), mocks) 105 So(err, ShouldBeNil) 106 _, err = t.RoundTrip(makeReq("https://another.example.com")) 107 So(err, ShouldBeNil) 108 109 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 110 "Authorization": {"Bearer id-token"}, 111 }) 112 }) 113 114 Convey("in AsSelf mode with ID token, pattern aud", func(c C) { 115 mocks := &rpcMocks{ 116 MintIDTokenForServiceAccount: func(ic context.Context, p MintIDTokenParams) (*Token, error) { 117 So(p, ShouldResemble, MintIDTokenParams{ 118 ServiceAccount: ownServiceAccountName, 119 Audience: "https://another.example.com:443/aud", 120 MinTTL: 2 * time.Minute, 121 }) 122 return &Token{ 123 Token: "id-token", 124 Expiry: clock.Now(ic).Add(time.Hour), 125 }, nil 126 }, 127 } 128 129 t, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("https://${host}/aud"), mocks) 130 So(err, ShouldBeNil) 131 _, err = t.RoundTrip(makeReq("https://another.example.com:443")) 132 So(err, ShouldBeNil) 133 134 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 135 "Authorization": {"Bearer id-token"}, 136 }) 137 }) 138 139 Convey("in AsUser mode, authenticated", func(c C) { 140 ctx := WithState(ctx, &state{ 141 user: &User{Identity: "user:abc@example.com"}, 142 }) 143 144 t, err := GetRPCTransport(ctx, AsUser, WithDelegationTags("a:b", "c:d"), &rpcMocks{ 145 MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) { 146 c.So(p, ShouldResemble, DelegationTokenParams{ 147 TargetHost: "example.com", 148 Tags: []string{"a:b", "c:d"}, 149 MinTTL: 10 * time.Minute, 150 }) 151 return &Token{Token: "deleg_tok"}, nil 152 }, 153 }) 154 So(err, ShouldBeNil) 155 _, err = t.RoundTrip(makeReq("https://example.com/some-path/sd")) 156 So(err, ShouldBeNil) 157 158 So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"}) 159 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 160 "Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"}, 161 "X-Delegation-Token-V1": {"deleg_tok"}, 162 }) 163 }) 164 165 Convey("in AsProject mode", func(c C) { 166 callExampleCom := func(ctx context.Context) { 167 t, err := GetRPCTransport(ctx, AsProject, WithProject("infra"), &rpcMocks{ 168 MintProjectToken: func(ic context.Context, p ProjectTokenParams) (*Token, error) { 169 c.So(p, ShouldResemble, ProjectTokenParams{ 170 MinTTL: 2 * time.Minute, 171 LuciProject: "infra", 172 OAuthScopes: defaultOAuthScopes, 173 }) 174 return &Token{ 175 Token: "scoped tok", 176 Expiry: clock.Now(ctx).Add(time.Hour), 177 }, nil 178 }, 179 }) 180 So(err, ShouldBeNil) 181 _, err = t.RoundTrip(makeReq("https://example.com/some-path/sd")) 182 So(err, ShouldBeNil) 183 } 184 185 Convey("external service", func() { 186 callExampleCom(WithState(ctx, &state{ 187 db: &fakeDB{internalService: "not-example.com"}, 188 })) 189 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 190 "Authorization": {"Bearer scoped tok"}, 191 }) 192 }) 193 194 Convey("internal service", func() { 195 callExampleCom(WithState(ctx, &state{ 196 db: &fakeDB{internalService: "example.com"}, 197 })) 198 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 199 "Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"}, 200 "X-Luci-Project": {"infra"}, 201 }) 202 }) 203 }) 204 205 Convey("in AsUser mode, anonymous", func(c C) { 206 ctx := WithState(ctx, &state{ 207 user: &User{Identity: identity.AnonymousIdentity}, 208 }) 209 210 t, err := GetRPCTransport(ctx, AsUser, &rpcMocks{ 211 MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) { 212 panic("must not be called") 213 }, 214 }) 215 So(err, ShouldBeNil) 216 _, err = t.RoundTrip(makeReq("https://example.com")) 217 So(err, ShouldBeNil) 218 So(mock.reqs[0].Header, ShouldResemble, http.Header{}) 219 }) 220 221 Convey("in AsUser mode, with existing token", func(c C) { 222 ctx := WithState(ctx, &state{ 223 user: &User{Identity: identity.AnonymousIdentity}, 224 }) 225 226 t, err := GetRPCTransport(ctx, AsUser, WithDelegationToken("deleg_tok"), &rpcMocks{ 227 MintDelegationToken: func(ic context.Context, p DelegationTokenParams) (*Token, error) { 228 panic("must not be called") 229 }, 230 }) 231 So(err, ShouldBeNil) 232 _, err = t.RoundTrip(makeReq("https://example.com")) 233 So(err, ShouldBeNil) 234 235 So(mock.calls[0], ShouldResemble, []string{"https://www.googleapis.com/auth/userinfo.email"}) 236 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 237 "Authorization": {"Bearer as-self-token:https://www.googleapis.com/auth/userinfo.email"}, 238 "X-Delegation-Token-V1": {"deleg_tok"}, 239 }) 240 }) 241 242 Convey("in AsUser mode with both delegation tags and token", func(c C) { 243 _, err := GetRPCTransport( 244 ctx, AsUser, WithDelegationToken("deleg_tok"), WithDelegationTags("a:b")) 245 So(err, ShouldNotBeNil) 246 }) 247 248 Convey("in NoAuth mode with delegation tags, should error", func(c C) { 249 _, err := GetRPCTransport(ctx, NoAuth, WithDelegationTags("a:b")) 250 So(err, ShouldNotBeNil) 251 }) 252 253 Convey("in NoAuth mode with scopes, should error", func(c C) { 254 _, err := GetRPCTransport(ctx, NoAuth, WithScopes("A")) 255 So(err, ShouldNotBeNil) 256 }) 257 258 Convey("in NoAuth mode with ID token, should error", func(c C) { 259 _, err := GetRPCTransport(ctx, NoAuth, WithIDTokenAudience("aud")) 260 So(err, ShouldNotBeNil) 261 }) 262 263 Convey("in AsSelf mode with ID token and scopes, should error", func(c C) { 264 _, err := GetRPCTransport(ctx, AsSelf, WithScopes("A"), WithIDTokenAudience("aud")) 265 So(err, ShouldNotBeNil) 266 }) 267 268 Convey("in AsSelf mode with bad aud pattern, should error", func(c C) { 269 _, err := GetRPCTransport(ctx, AsSelf, WithIDTokenAudience("${huh}")) 270 So(err, ShouldNotBeNil) 271 }) 272 273 Convey("in AsCredentialsForwarder mode, anonymous", func(c C) { 274 ctx := WithState(ctx, &state{ 275 user: &User{Identity: identity.AnonymousIdentity}, 276 endUserErr: ErrNoForwardableCreds, 277 }) 278 279 t, err := GetRPCTransport(ctx, AsCredentialsForwarder) 280 So(err, ShouldBeNil) 281 _, err = t.RoundTrip(makeReq("https://example.com")) 282 So(err, ShouldBeNil) 283 284 // No credentials passed. 285 So(mock.reqs[0].Header, ShouldHaveLength, 0) 286 }) 287 288 Convey("in AsCredentialsForwarder mode, non-anonymous", func(c C) { 289 ctx := WithState(ctx, &state{ 290 user: &User{Identity: "user:a@example.com"}, 291 endUserTok: &oauth2.Token{ 292 TokenType: "Bearer", 293 AccessToken: "abc.def", 294 }, 295 endUserExtraHeaders: map[string]string{"X-Extra": "val"}, 296 }) 297 298 t, err := GetRPCTransport(ctx, AsCredentialsForwarder) 299 So(err, ShouldBeNil) 300 _, err = t.RoundTrip(makeReq("https://example.com")) 301 So(err, ShouldBeNil) 302 303 // Passed the token and the extra header. 304 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 305 "Authorization": {"Bearer abc.def"}, 306 "X-Extra": {"val"}, 307 }) 308 }) 309 310 Convey("in AsCredentialsForwarder mode, non-forwardable", func(c C) { 311 ctx := WithState(ctx, &state{ 312 user: &User{Identity: "user:a@example.com"}, 313 endUserErr: ErrNoForwardableCreds, 314 }) 315 316 _, err := GetRPCTransport(ctx, AsCredentialsForwarder) 317 So(err, ShouldEqual, ErrNoForwardableCreds) 318 }) 319 320 Convey("in AsActor mode with account", func(c C) { 321 mocks := &rpcMocks{ 322 MintAccessTokenForServiceAccount: func(ic context.Context, p MintAccessTokenParams) (*Token, error) { 323 So(p, ShouldResemble, MintAccessTokenParams{ 324 ServiceAccount: "abc@example.com", 325 Scopes: []string{auth.OAuthScopeEmail}, 326 MinTTL: 2 * time.Minute, 327 }) 328 return &Token{ 329 Token: "blah-blah", 330 Expiry: clock.Now(ic).Add(time.Hour), 331 }, nil 332 }, 333 } 334 335 t, err := GetRPCTransport(ctx, AsActor, WithServiceAccount("abc@example.com"), mocks) 336 So(err, ShouldBeNil) 337 338 _, err = t.RoundTrip(makeReq("https://example.com")) 339 So(err, ShouldBeNil) 340 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 341 "Authorization": {"Bearer blah-blah"}, 342 }) 343 }) 344 345 Convey("in AsActor mode without account, error", func(c C) { 346 _, err := GetRPCTransport(ctx, AsActor) 347 So(err, ShouldNotBeNil) 348 }) 349 350 Convey("in AsProject mode without project, error", func(c C) { 351 _, err := GetRPCTransport(ctx, AsProject) 352 So(err, ShouldNotBeNil) 353 }) 354 355 Convey("in AsSessionUser mode without session", func(c C) { 356 _, err := GetRPCTransport(ctx, AsSessionUser) 357 So(err, ShouldEqual, nil) 358 }) 359 360 Convey("in AsSessionUser mode", func(c C) { 361 ctx := WithState(ctx, &state{ 362 user: &User{Identity: "user:abc@example.com"}, 363 session: &fakeSession{ 364 accessToken: &oauth2.Token{ 365 TokenType: "Bearer", 366 AccessToken: "access_token", 367 }, 368 idToken: &oauth2.Token{ 369 TokenType: "Bearer", 370 AccessToken: "id_token", 371 }, 372 }, 373 }) 374 375 Convey("OAuth2 token", func() { 376 t, err := GetRPCTransport(ctx, AsSessionUser) 377 So(err, ShouldBeNil) 378 _, err = t.RoundTrip(makeReq("https://example.com")) 379 So(err, ShouldBeNil) 380 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 381 "Authorization": {"Bearer access_token"}, 382 }) 383 }) 384 385 Convey("ID token", func() { 386 t, err := GetRPCTransport(ctx, AsSessionUser, WithIDToken()) 387 So(err, ShouldBeNil) 388 _, err = t.RoundTrip(makeReq("https://example.com")) 389 So(err, ShouldBeNil) 390 So(mock.reqs[0].Header, ShouldResemble, http.Header{ 391 "Authorization": {"Bearer id_token"}, 392 }) 393 }) 394 395 Convey("Trying to override scopes", func() { 396 _, err := GetRPCTransport(ctx, AsSessionUser, WithScopes("a")) 397 So(err, ShouldNotBeNil) 398 }) 399 400 Convey("Trying to override aud", func() { 401 _, err := GetRPCTransport(ctx, AsSessionUser, WithIDTokenAudience("aud")) 402 So(err, ShouldNotBeNil) 403 }) 404 }) 405 406 Convey("when headers are needed, Request context is used", func() { 407 root := ctx 408 409 // Contexts with different auth state. 410 ctx1 := WithState(root, &state{user: &User{Identity: "user:abc@example.com"}}) 411 ctx2 := WithState(root, &state{user: &User{Identity: "user:abc@example.com"}}) 412 413 // Use a mode which actually uses transport context to compute headers. 414 run := func(c C, reqCtx, transCtx context.Context) (usedCtx context.Context) { 415 mocks := &rpcMocks{ 416 MintAccessTokenForServiceAccount: func(ic context.Context, _ MintAccessTokenParams) (*Token, error) { 417 usedCtx = ic 418 return &Token{ 419 Token: "blah", 420 Expiry: clock.Now(ic).Add(time.Hour), 421 }, nil 422 }, 423 } 424 t, err := GetRPCTransport(transCtx, AsActor, WithServiceAccount("abc@example.com"), mocks) 425 c.So(err, ShouldBeNil) 426 req := makeReq("https://example.com") 427 if reqCtx != nil { 428 req = req.WithContext(reqCtx) 429 } 430 _, err = t.RoundTrip(req) 431 c.So(err, ShouldBeNil) 432 return 433 } 434 435 Convey("no request context", func(c C) { 436 So(run(c, nil, ctx1), ShouldEqual, ctx1) 437 }) 438 439 Convey("same context", func(c C) { 440 So(run(c, ctx1, ctx1), ShouldEqual, ctx1) 441 }) 442 443 Convey("uses request context", func(c C) { 444 reqCtx, cancel := context.WithTimeout(ctx1, time.Minute) 445 defer cancel() 446 transCtx, cancel := context.WithTimeout(ctx1, time.Hour) 447 defer cancel() 448 So(run(c, reqCtx, transCtx), ShouldEqual, reqCtx) 449 }) 450 451 Convey("OK on two background contexts", func(c C) { 452 reqCtx, cancel := context.WithTimeout(root, time.Minute) 453 defer cancel() 454 transCtx, cancel := context.WithTimeout(root, time.Hour) 455 defer cancel() 456 So(run(c, reqCtx, transCtx), ShouldEqual, reqCtx) 457 }) 458 459 Convey("request ctx is user, transport is background", func(c C) { 460 reqCtx, cancel := context.WithTimeout(ctx1, time.Minute) 461 reqCtxDeadline, _ := reqCtx.Deadline() 462 defer cancel() 463 transCtx, cancel := context.WithTimeout(root, time.Hour) 464 defer cancel() 465 // Used `reqCtx` for the deadline, but have background auth state. 466 usedCtx := run(c, reqCtx, transCtx) 467 usedDeadline, _ := usedCtx.Deadline() 468 So(usedDeadline.Equal(reqCtxDeadline), ShouldBeTrue) 469 So(GetState(usedCtx), ShouldResemble, GetState(transCtx)) 470 }) 471 472 Convey("request ctx is background, transport is user", func(c C) { 473 So(func() { 474 reqCtx, cancel := context.WithTimeout(root, time.Minute) 475 defer cancel() 476 transCtx, cancel := context.WithTimeout(ctx1, time.Hour) 477 defer cancel() 478 run(c, reqCtx, transCtx) 479 }, ShouldPanic) 480 }) 481 482 Convey("panics on contexts with different auth state", func(c C) { 483 So(func() { 484 reqCtx, cancel := context.WithTimeout(ctx1, time.Minute) 485 defer cancel() 486 transCtx, cancel := context.WithTimeout(ctx2, time.Hour) 487 defer cancel() 488 run(c, reqCtx, transCtx) 489 }, ShouldPanic) 490 }) 491 }) 492 }) 493 } 494 495 func TestTokenSource(t *testing.T) { 496 t.Parallel() 497 498 Convey("GetTokenSource works", t, func() { 499 ctx := context.Background() 500 mock := &clientRPCTransportMock{} 501 ctx = ModifyConfig(ctx, func(cfg Config) Config { 502 cfg.AccessTokenProvider = mock.getAccessToken 503 cfg.AnonymousTransport = mock.getTransport 504 return cfg 505 }) 506 507 Convey("With no scopes", func() { 508 ts, err := GetTokenSource(ctx, AsSelf) 509 So(err, ShouldBeNil) 510 tok, err := ts.Token() 511 So(err, ShouldBeNil) 512 So(tok, ShouldResemble, &oauth2.Token{ 513 AccessToken: "as-self-token:https://www.googleapis.com/auth/userinfo.email", 514 TokenType: "Bearer", 515 }) 516 }) 517 518 Convey("With a specific list of scopes", func() { 519 ts, err := GetTokenSource(ctx, AsSelf, WithScopes("foo", "bar", "baz")) 520 So(err, ShouldBeNil) 521 tok, err := ts.Token() 522 So(err, ShouldBeNil) 523 So(tok, ShouldResemble, &oauth2.Token{ 524 AccessToken: "as-self-token:foo,bar,baz", 525 TokenType: "Bearer", 526 }) 527 }) 528 529 Convey("With ID token, static aud", func() { 530 _, err := GetTokenSource(ctx, AsSelf, WithIDTokenAudience("https://host.example.com")) 531 So(err, ShouldBeNil) 532 }) 533 534 Convey("With ID token, pattern aud", func() { 535 _, err := GetTokenSource(ctx, AsSelf, WithIDTokenAudience("https://${host}")) 536 So(err, ShouldNotBeNil) 537 }) 538 539 Convey("NoAuth is not allowed", func() { 540 ts, err := GetTokenSource(ctx, NoAuth) 541 So(ts, ShouldBeNil) 542 So(err, ShouldNotBeNil) 543 }) 544 545 Convey("AsUser is not allowed", func() { 546 ts, err := GetTokenSource(ctx, AsUser) 547 So(ts, ShouldBeNil) 548 So(err, ShouldNotBeNil) 549 }) 550 }) 551 } 552 553 func TestParseAudPattern(t *testing.T) { 554 t.Parallel() 555 556 Convey("Works", t, func() { 557 cb, err := parseAudPattern("https://${host}/zzz") 558 So(err, ShouldBeNil) 559 560 s, err := cb(&http.Request{ 561 Host: "something.example.com:443", 562 }) 563 So(err, ShouldBeNil) 564 So(s, ShouldEqual, "https://something.example.com:443/zzz") 565 }) 566 567 Convey("Static", t, func() { 568 cb, err := parseAudPattern("no-vars-here") 569 So(cb, ShouldBeNil) 570 So(err, ShouldBeNil) 571 }) 572 573 Convey("Malformed", t, func() { 574 cb, err := parseAudPattern("aaa-${host)-bbb") 575 So(cb, ShouldBeNil) 576 So(err, ShouldNotBeNil) 577 }) 578 579 Convey("Unknown var", t, func() { 580 cb, err := parseAudPattern("aaa-${unknown}-bbb") 581 So(cb, ShouldBeNil) 582 So(err, ShouldNotBeNil) 583 }) 584 } 585 586 func makeReq(url string) *http.Request { 587 req, err := http.NewRequest("GET", url, nil) 588 if err != nil { 589 panic(err) 590 } 591 return req 592 } 593 594 type fakeSession struct { 595 accessToken *oauth2.Token 596 idToken *oauth2.Token 597 } 598 599 func (s *fakeSession) AccessToken(ctx context.Context) (*oauth2.Token, error) { 600 return s.accessToken, nil 601 } 602 603 func (s *fakeSession) IDToken(ctx context.Context) (*oauth2.Token, error) { 604 return s.idToken, nil 605 } 606 607 type clientRPCTransportMock struct { 608 calls [][]string 609 reqs []*http.Request 610 611 cb func(req *http.Request, body string) string 612 } 613 614 func (m *clientRPCTransportMock) getAccessToken(ctx context.Context, scopes []string) (*oauth2.Token, error) { 615 m.calls = append(m.calls, scopes) 616 return &oauth2.Token{ 617 AccessToken: "as-self-token:" + strings.Join(scopes, ","), 618 TokenType: "Bearer", 619 }, nil 620 } 621 622 func (m *clientRPCTransportMock) getTransport(ctx context.Context) http.RoundTripper { 623 return m 624 } 625 626 func (m *clientRPCTransportMock) RoundTrip(req *http.Request) (*http.Response, error) { 627 m.reqs = append(m.reqs, req) 628 code := 500 629 resp := "internal error" 630 if req.Body != nil { 631 body, err := io.ReadAll(req.Body) 632 req.Body.Close() 633 if err != nil { 634 return nil, err 635 } 636 if m.cb != nil { 637 code = 200 638 resp = m.cb(req, string(body)) 639 } 640 } 641 return &http.Response{ 642 StatusCode: code, 643 Body: io.NopCloser(bytes.NewReader([]byte(resp))), 644 }, nil 645 }