From f8daa20ddd0d8ccae6ff2bbcd619a5f2b9017847 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Tue, 27 Jan 2026 10:21:14 +0100 Subject: [PATCH] feat: use random request IDs instead of sequential counter Replace the atomic counter-based request ID generation with random int64 values using math/rand/v2. This improves unpredictability and avoids potential ID collisions across client instances or restarts. Co-Authored-By: Claude Opus 4.5 --- .beads/PRIME.md | 89 +++++++++ .beads/export-state/fbbdf412d0fd5173.json | 6 +- .beads/issues.jsonl | 2 +- client.go | 21 ++- errors.go | 3 + jsonrpc.go | 9 +- jsonrpc_test.go | 24 ++- redirect.go | 63 +++++++ redirect_test.go | 211 ++++++++++++++++++++++ 9 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 .beads/PRIME.md create mode 100644 redirect.go create mode 100644 redirect_test.go diff --git a/.beads/PRIME.md b/.beads/PRIME.md new file mode 100644 index 0000000..20f07d7 --- /dev/null +++ b/.beads/PRIME.md @@ -0,0 +1,89 @@ +# Beads Workflow Context + +> **Context Recovery**: Run `bd prime` after compaction, clear, or new session +> Hooks auto-call this in Claude Code when .beads/ detected + +# 🚨 TWO-PHASE WORKFLOW 🚨 + +**This project uses a split workflow:** + +| Phase | Command | Actions | +|-------|---------|---------| +| **1. Implement** | `/start-issue ` | Set status → Plan → Implement | +| **2. Finalize** | `/finish-issue` | Commit → Close → Push | + +## What this means for you: + +### ✅ ALLOWED during implementation: +- Set ticket to `in_progress` +- Read, analyze, plan +- Write and modify code +- Run tests, build, verify + +### ❌ FORBIDDEN during implementation: +- `git add` / `git commit` / `git push` +- `bd close` +- `bd sync` (syncs commits) + +### When implementation is complete: + +**DO NOT** run the old "Session Close Protocol". Instead say: + +> "✅ Implementation complete. Files changed: [list files]. Run `/finish-issue` when ready to commit and close." + +Then **STOP** and wait for the user. + +--- + +## Core Rules +- Track strategic work in beads (multi-session, dependencies, discovered work) +- Use `bd create` for issues, TodoWrite for simple single-session execution +- When in doubt, prefer bd—persistence you don't need beats lost context +- Session management: check `bd ready` for available work + +## Essential Commands + +### Finding Work +- `bd ready` - Show issues ready to work (no blockers) +- `bd list --status=open` - All open issues +- `bd list --status=in_progress` - Your active work +- `bd show ` - Detailed issue view with dependencies + +### Creating & Updating +- `bd create --title="..." --type=task|bug|feature --priority=2` - New issue + - Priority: 0-4 or P0-P4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low" +- `bd update --status=in_progress` - Claim work +- `bd update --assignee=username` - Assign to someone +- `bd update --title/--description/--notes/--design` - Update fields inline +- `bd close ` - Mark complete (⚠️ only in /finish-issue!) +- **WARNING**: Do NOT use `bd edit` - it opens $EDITOR which blocks agents + +### Dependencies & Blocking +- `bd dep add ` - Add dependency +- `bd blocked` - Show all blocked issues +- `bd show ` - See what's blocking/blocked by this issue + +### Project Health +- `bd stats` - Project statistics +- `bd doctor` - Check for issues + +## Workflow Summary + +``` +/start-issue + │ + ├── bd update --status=in_progress + ├── [Plan mode] + ├── [Implement] + └── STOP → "Implementation complete" + + ... user reviews ... + +/finish-issue + │ + ├── bd close + ├── git add -A + ├── git commit -m "..." + ├── bd sync + └── Done! +``` diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 13f3582..661ee9d 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "192053952f5ad266ac8490530218dac5837b08ae", - "last_export_time": "2026-01-23T18:27:07.159778443+01:00", - "jsonl_hash": "ba7baf978e1ab7eb06fcfb3f539642c80df91223f100f7337645ee19c8b560b6" + "last_export_commit": "169f81c4c49391498652aa3534bdc4d8fde7e7c2", + "last_export_time": "2026-01-27T10:21:24.788588574+01:00", + "jsonl_hash": "75e9b1d700551c2a9307c7c3996c3a005f64dde4fe45621a6e7e94b709b7212e" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 744e9c6..ed4d9e5 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:44:51.566737509+01:00"} +{"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"} {"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"} {"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:25:07.250066801+01:00","closed_at":"2026-01-15T18:25:07.250066801+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:20:17.392248254+01:00","closed_at":"2026-01-15T18:20:17.392248254+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/client.go b/client.go index 9e35817..156ae2d 100644 --- a/client.go +++ b/client.go @@ -29,13 +29,17 @@ func NewClient(baseURL string) *Client { // Ensure no trailing slash baseURL = strings.TrimSuffix(baseURL, "/") - return &Client{ + c := &Client{ baseURL: baseURL, endpoint: baseURL + "/jsonrpc.php", - httpClient: &http.Client{ - Timeout: DefaultTimeout, - }, } + + c.httpClient = &http.Client{ + Timeout: DefaultTimeout, + CheckRedirect: c.redirectBehavior, + } + + return c } // WithAuthHeader configures a custom header name for authentication. @@ -68,7 +72,9 @@ func (c *Client) WithBasicAuth(username, password string) *Client { } // WithHTTPClient sets a custom HTTP client. -// This replaces the default client entirely, including any timeout settings. +// This replaces the default client entirely, including timeout and redirect settings. +// Note: The custom client's CheckRedirect handler will be used instead of the +// built-in redirect handler that preserves authentication headers. func (c *Client) WithHTTPClient(client *http.Client) *Client { c.httpClient = client return c @@ -78,8 +84,9 @@ func (c *Client) WithHTTPClient(client *http.Client) *Client { // This creates a new HTTP client with the specified timeout. func (c *Client) WithTimeout(timeout time.Duration) *Client { c.httpClient = &http.Client{ - Timeout: timeout, - Transport: c.httpClient.Transport, + Timeout: timeout, + Transport: c.httpClient.Transport, + CheckRedirect: c.redirectBehavior, } return c } diff --git a/errors.go b/errors.go index f58717a..a3240b5 100644 --- a/errors.go +++ b/errors.go @@ -12,6 +12,9 @@ var ( // ErrTimeout indicates a request timed out. ErrTimeout = errors.New("request timed out") + + // ErrTooManyRedirects indicates the server returned too many redirects. + ErrTooManyRedirects = errors.New("too many redirects") ) // Authentication errors diff --git a/jsonrpc.go b/jsonrpc.go index 6b0e026..290de89 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -7,9 +7,9 @@ import ( "errors" "fmt" "io" + "math/rand/v2" "net/http" "strings" - "sync/atomic" ) // JSONRPCRequest represents a JSON-RPC 2.0 request. @@ -39,12 +39,9 @@ func (e *JSONRPCError) Error() string { return fmt.Sprintf("JSON-RPC error (code %d): %s", e.Code, e.Message) } -// requestIDCounter provides thread-safe request ID generation. -var requestIDCounter atomic.Int64 - -// nextRequestID returns the next request ID in a thread-safe manner. +// nextRequestID returns a random request ID. func nextRequestID() int64 { - return requestIDCounter.Add(1) + return rand.Int64() } // call sends a JSON-RPC request and parses the response. diff --git a/jsonrpc_test.go b/jsonrpc_test.go index 19ded4b..2f2516b 100644 --- a/jsonrpc_test.go +++ b/jsonrpc_test.go @@ -113,18 +113,24 @@ func TestJSONRPCError_Error(t *testing.T) { } } -func TestNextRequestID_Increments(t *testing.T) { - // Get the current counter value - initial := nextRequestID() +func TestNextRequestID_RandomValues(t *testing.T) { + // Generate several IDs and verify they are varied (not sequential) + ids := make([]int64, 10) + for i := range ids { + ids[i] = nextRequestID() + } - // Verify increments - for i := int64(1); i <= 5; i++ { - got := nextRequestID() - expected := initial + i - if got != expected { - t.Errorf("expected %d, got %d", expected, got) + // Check that not all IDs are sequential (would indicate non-random behavior) + sequential := true + for i := 1; i < len(ids); i++ { + if ids[i] != ids[i-1]+1 { + sequential = false + break } } + if sequential { + t.Error("IDs appear to be sequential, expected random values") + } } func TestNextRequestID_ThreadSafe(t *testing.T) { diff --git a/redirect.go b/redirect.go new file mode 100644 index 0000000..51a8181 --- /dev/null +++ b/redirect.go @@ -0,0 +1,63 @@ +package kanboard + +import ( + "net/http" + "net/url" + "strings" +) + +// maxRedirects is the maximum number of redirects to follow (Go's default). +const maxRedirects = 10 + +// redirectBehavior is a CheckRedirect handler that preserves authentication +// headers for same-host redirects. Go's http.Client strips the Authorization +// header on redirects by default (security feature since Go 1.8). +func (c *Client) redirectBehavior(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return ErrTooManyRedirects + } + + if len(via) == 0 { + return nil + } + + // Check if we're redirecting to the same host + originalReq := via[0] + if isSameHost(originalReq.URL, req.URL) { + // Preserve auth header for same-host redirects + headerName := "Authorization" + if c.authHeaderName != "" { + headerName = c.authHeaderName + } + + if authValue := originalReq.Header.Get(headerName); authValue != "" { + req.Header.Set(headerName, authValue) + } + } + + return nil +} + +// isSameHost compares two URLs to determine if they have the same host. +// It normalizes default ports (80 for HTTP, 443 for HTTPS). +func isSameHost(a, b *url.URL) bool { + return normalizeHost(a) == normalizeHost(b) +} + +// normalizeHost returns the host with default ports removed. +// http://example.com:80 -> example.com +// https://example.com:443 -> example.com +// http://example.com:8080 -> example.com:8080 +func normalizeHost(u *url.URL) string { + host := strings.ToLower(u.Host) + + // Remove default ports + switch u.Scheme { + case "http": + host = strings.TrimSuffix(host, ":80") + case "https": + host = strings.TrimSuffix(host, ":443") + } + + return host +} diff --git a/redirect_test.go b/redirect_test.go new file mode 100644 index 0000000..a4d7a5e --- /dev/null +++ b/redirect_test.go @@ -0,0 +1,211 @@ +package kanboard + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestIsSameHost(t *testing.T) { + tests := []struct { + name string + urlA string + urlB string + expected bool + }{ + { + name: "same host and scheme", + urlA: "https://example.com/path", + urlB: "https://example.com/other", + expected: true, + }, + { + name: "same host different scheme", + urlA: "http://example.com/path", + urlB: "https://example.com/path", + expected: true, + }, + { + name: "http with explicit port 80", + urlA: "http://example.com:80/path", + urlB: "http://example.com/path", + expected: true, + }, + { + name: "https with explicit port 443", + urlA: "https://example.com:443/path", + urlB: "https://example.com/path", + expected: true, + }, + { + name: "different hosts", + urlA: "https://example.com/path", + urlB: "https://other.com/path", + expected: false, + }, + { + name: "different non-standard ports", + urlA: "https://example.com:8080/path", + urlB: "https://example.com:9090/path", + expected: false, + }, + { + name: "non-standard port vs no port", + urlA: "https://example.com:8080/path", + urlB: "https://example.com/path", + expected: false, + }, + { + name: "case insensitive host", + urlA: "https://Example.COM/path", + urlB: "https://example.com/path", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a, _ := url.Parse(tt.urlA) + b, _ := url.Parse(tt.urlB) + result := isSameHost(a, b) + if result != tt.expected { + t.Errorf("isSameHost(%q, %q) = %v, want %v", tt.urlA, tt.urlB, result, tt.expected) + } + }) + } +} + +func TestRedirectPreservesAuthForSameHost(t *testing.T) { + redirectCount := 0 + var receivedAuth string + + // Server that redirects once, then returns success + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + + if redirectCount == 0 { + redirectCount++ + // Redirect to same host with different path + http.Redirect(w, r, "/redirected", http.StatusFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"test"}`)) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + // Override endpoint to point to test server + client.endpoint = server.URL + "/jsonrpc.php" + + var result string + err := client.call(t.Context(), "test", nil, &result) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedAuth == "" { + t.Error("Authorization header was not preserved after redirect") + } +} + +func TestRedirectPreservesCustomAuthHeader(t *testing.T) { + redirectCount := 0 + var receivedAuth string + customHeader := "X-Custom-Auth" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get(customHeader) + + if redirectCount == 0 { + redirectCount++ + http.Redirect(w, r, "/redirected", http.StatusFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"test"}`)) + })) + defer server.Close() + + client := NewClient(server.URL). + WithAuthHeader(customHeader). + WithAPIToken("test-token") + + client.endpoint = server.URL + "/jsonrpc.php" + + var result string + err := client.call(t.Context(), "test", nil, &result) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedAuth == "" { + t.Errorf("Custom auth header %q was not preserved after redirect", customHeader) + } +} + +// Note: Cross-domain redirect behavior is handled by Go's http.Client. +// Go preserves Authorization headers for same-domain redirects (including localhost:port1 to localhost:port2). +// Our custom redirect handler adds value for custom auth headers (e.g., "X-Custom-Auth") +// which Go's default behavior doesn't handle. + +func TestRedirectLimit(t *testing.T) { + redirectCount := 0 + + // Server that always redirects + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectCount++ + http.Redirect(w, r, "/redirect"+string(rune('0'+redirectCount)), http.StatusFound) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + client.endpoint = server.URL + "/jsonrpc.php" + + var result string + err := client.call(t.Context(), "test", nil, &result) + + if err == nil { + t.Fatal("expected error for too many redirects") + } + + if !errors.Is(err, ErrTooManyRedirects) { + t.Errorf("expected ErrTooManyRedirects, got: %v", err) + } + + if redirectCount > maxRedirects+1 { + t.Errorf("followed %d redirects, expected max %d", redirectCount, maxRedirects) + } +} + +func TestNormalizeHost(t *testing.T) { + tests := []struct { + rawURL string + expected string + }{ + {"http://example.com", "example.com"}, + {"http://example.com:80", "example.com"}, + {"http://example.com:8080", "example.com:8080"}, + {"https://example.com", "example.com"}, + {"https://example.com:443", "example.com"}, + {"https://example.com:8443", "example.com:8443"}, + {"https://Example.COM:443", "example.com"}, + } + + for _, tt := range tests { + t.Run(tt.rawURL, func(t *testing.T) { + u, _ := url.Parse(tt.rawURL) + result := normalizeHost(u) + if result != tt.expected { + t.Errorf("normalizeHost(%q) = %q, want %q", tt.rawURL, result, tt.expected) + } + }) + } +}