147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agentctl/server/config"
|
|
"github.com/kandev/kandev/internal/agentctl/server/instance"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// TestHandshakeIntegration_FullFlow exercises the complete client↔server
|
|
// handshake flow: nonce-based auth → token retrieval → authenticated requests.
|
|
// No Docker, no agent binaries — runs in CI as a regular test.
|
|
func TestHandshakeIntegration_FullFlow(t *testing.T) {
|
|
// 1. Set up a ControlServer with a bootstrap nonce (simulating Docker/Sprites mode)
|
|
cfg := &config.Config{
|
|
AuthToken: "test-generated-token-xyz",
|
|
BootstrapNonce: "integration-test-nonce-abc123",
|
|
}
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
|
cs := NewControlServer(cfg, &instance.Manager{}, log)
|
|
|
|
server := httptest.NewServer(cs.Router())
|
|
defer server.Close()
|
|
|
|
// Parse host:port from test server URL
|
|
host, port := parseHostPort(t, server.URL)
|
|
|
|
// 2. Create a ControlClient with NO auth token (simulating fresh backend)
|
|
client := agentctl.NewControlClient(host, port, log)
|
|
|
|
ctx := context.Background()
|
|
|
|
// 3. Health check should work without auth
|
|
if err := client.Health(ctx); err != nil {
|
|
t.Fatalf("health check should work without auth: %v", err)
|
|
}
|
|
|
|
// 4. Authenticated endpoint should be REJECTED (no token yet)
|
|
_, err := client.ListInstances(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error for unauthenticated request, got nil")
|
|
}
|
|
|
|
// 5. Handshake with correct nonce — should return token
|
|
token, err := client.Handshake(ctx, "integration-test-nonce-abc123")
|
|
if err != nil {
|
|
t.Fatalf("handshake failed: %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Fatal("handshake returned empty token")
|
|
}
|
|
|
|
// 6. After handshake, authenticated requests should WORK
|
|
// (ListInstances may return an endpoint/client error, but it should
|
|
// NOT be a 401 — it should get past auth.)
|
|
_, err = client.ListInstances(ctx)
|
|
if err != nil {
|
|
// If the error mentions "unauthorized" or "auth", auth is still failing
|
|
if containsAuthError(err.Error()) {
|
|
t.Fatalf("request after handshake still rejected by auth: %v", err)
|
|
}
|
|
// Any other error (e.g., nil instMgr panic recovery) is fine —
|
|
// it means auth passed and we hit the handler
|
|
}
|
|
|
|
// 7. Second handshake with same nonce should FAIL (burned)
|
|
_, err = client.Handshake(ctx, "integration-test-nonce-abc123")
|
|
if err == nil {
|
|
t.Fatal("second handshake should fail (nonce burned)")
|
|
}
|
|
|
|
// 8. Handshake with wrong nonce should also fail
|
|
_, err = client.Handshake(ctx, "wrong-nonce")
|
|
if err == nil {
|
|
t.Fatal("handshake with wrong nonce should fail")
|
|
}
|
|
}
|
|
|
|
// TestHandshakeIntegration_StandaloneMode verifies that when AuthToken is set
|
|
// directly (standalone mode, no nonce), normal Bearer auth works.
|
|
func TestHandshakeIntegration_StandaloneMode(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthToken: "standalone-secret-token",
|
|
// No BootstrapNonce — standalone mode
|
|
}
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
|
cs := NewControlServer(cfg, &instance.Manager{}, log)
|
|
|
|
server := httptest.NewServer(cs.Router())
|
|
defer server.Close()
|
|
|
|
host, port := parseHostPort(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
// Without auth token: rejected
|
|
noAuthClient := agentctl.NewControlClient(host, port, log)
|
|
_, err := noAuthClient.ListInstances(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected rejection without auth token")
|
|
}
|
|
|
|
// With correct auth token: passes auth
|
|
authClient := agentctl.NewControlClient(host, port, log,
|
|
agentctl.WithControlAuthToken("standalone-secret-token"))
|
|
_, err = authClient.ListInstances(ctx)
|
|
if err != nil && containsAuthError(err.Error()) {
|
|
t.Fatalf("request with correct token rejected: %v", err)
|
|
}
|
|
|
|
// Handshake should fail (no nonce configured)
|
|
_, err = noAuthClient.Handshake(ctx, "any-nonce")
|
|
if err == nil {
|
|
t.Fatal("handshake should fail when no nonce is configured")
|
|
}
|
|
}
|
|
|
|
func parseHostPort(t *testing.T, url string) (string, int) {
|
|
t.Helper()
|
|
var host string
|
|
var port int
|
|
// httptest URLs look like "http://127.0.0.1:PORT"
|
|
n, err := fmt.Sscanf(url, "http://%s", &host)
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("failed to parse test server URL %q", url)
|
|
}
|
|
// Split host:port
|
|
for i := len(host) - 1; i >= 0; i-- {
|
|
if host[i] == ':' {
|
|
_, _ = fmt.Sscanf(host[i+1:], "%d", &port)
|
|
host = host[:i]
|
|
break
|
|
}
|
|
}
|
|
return host, port
|
|
}
|
|
|
|
func containsAuthError(s string) bool {
|
|
lower := strings.ToLower(s)
|
|
return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "status 401")
|
|
}
|