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:
parent
735b288504
commit
f8daa20ddd
9 changed files with 402 additions and 26 deletions
89
.beads/PRIME.md
Normal file
89
.beads/PRIME.md
Normal 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!
|
||||
```
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"}]}
|
||||
|
|
|
|||
21
client.go
21
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
63
redirect.go
Normal file
63
redirect.go
Normal 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
211
redirect_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue