Add core JSON-RPC 2.0 protocol implementation for Kanboard API: - JSONRPCRequest/Response/Error structs with proper JSON tags - Generic call() method for sending requests and parsing responses - Thread-safe request ID generation using atomic.Int64 - Automatic /jsonrpc.php path appending to baseURL - Support for subdirectory installations - HTTP Basic Auth support (API token and username/password) - Error handling for unauthorized/forbidden responses Includes comprehensive tests with httptest mock server. Closes: kanboard-api-2g1
392 lines
9.5 KiB
Go
392 lines
9.5 KiB
Go
package kanboard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestJSONRPCRequest_Marshal(t *testing.T) {
|
|
req := JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "getTask",
|
|
ID: 1,
|
|
Params: map[string]int{"task_id": 42},
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal request: %v", err)
|
|
}
|
|
|
|
var unmarshaled map[string]interface{}
|
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
if unmarshaled["jsonrpc"] != "2.0" {
|
|
t.Errorf("expected jsonrpc=2.0, got %v", unmarshaled["jsonrpc"])
|
|
}
|
|
if unmarshaled["method"] != "getTask" {
|
|
t.Errorf("expected method=getTask, got %v", unmarshaled["method"])
|
|
}
|
|
if unmarshaled["id"].(float64) != 1 {
|
|
t.Errorf("expected id=1, got %v", unmarshaled["id"])
|
|
}
|
|
}
|
|
|
|
func TestJSONRPCRequest_MarshalWithoutParams(t *testing.T) {
|
|
req := JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "getAllProjects",
|
|
ID: 1,
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal request: %v", err)
|
|
}
|
|
|
|
var unmarshaled map[string]interface{}
|
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
if _, exists := unmarshaled["params"]; exists {
|
|
t.Error("params should be omitted when nil")
|
|
}
|
|
}
|
|
|
|
func TestJSONRPCResponse_Unmarshal(t *testing.T) {
|
|
data := `{"jsonrpc":"2.0","id":1,"result":{"id":42,"title":"Test Task"}}`
|
|
|
|
var resp JSONRPCResponse
|
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.JSONRPC != "2.0" {
|
|
t.Errorf("expected jsonrpc=2.0, got %v", resp.JSONRPC)
|
|
}
|
|
if resp.ID != 1 {
|
|
t.Errorf("expected id=1, got %v", resp.ID)
|
|
}
|
|
if resp.Error != nil {
|
|
t.Error("expected no error")
|
|
}
|
|
if resp.Result == nil {
|
|
t.Error("expected result to be present")
|
|
}
|
|
}
|
|
|
|
func TestJSONRPCResponse_UnmarshalError(t *testing.T) {
|
|
data := `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}`
|
|
|
|
var resp JSONRPCResponse
|
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error to be present")
|
|
}
|
|
if resp.Error.Code != -32600 {
|
|
t.Errorf("expected error code=-32600, got %v", resp.Error.Code)
|
|
}
|
|
if resp.Error.Message != "Invalid Request" {
|
|
t.Errorf("expected error message='Invalid Request', got %v", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestJSONRPCError_Error(t *testing.T) {
|
|
err := &JSONRPCError{
|
|
Code: -32600,
|
|
Message: "Invalid Request",
|
|
}
|
|
|
|
expected := "JSON-RPC error (code -32600): Invalid Request"
|
|
if err.Error() != expected {
|
|
t.Errorf("expected %q, got %q", expected, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestNextRequestID_Increments(t *testing.T) {
|
|
// Get the current counter value
|
|
initial := 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNextRequestID_ThreadSafe(t *testing.T) {
|
|
const goroutines = 100
|
|
const iterations = 100
|
|
|
|
var wg sync.WaitGroup
|
|
ids := make(chan int64, goroutines*iterations)
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < iterations; j++ {
|
|
ids <- nextRequestID()
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(ids)
|
|
|
|
// Collect all IDs and check for uniqueness
|
|
seen := make(map[int64]bool)
|
|
for id := range ids {
|
|
if seen[id] {
|
|
t.Errorf("duplicate request ID: %d", id)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
|
|
if len(seen) != goroutines*iterations {
|
|
t.Errorf("expected %d unique IDs, got %d", goroutines*iterations, len(seen))
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/jsonrpc.php" {
|
|
t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path)
|
|
}
|
|
|
|
var req JSONRPCRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("failed to decode request: %v", err)
|
|
}
|
|
|
|
if req.JSONRPC != "2.0" {
|
|
t.Errorf("expected jsonrpc=2.0, got %s", req.JSONRPC)
|
|
}
|
|
if req.Method != "getTask" {
|
|
t.Errorf("expected method=getTask, got %s", req.Method)
|
|
}
|
|
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(`{"id":42,"title":"Test Task"}`),
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
|
|
|
var result struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
err := client.call(context.Background(), "getTask", map[string]int{"task_id": 42}, &result)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.ID != 42 {
|
|
t.Errorf("expected id=42, got %d", result.ID)
|
|
}
|
|
if result.Title != "Test Task" {
|
|
t.Errorf("expected title='Test Task', got %s", result.Title)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_APIError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req JSONRPCRequest
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Error: &JSONRPCError{
|
|
Code: -32600,
|
|
Message: "Invalid Request",
|
|
},
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
|
|
|
var result interface{}
|
|
err := client.call(context.Background(), "invalidMethod", nil, &result)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
apiErr, ok := err.(*APIError)
|
|
if !ok {
|
|
t.Fatalf("expected *APIError, got %T", err)
|
|
}
|
|
if apiErr.Code != -32600 {
|
|
t.Errorf("expected code=-32600, got %d", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_Unauthorized(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("invalid-token")
|
|
|
|
var result interface{}
|
|
err := client.call(context.Background(), "getTask", nil, &result)
|
|
|
|
if err != ErrUnauthorized {
|
|
t.Errorf("expected ErrUnauthorized, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_Forbidden(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
|
|
|
var result interface{}
|
|
err := client.call(context.Background(), "getTask", nil, &result)
|
|
|
|
if err != ErrForbidden {
|
|
t.Errorf("expected ErrForbidden, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_ContextCanceled(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Simulate slow response
|
|
select {}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
var result interface{}
|
|
err := client.call(ctx, "getTask", nil, &result)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error due to canceled context")
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_SubdirectoryInstallation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/kanboard/jsonrpc.php" {
|
|
t.Errorf("expected /kanboard/jsonrpc.php, got %s", r.URL.Path)
|
|
}
|
|
|
|
var req JSONRPCRequest
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(`true`),
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Client with subdirectory path
|
|
client := NewClient(server.URL + "/kanboard").WithAPIToken("test-token")
|
|
|
|
var result bool
|
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_TrailingSlashHandling(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/jsonrpc.php" {
|
|
t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path)
|
|
}
|
|
|
|
var req JSONRPCRequest
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(`true`),
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Client with trailing slash
|
|
client := NewClient(server.URL + "/").WithAPIToken("test-token")
|
|
|
|
var result bool
|
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_Call_AuthHeaderSent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok {
|
|
t.Error("expected Basic Auth header")
|
|
}
|
|
if username != "jsonrpc" {
|
|
t.Errorf("expected username=jsonrpc, got %s", username)
|
|
}
|
|
if password != "test-token" {
|
|
t.Errorf("expected password=test-token, got %s", password)
|
|
}
|
|
|
|
var req JSONRPCRequest
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
resp := JSONRPCResponse{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: json.RawMessage(`true`),
|
|
}
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL).WithAPIToken("test-token")
|
|
|
|
var result bool
|
|
err := client.call(context.Background(), "getVersion", nil, &result)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|