The shared VCR BeforeSave hook scrubs every other header-auth provider's key (X-Api-Key, Api-Key, Signoz-Api-Key, X-Auth-Token) but was not updated for Dotfile, so re-recording testdata/dotfile.yaml with a real X-DOTFILE-API-KEY would persist the key into the committed cassette. Delete the canonicalized X-Dotfile-Api-Key header alongside the others. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
192 lines
6.4 KiB
Go
192 lines
6.4 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package drivers
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
|
|
"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
|
|
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
|
|
)
|
|
|
|
// versionedClientHeaders are HTTP headers that encode SDK or client library
|
|
// versions. They are ignored by the matcher so cassettes keep replaying after
|
|
// dependency bumps.
|
|
var versionedClientHeaders = []string{"User-Agent", "X-Goog-Api-Client"}
|
|
|
|
// newRecorder creates a go-vcr recorder for the given cassette path. When
|
|
// the env var is non-empty the recorder runs in record mode, otherwise
|
|
// it replays from the committed cassette. A BeforeSave hook strips the
|
|
// Authorization header so tokens are never persisted.
|
|
func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Recorder {
|
|
t.Helper()
|
|
|
|
mode := recorder.ModeReplayOnly
|
|
if os.Getenv(envVar) != "" {
|
|
mode = recorder.ModeRecordOnly
|
|
}
|
|
|
|
rec, err := recorder.New(
|
|
cassettePath,
|
|
recorder.WithMode(mode),
|
|
recorder.WithSkipRequestLatency(true),
|
|
recorder.WithMatcher(cassette.NewDefaultMatcher(
|
|
cassette.WithIgnoreAuthorization(),
|
|
cassette.WithIgnoreHeaders(versionedClientHeaders...),
|
|
)),
|
|
recorder.WithHook(func(i *cassette.Interaction) error {
|
|
i.Request.Headers.Del("Authorization")
|
|
// Providers like Anthropic (x-api-key), SigNoz
|
|
// (SIGNOZ-API-KEY) and Brevo (api-key) authenticate via a
|
|
// custom header rather than Authorization; strip those too so a
|
|
// re-record never persists a raw key.
|
|
i.Request.Headers.Del("X-Api-Key")
|
|
i.Request.Headers.Del("Signoz-Api-Key")
|
|
i.Request.Headers.Del("Api-Key")
|
|
// Scaleway authenticates with the secret key in X-Auth-Token.
|
|
i.Request.Headers.Del("X-Auth-Token")
|
|
// Dotfile authenticates with the key in X-DOTFILE-API-KEY
|
|
// (canonicalized to X-Dotfile-Api-Key).
|
|
i.Request.Headers.Del("X-Dotfile-Api-Key")
|
|
|
|
return nil
|
|
}, recorder.BeforeSaveHook),
|
|
)
|
|
if err != nil {
|
|
if mode == recorder.ModeReplayOnly {
|
|
t.Skipf("cassette not found (record with %s env var): %v", envVar, err)
|
|
}
|
|
|
|
t.Fatalf("cannot create vcr recorder: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
if err := rec.Stop(); err != nil {
|
|
t.Errorf("cannot stop vcr recorder: %v", err)
|
|
}
|
|
})
|
|
|
|
return rec
|
|
}
|
|
|
|
// authRoundTripper wraps a transport and injects an Authorization header
|
|
// into each request. The authValue is set as-is (caller provides "Bearer xxx"
|
|
// or a raw API key depending on the provider).
|
|
type authRoundTripper struct {
|
|
authValue string
|
|
transport http.RoundTripper
|
|
}
|
|
|
|
func (rt *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if rt.authValue != "" {
|
|
req.Header.Set("Authorization", rt.authValue)
|
|
}
|
|
|
|
return rt.transport.RoundTrip(req)
|
|
}
|
|
|
|
// bearerAuth returns "Bearer <token>" if the token is non-empty, or "" otherwise.
|
|
func bearerAuth(token string) string {
|
|
if token == "" {
|
|
return ""
|
|
}
|
|
|
|
return "Bearer " + token
|
|
}
|
|
|
|
// basicAuth returns the HTTP Basic auth header value for a username with
|
|
// an empty password ("Basic base64(<username>:)"), or "" if the username
|
|
// is empty. Cursor presents its admin API key as the Basic auth username.
|
|
func basicAuth(username string) string {
|
|
if username == "" {
|
|
return ""
|
|
}
|
|
|
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"))
|
|
}
|
|
|
|
// basicAuthUserPass returns the HTTP Basic auth header value for a credential
|
|
// that already holds the "username:password" pair ("Basic
|
|
// base64(<credential>)"), or "" if the credential is empty. ClickHouse
|
|
// Cloud (keyId:keySecret) and Langfuse (publicKey:secretKey) present such
|
|
// a credential. The matcher ignores Authorization, so this only matters
|
|
// when re-recording.
|
|
func basicAuthUserPass(credential string) string {
|
|
if credential == "" {
|
|
return ""
|
|
}
|
|
|
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credential))
|
|
}
|
|
|
|
// newVCRClient creates an *http.Client backed by the recorder's transport,
|
|
// with an optional Authorization header injected into requests (for recording
|
|
// mode). The authValue should be the complete header value, e.g.
|
|
// "Bearer xxx" or a raw API key like "lin_api_xxx".
|
|
func newVCRClient(rec *recorder.Recorder, authValue string) *http.Client {
|
|
transport := rec.GetDefaultClient().Transport
|
|
if authValue != "" {
|
|
transport = &authRoundTripper{
|
|
authValue: authValue,
|
|
transport: transport,
|
|
}
|
|
}
|
|
|
|
return &http.Client{Transport: transport}
|
|
}
|
|
|
|
// headerRoundTripper injects a value into an arbitrary request header.
|
|
// Used for providers (e.g. Anthropic) that authenticate with a custom
|
|
// header instead of Authorization.
|
|
type headerRoundTripper struct {
|
|
header string
|
|
value string
|
|
transport http.RoundTripper
|
|
}
|
|
|
|
func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if rt.value != "" {
|
|
req.Header.Set(rt.header, rt.value)
|
|
}
|
|
|
|
return rt.transport.RoundTrip(req)
|
|
}
|
|
|
|
// newVCRClientWithHeader is like newVCRClient but injects the auth value
|
|
// into a named header (e.g. "x-api-key") instead of Authorization, for
|
|
// providers that do not use Bearer auth. The header is stripped from the
|
|
// cassette by newRecorder's BeforeSave hook.
|
|
func newVCRClientWithHeader(rec *recorder.Recorder, header, value string) *http.Client {
|
|
transport := rec.GetDefaultClient().Transport
|
|
if value != "" {
|
|
transport = &headerRoundTripper{
|
|
header: header,
|
|
value: value,
|
|
transport: transport,
|
|
}
|
|
}
|
|
|
|
return &http.Client{Transport: transport}
|
|
}
|