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 <noreply@anthropic.com>
This commit is contained in:
Oliver Jakoubek 2026-01-27 10:21:14 +01:00
commit f8daa20ddd
9 changed files with 402 additions and 26 deletions

89
.beads/PRIME.md Normal file
View file

@ -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 <id>` | 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 <id>` - 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 <id> --status=in_progress` - Claim work
- `bd update <id> --assignee=username` - Assign to someone
- `bd update <id> --title/--description/--notes/--design` - Update fields inline
- `bd close <id>` - Mark complete (⚠️ only in /finish-issue!)
- **WARNING**: Do NOT use `bd edit` - it opens $EDITOR which blocks agents
### Dependencies & Blocking
- `bd dep add <issue> <depends-on>` - Add dependency
- `bd blocked` - Show all blocked issues
- `bd show <id>` - See what's blocking/blocked by this issue
### Project Health
- `bd stats` - Project statistics
- `bd doctor` - Check for issues
## Workflow Summary
```
/start-issue <id>
├── bd update <id> --status=in_progress
├── [Plan mode]
├── [Implement]
└── STOP → "Implementation complete"
... user reviews ...
/finish-issue
├── bd close <id>
├── git add -A
├── git commit -m "..."
├── bd sync
└── Done!
```

View file

@ -1,6 +1,6 @@
{ {
"worktree_root": "/home/oli/Dev/kanboard-api", "worktree_root": "/home/oli/Dev/kanboard-api",
"last_export_commit": "192053952f5ad266ac8490530218dac5837b08ae", "last_export_commit": "169f81c4c49391498652aa3534bdc4d8fde7e7c2",
"last_export_time": "2026-01-23T18:27:07.159778443+01:00", "last_export_time": "2026-01-27T10:21:24.788588574+01:00",
"jsonl_hash": "ba7baf978e1ab7eb06fcfb3f539642c80df91223f100f7337645ee19c8b560b6" "jsonl_hash": "75e9b1d700551c2a9307c7c3996c3a005f64dde4fe45621a6e7e94b709b7212e"
} }

View file

@ -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-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-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"}]} {"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"}]}

View file

@ -29,13 +29,17 @@ func NewClient(baseURL string) *Client {
// Ensure no trailing slash // Ensure no trailing slash
baseURL = strings.TrimSuffix(baseURL, "/") baseURL = strings.TrimSuffix(baseURL, "/")
return &Client{ c := &Client{
baseURL: baseURL, baseURL: baseURL,
endpoint: baseURL + "/jsonrpc.php", 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. // 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. // 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 { func (c *Client) WithHTTPClient(client *http.Client) *Client {
c.httpClient = client c.httpClient = client
return c return c
@ -78,8 +84,9 @@ func (c *Client) WithHTTPClient(client *http.Client) *Client {
// This creates a new HTTP client with the specified timeout. // This creates a new HTTP client with the specified timeout.
func (c *Client) WithTimeout(timeout time.Duration) *Client { func (c *Client) WithTimeout(timeout time.Duration) *Client {
c.httpClient = &http.Client{ c.httpClient = &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: c.httpClient.Transport, Transport: c.httpClient.Transport,
CheckRedirect: c.redirectBehavior,
} }
return c return c
} }

View file

@ -12,6 +12,9 @@ var (
// ErrTimeout indicates a request timed out. // ErrTimeout indicates a request timed out.
ErrTimeout = errors.New("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 // Authentication errors

View file

@ -7,9 +7,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math/rand/v2"
"net/http" "net/http"
"strings" "strings"
"sync/atomic"
) )
// JSONRPCRequest represents a JSON-RPC 2.0 request. // 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) return fmt.Sprintf("JSON-RPC error (code %d): %s", e.Code, e.Message)
} }
// requestIDCounter provides thread-safe request ID generation. // nextRequestID returns a random request ID.
var requestIDCounter atomic.Int64
// nextRequestID returns the next request ID in a thread-safe manner.
func nextRequestID() int64 { func nextRequestID() int64 {
return requestIDCounter.Add(1) return rand.Int64()
} }
// call sends a JSON-RPC request and parses the response. // call sends a JSON-RPC request and parses the response.

View file

@ -113,18 +113,24 @@ func TestJSONRPCError_Error(t *testing.T) {
} }
} }
func TestNextRequestID_Increments(t *testing.T) { func TestNextRequestID_RandomValues(t *testing.T) {
// Get the current counter value // Generate several IDs and verify they are varied (not sequential)
initial := nextRequestID() ids := make([]int64, 10)
for i := range ids {
ids[i] = nextRequestID()
}
// Verify increments // Check that not all IDs are sequential (would indicate non-random behavior)
for i := int64(1); i <= 5; i++ { sequential := true
got := nextRequestID() for i := 1; i < len(ids); i++ {
expected := initial + i if ids[i] != ids[i-1]+1 {
if got != expected { sequential = false
t.Errorf("expected %d, got %d", expected, got) break
} }
} }
if sequential {
t.Error("IDs appear to be sequential, expected random values")
}
} }
func TestNextRequestID_ThreadSafe(t *testing.T) { func TestNextRequestID_ThreadSafe(t *testing.T) {

63
redirect.go Normal file
View file

@ -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
}

211
redirect_test.go Normal file
View file

@ -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)
}
})
}
}