Compare commits

..

4 Commits

Author SHA1 Message Date
claw 0232343126 feat(github): add safeguards against accidental AllowInsecureHTTP use in production
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 13s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m45s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m9s
Three-layer defense for the AllowInsecureHTTP client option:

1. Environment gate: AllowInsecureHTTP() requires REVIEW_BOT_ALLOW_INSECURE=1
   env var. Without it, the option is silently ignored with a slog.Warn.

2. Warning log on activation: When the env gate IS satisfied, a slog.Warn
   fires at client construction time so operators notice in production logs.

3. Test bypass: AllowInsecureHTTPForTest() skips the env gate entirely,
   keeping test code clean (no t.Setenv needed in every test).

Additionally, doRequest now rejects HTTP URLs unless allowInsecureHTTP is set
on the client, providing defense-in-depth against credential leakage.

Closes #96
2026-05-13 11:24:07 -07:00
aweiker b26514714f Merge pull request 'feat(gitea): pass commit_id explicitly in PostReview (#107)' (#112) from review-bot-issue-107 into main
CI / test (push) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
Reviewed-on: #112
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-13 18:07:38 +00:00
claw 028d46942a fix(gitea): update PostReview doc comment to include COMMENT event value
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 53s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m4s
2026-05-13 10:41:52 -07:00
claw e59c2bc831 feat(gitea): pass commit_id explicitly in PostReview (#107)
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 42s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 57s
Add commitID parameter to gitea.Client.PostReview so the review is
anchored to the specific commit that was evaluated. The caller
(cmd/review-bot) already computes evaluatedSHA from pr.Head.Sha;
this wires it through to the Gitea API payload.

When commitID is empty, omitempty drops it from the JSON and Gitea
defaults to the current PR head (backward-compatible).

Closes #107
2026-05-13 10:29:05 -07:00
8 changed files with 168 additions and 180 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ func TestIntegration_PostAndCleanup(t *testing.T) {
// Post a test review
sentinel := "<!-- review-bot:integration-test -->"
testBody := "# Integration Test Review\n\nThis is a test review.\n\n" + sentinel
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "COMMENT", testBody, nil)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "COMMENT", testBody, "", nil)
if err != nil {
t.Fatalf("PostReview: %v", err)
}
+1 -1
View File
@@ -444,7 +444,7 @@ func main() {
// POST new review
slog.Info("posting review", "event", event, "pr", prNumber)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, evaluatedSHA, inlineComments)
if err != nil {
slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
os.Exit(1)
+6 -2
View File
@@ -262,18 +262,22 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
}
// PostReview submits a review to a PR and returns the created review.
// event should be "APPROVED" or "REQUEST_CHANGES".
// event should be one of "APPROVED", "REQUEST_CHANGES", or "COMMENT".
// commitID anchors the review to a specific commit SHA. If empty, Gitea
// defaults to the current PR head.
// comments are optional inline comments attached to specific lines.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"`
}{
Body: body,
Event: event,
CommitID: commitID,
Comments: comments,
}
+31 -7
View File
@@ -117,8 +117,9 @@ func TestPostReview(t *testing.T) {
}
var payload struct {
Body string `json:"body"`
Event string `json:"event"`
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode payload: %v", err)
@@ -129,14 +130,16 @@ func TestPostReview(t *testing.T) {
if payload.Event != "APPROVED" {
t.Errorf("expected event %q, got %q", "APPROVED", payload.Event)
}
if payload.CommitID != "abc123def" {
t.Errorf("expected commit_id %q, got %q", "abc123def", payload.CommitID)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":100,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil)
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "abc123def", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -183,12 +186,35 @@ func TestPostReview_Non200(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
if err == nil {
t.Fatal("expected error for 403, got nil")
}
}
func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("failed to decode payload: %v", err)
}
if _, exists := raw["commit_id"]; exists {
t.Errorf("expected commit_id to be omitted from payload when empty, but it was present")
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":200,"user":{"login":"bot"},"state":"APPROVED","stale":false}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFileContent(t *testing.T) {
expected := "# Conventions\n- Be nice\n"
@@ -945,8 +971,6 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
}
}
// mockTransport is a test helper that returns errors for the first N calls,
// then delegates to a real server.
type mockTransport struct {
+2 -2
View File
@@ -37,7 +37,7 @@ func TestPostReview_WithComments(t *testing.T) {
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
}
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", comments)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -72,7 +72,7 @@ func TestPostReview_NilComments(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", nil)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+57 -61
View File
@@ -33,9 +33,6 @@ const (
// maxResponseBodyBytes limits how much of a successful response body we read
// for defense-in-depth against servers returning excessively large payloads.
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
// envAllowInsecure is the environment variable that gates the WithAllowInsecureHTTP option.
envAllowInsecure = "REVIEW_BOT_ALLOW_INSECURE"
)
// APIError represents an HTTP error response from the GitHub API.
@@ -89,27 +86,6 @@ func asAPIError(err error) (*APIError, bool) {
return nil, false
}
// clientConfig holds optional configuration for NewClient.
type clientConfig struct {
allowInsecureHTTP bool
testBypass bool // skip env gate; only WithAllowInsecureHTTPForTest (export_test.go) should set this
}
// ClientOption configures optional behavior of NewClient.
type ClientOption func(*clientConfig)
// WithAllowInsecureHTTP permits the client to send credentials over HTTP (non-TLS) URLs.
// This is gated by the REVIEW_BOT_ALLOW_INSECURE=1 environment variable as a
// defense-in-depth safeguard. If the env var is not set, the option is ignored
// and a warning is logged.
//
// For production use on trusted internal networks only.
func WithAllowInsecureHTTP() ClientOption {
return func(c *clientConfig) {
c.allowInsecureHTTP = true
}
}
// Client interacts with the GitHub API.
// A Client is safe for concurrent use by multiple goroutines.
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
@@ -119,10 +95,13 @@ type Client struct {
// Higher-level exported methods (GetPullRequest, etc.) will use it to
// construct request URLs; remove this field if those methods end up
// accepting full URLs instead.
baseURL string
token string
baseURL string
token string
httpClient *http.Client
// allowInsecureHTTP permits requests to HTTP (non-TLS) endpoints.
// When false, doRequest rejects URLs with an http:// scheme.
allowInsecureHTTP bool
httpClient *http.Client
// retryBackoff defines the delays between retry attempts for 429 responses.
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
@@ -162,39 +141,63 @@ func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
return nil
}
// ClientOption configures optional behavior of a Client.
type ClientOption func(*clientConfig)
type clientConfig struct {
allowInsecureHTTP bool
insecureIsTestBypass bool
}
// AllowInsecureHTTP permits sending credentials over plaintext HTTP connections.
// In production, this option is gated by the REVIEW_BOT_ALLOW_INSECURE=1
// environment variable. Without the env var set, the option is silently ignored
// and a warning is logged.
//
// For tests, prefer AllowInsecureHTTPForTest which bypasses the env gate.
func AllowInsecureHTTP() ClientOption {
return func(cfg *clientConfig) {
cfg.allowInsecureHTTP = true
}
}
// AllowInsecureHTTPForTest permits sending credentials over plaintext HTTP
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
// This is intended exclusively for test code using httptest.Server.
func AllowInsecureHTTPForTest() ClientOption {
return func(cfg *clientConfig) {
cfg.allowInsecureHTTP = true
cfg.insecureIsTestBypass = true
}
}
// NewClient creates a new GitHub API client.
// If baseURL is empty, it defaults to https://api.github.com.
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
// The baseURL must use HTTPS unless WithAllowInsecureHTTP() or WithAllowInsecureHTTPForTest() (test-only)
// is passed as an option.
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
if baseURL == "" {
baseURL = defaultBaseURL
}
var cfg clientConfig
for _, o := range opts {
o(&cfg)
for _, opt := range opts {
opt(&cfg)
}
// Environment gate for WithAllowInsecureHTTP (defense-in-depth).
// WithAllowInsecureHTTPForTest bypasses this gate for test convenience.
allowInsecure := false
if cfg.allowInsecureHTTP {
if cfg.testBypass {
allowInsecure = true
} else if os.Getenv(envAllowInsecure) == "1" {
allowInsecure = true
slog.Warn("WithAllowInsecureHTTP enabled — credentials may be sent over plaintext",
"env", envAllowInsecure+"=1")
if cfg.allowInsecureHTTP && !cfg.insecureIsTestBypass {
if os.Getenv("REVIEW_BOT_ALLOW_INSECURE") != "1" {
slog.Warn("AllowInsecureHTTP ignored: set REVIEW_BOT_ALLOW_INSECURE=1 to enable")
cfg.allowInsecureHTTP = false
} else {
slog.Warn("WithAllowInsecureHTTP option ignored", "hint", "set "+envAllowInsecure+"=1 to enable")
slog.Warn("AllowInsecureHTTP enabled — credentials may be sent over plaintext",
"env", "REVIEW_BOT_ALLOW_INSECURE=1")
}
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
allowInsecureHTTP: allowInsecure,
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
allowInsecureHTTP: cfg.allowInsecureHTTP,
httpClient: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: defaultCheckRedirect,
@@ -265,28 +268,21 @@ func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
return 0, false
}
// hasHTTPSScheme reports whether rawURL starts with "https://" (case-insensitive).
// It avoids the allocation of url.Parse for a simple scheme check.
func hasHTTPSScheme(rawURL string) bool {
const prefix = "https://"
return len(rawURL) >= len(prefix) && strings.EqualFold(rawURL[:len(prefix)], prefix)
// redactURL redacts query parameters from a URL for safe inclusion in error
// messages and log output.
func redactURL(rawURL string) string {
if idx := strings.IndexByte(rawURL, '?'); idx != -1 {
return rawURL[:idx] + "?<redacted>"
}
return rawURL
}
// doRequest performs an HTTP request with retry on 429 rate limit responses.
// It respects the Retry-After header when present, supporting both integer
// seconds and HTTP-date formats (capped at maxRetryAfter).
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
// Reject non-HTTPS URLs when credentials are present and insecure mode is not enabled.
// Uses a string prefix check instead of url.Parse to avoid per-request allocations.
if c.token != "" && !c.allowInsecureHTTP {
if !hasHTTPSScheme(reqURL) {
// Redact query parameters to avoid leaking sensitive data in error messages.
sanitized := reqURL
if i := strings.Index(reqURL, "?"); i >= 0 {
sanitized = reqURL[:i] + "?[REDACTED]"
}
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use WithAllowInsecureHTTP option for trusted networks)", sanitized)
}
if !c.allowInsecureHTTP && strings.HasPrefix(reqURL, "http://") {
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
}
var backoff []time.Duration
+70 -92
View File
@@ -35,7 +35,7 @@ func TestDoRequest_Success(t *testing.T) {
}))
defer srv.Close()
c := NewClient("test-token", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("test-token", srv.URL, AllowInsecureHTTPForTest())
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -60,7 +60,7 @@ func TestDoRequest_429_RetryAfter_IntegerSeconds(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -94,7 +94,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.now = func() time.Time { return fixedNow }
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
c.SetRetryBackoff([]time.Duration{0, 0})
@@ -130,7 +130,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate_InPast(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.now = func() time.Time { return fixedNow }
c.SetRetryBackoff([]time.Duration{0, 0})
@@ -157,7 +157,7 @@ func TestDoRequest_429_NoRetryAfter_UsesDefaultBackoff(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -187,7 +187,7 @@ func TestDoRequest_429_InvalidRetryAfter_UsesDefaultBackoff(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.SetRetryBackoff([]time.Duration{0, 0})
body, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -208,7 +208,7 @@ func TestDoRequest_404_NoRetry(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error, got nil")
@@ -230,7 +230,7 @@ func TestDoRequest_401_NoRetry(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error, got nil")
@@ -260,7 +260,7 @@ func TestDoRequest_ContextCanceled(t *testing.T) {
}))
defer srv.Close()
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
ctx, cancel := context.WithCancel(context.Background())
@@ -512,129 +512,107 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
}
}
func TestDoRequest_RejectsHTTPWithToken(t *testing.T) {
func TestAllowInsecureHTTPForTest_PermitsHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("{}"))
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
// Without WithAllowInsecureHTTP, should refuse to send token over HTTP
c := NewClient("secret-token", srv.URL)
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != "ok" {
t.Errorf("body = %q, want %q", body, "ok")
}
}
func TestNoInsecureOption_RejectsHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not have been sent")
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error when sending token over HTTP")
t.Fatal("expected error for HTTP request without AllowInsecureHTTP")
}
if !strings.Contains(err.Error(), "refusing to send credentials") {
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDoRequest_RejectsHTTPWithToken_RedactsQueryParams(t *testing.T) {
func TestAllowInsecureHTTP_WithoutEnvVar_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("{}"))
t.Fatal("request should not have been sent")
}))
defer srv.Close()
c := NewClient("secret-token", srv.URL)
_, err := c.doGet(context.Background(), srv.URL+"/test?secret=hunter2&token=abc")
if err == nil {
t.Fatal("expected error when sending token over HTTP")
}
errMsg := err.Error()
if strings.Contains(errMsg, "hunter2") || strings.Contains(errMsg, "token=abc") {
t.Errorf("error message should not contain query params, got: %v", errMsg)
}
if !strings.Contains(errMsg, "?[REDACTED]") {
t.Errorf("error message should contain redacted marker, got: %v", errMsg)
}
}
func TestDoRequest_AllowsHTTPWithoutToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
// Without token, HTTP should be fine (no credentials to leak)
c := NewClient("", srv.URL)
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
t.Errorf("unexpected body: %s", body)
}
}
func TestDoRequest_AllowsHTTPWithInsecureTestOption(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTPForTest())
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
t.Errorf("unexpected body: %s", body)
}
}
func TestWithAllowInsecureHTTP_RequiresEnvVar(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
// Without the env var, AllowInsecureHTTP should be ignored
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "")
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error: WithAllowInsecureHTTP without env var should be rejected")
t.Fatal("expected error: AllowInsecureHTTP without env var should be rejected")
}
if !strings.Contains(err.Error(), "refusing to send credentials") {
t.Errorf("unexpected error: %v", err)
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestWithAllowInsecureHTTP_WithEnvVar(t *testing.T) {
func TestAllowInsecureHTTP_WithEnvVar_Permitted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
w.WriteHeader(http.StatusOK)
w.Write([]byte("insecure-ok"))
}))
defer srv.Close()
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "1")
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(body) != `{"ok":true}` {
t.Errorf("unexpected body: %s", body)
if string(body) != "insecure-ok" {
t.Errorf("body = %q, want %q", body, "insecure-ok")
}
}
func TestWithAllowInsecureHTTP_EnvVarMustBeExactlyOne(t *testing.T) {
func TestAllowInsecureHTTP_EnvVarNotOne_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
t.Fatal("request should not have been sent")
}))
defer srv.Close()
// "true" is not "1" — should be rejected
// "true" is not "1" — strict check
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "true")
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
_, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil {
t.Fatal("expected error: env var must be exactly \"1\" to satisfy gate")
t.Fatal("expected error: env var 'true' is not '1'")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestRedactURL_WithQuery(t *testing.T) {
got := redactURL("http://localhost:1234/path?secret=token&foo=bar")
want := "http://localhost:1234/path?<redacted>"
if got != want {
t.Errorf("redactURL = %q, want %q", got, want)
}
}
func TestRedactURL_NoQuery(t *testing.T) {
got := redactURL("http://localhost:1234/path")
want := "http://localhost:1234/path"
if got != want {
t.Errorf("redactURL = %q, want %q", got, want)
}
}
-14
View File
@@ -1,14 +0,0 @@
package github
// WithAllowInsecureHTTPForTest permits the client to send credentials over HTTP
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
// This is intended exclusively for tests using httptest.Server.
//
// This function lives in export_test.go so it is only available to test
// binaries and does not appear in the production API surface.
func WithAllowInsecureHTTPForTest() ClientOption {
return func(c *clientConfig) {
c.allowInsecureHTTP = true
c.testBypass = true
}
}