399 lines
9.4 KiB
Go
399 lines
9.4 KiB
Go
// integration_test.go - End-to-end tests for 9P toolsrv with Tauth.
|
|
//
|
|
// These tests build and spawn a real toolsrv process, then connect via 9P
|
|
// to verify the authentication flow works correctly.
|
|
package toolsrv
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var testToolsrvBinary string
|
|
|
|
// TestMain builds the toolsrv binary once for all integration tests.
|
|
func TestMain(m *testing.M) {
|
|
// Build toolsrv binary to a temp location
|
|
tmpDir, err := os.MkdirTemp("", "toolsrv-test-*")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
testToolsrvBinary = filepath.Join(tmpDir, "toolsrv")
|
|
|
|
// Build the binary
|
|
cmd := exec.Command("go", "build", "-o", testToolsrvBinary, "ollie/cmd/toolsrv")
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to build toolsrv: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// startTestServer starts a toolsrv process for testing.
|
|
// Returns socketPath and a cleanup function.
|
|
func startTestServer(t *testing.T) (socketPath string, cleanup func()) {
|
|
t.Helper()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "toolsrv-socket-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
|
|
socketPath = filepath.Join(tmpDir, "toolsrv.sock")
|
|
cwd, _ := os.Getwd()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cmd := exec.CommandContext(ctx, testToolsrvBinary, "serve", "--cwd", cwd, "--listen", socketPath)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
os.RemoveAll(tmpDir)
|
|
t.Fatalf("failed to start toolsrv: %v", err)
|
|
}
|
|
|
|
// Wait for socket to be ready
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(socketPath); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
if _, err := os.Stat(socketPath); err != nil {
|
|
cancel()
|
|
cmd.Wait()
|
|
os.RemoveAll(tmpDir)
|
|
t.Fatalf("toolsrv socket not ready: %v", err)
|
|
}
|
|
|
|
return socketPath, func() {
|
|
cancel()
|
|
cmd.Wait()
|
|
os.RemoveAll(tmpDir)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_FirstConnectionSetsSecret(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
// First connection with a secret should succeed
|
|
secret := "test-secret-first-connection"
|
|
conn, err := Dial(socketPath, secret)
|
|
if err != nil {
|
|
t.Fatalf("first Dial failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Should have received a token
|
|
if conn.Token() == "" {
|
|
t.Error("expected non-empty token")
|
|
}
|
|
|
|
// Secret should be stored
|
|
if conn.Secret() != secret {
|
|
t.Errorf("Secret() = %q, want %q", conn.Secret(), secret)
|
|
}
|
|
}
|
|
|
|
func TestIntegration_SameSecretReconnects(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
secret := "test-secret-reconnect"
|
|
|
|
// First connection establishes the secret
|
|
conn1, err := Dial(socketPath, secret)
|
|
if err != nil {
|
|
t.Fatalf("first Dial failed: %v", err)
|
|
}
|
|
token1 := conn1.Token()
|
|
conn1.Close()
|
|
|
|
// Second connection with same secret should succeed
|
|
conn2, err := Dial(socketPath, secret)
|
|
if err != nil {
|
|
t.Fatalf("second Dial failed: %v", err)
|
|
}
|
|
defer conn2.Close()
|
|
|
|
// Should get the same token
|
|
if conn2.Token() != token1 {
|
|
t.Errorf("token changed: %q -> %q", token1, conn2.Token())
|
|
}
|
|
}
|
|
|
|
func TestIntegration_WrongSecretFails(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
// First connection establishes the secret
|
|
conn1, err := Dial(socketPath, "correct-secret")
|
|
if err != nil {
|
|
t.Fatalf("first Dial failed: %v", err)
|
|
}
|
|
conn1.Close()
|
|
|
|
// Second connection with wrong secret should fail
|
|
conn2, err := Dial(socketPath, "wrong-secret")
|
|
if err == nil {
|
|
conn2.Close()
|
|
t.Fatal("Dial with wrong secret should fail")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_EmptySecretGeneratesOne(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
// Empty secret should generate a random one
|
|
conn, err := Dial(socketPath, "")
|
|
if err != nil {
|
|
t.Fatalf("Dial with empty secret failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Should have generated and stored a secret
|
|
if conn.Secret() == "" {
|
|
t.Error("expected non-empty generated secret")
|
|
}
|
|
|
|
// Token should be set
|
|
if conn.Token() == "" {
|
|
t.Error("expected non-empty token")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_BasicOperations(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
conn, err := Dial(socketPath, "test-secret-ops")
|
|
if err != nil {
|
|
t.Fatalf("Dial failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Test Ping
|
|
if err := conn.Ping(); err != nil {
|
|
t.Errorf("Ping failed: %v", err)
|
|
}
|
|
|
|
// Test HostInfo
|
|
info, err := conn.HostInfo()
|
|
if err != nil {
|
|
t.Errorf("HostInfo failed: %v", err)
|
|
}
|
|
if info.Platform == "" {
|
|
t.Error("HostInfo.Platform is empty")
|
|
}
|
|
|
|
// Test ListTools (should be empty initially, but shouldn't error)
|
|
tools, err := conn.ListTools()
|
|
if err != nil {
|
|
t.Errorf("ListTools failed: %v", err)
|
|
}
|
|
t.Logf("ListTools returned %d tools", len(tools))
|
|
}
|
|
|
|
func TestIntegration_ToolExecution(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
conn, err := Dial(socketPath, "test-secret-exec")
|
|
if err != nil {
|
|
t.Fatalf("Dial failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Load the shell tool
|
|
if err := conn.LoadTool("shell"); err != nil {
|
|
t.Fatalf("LoadTool failed: %v", err)
|
|
}
|
|
|
|
// Verify it's loaded
|
|
tools, err := conn.ListTools()
|
|
if err != nil {
|
|
t.Fatalf("ListTools failed: %v", err)
|
|
}
|
|
found := false
|
|
for _, tool := range tools {
|
|
if tool.Name == "shell" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("shell tool not found after loading")
|
|
}
|
|
|
|
// Execute a tool
|
|
ctx := context.Background()
|
|
args := json.RawMessage(`{"cmd": "echo hello from test"}`)
|
|
result, err := conn.CallTool(ctx, "shell", args)
|
|
if err != nil {
|
|
t.Fatalf("CallTool failed: %v", err)
|
|
}
|
|
|
|
// Result should be JSON containing the output
|
|
resultStr := string(result)
|
|
if !strings.Contains(resultStr, "hello from test") {
|
|
t.Errorf("unexpected result: %s", resultStr)
|
|
}
|
|
t.Logf("Tool result: %s", resultStr)
|
|
}
|
|
|
|
func TestIntegration_ProcessKeeperReconnect(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
secret := "test-secret-keeper"
|
|
|
|
// Create a process manually (simulating what Spawn returns)
|
|
proc := &Process{
|
|
SocketPath: socketPath,
|
|
Secret: secret,
|
|
}
|
|
|
|
// Create keeper without respawn (we're testing reconnect, not respawn)
|
|
keeper := NewProcessKeeper(context.Background(), proc, nil)
|
|
|
|
// First dial
|
|
conn1, err := keeper.Dial()
|
|
if err != nil {
|
|
t.Fatalf("first Dial failed: %v", err)
|
|
}
|
|
token := conn1.Token()
|
|
conn1.Close()
|
|
|
|
// Second dial should reconnect with same secret
|
|
conn2, err := keeper.Dial()
|
|
if err != nil {
|
|
t.Fatalf("second Dial failed: %v", err)
|
|
}
|
|
defer conn2.Close()
|
|
|
|
if conn2.Token() != token {
|
|
t.Errorf("token changed after reconnect: %q -> %q", token, conn2.Token())
|
|
}
|
|
}
|
|
|
|
func TestIntegration_ConcurrentConnections(t *testing.T) {
|
|
socketPath, cleanup := startTestServer(t)
|
|
defer cleanup()
|
|
|
|
secret := "test-secret-concurrent"
|
|
|
|
// Establish the secret
|
|
conn0, err := Dial(socketPath, secret)
|
|
if err != nil {
|
|
t.Fatalf("initial Dial failed: %v", err)
|
|
}
|
|
expectedToken := conn0.Token()
|
|
conn0.Close()
|
|
|
|
// Open multiple concurrent connections
|
|
const numConns = 5
|
|
conns := make([]*Conn, numConns)
|
|
errors := make([]error, numConns)
|
|
|
|
for i := 0; i < numConns; i++ {
|
|
conns[i], errors[i] = Dial(socketPath, secret)
|
|
}
|
|
|
|
// Check all succeeded with same token
|
|
for i := 0; i < numConns; i++ {
|
|
if errors[i] != nil {
|
|
t.Errorf("connection %d failed: %v", i, errors[i])
|
|
continue
|
|
}
|
|
if conns[i].Token() != expectedToken {
|
|
t.Errorf("connection %d has different token", i)
|
|
}
|
|
conns[i].Close()
|
|
}
|
|
}
|
|
|
|
func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
|
|
// This test verifies that when a toolsrv process dies,
|
|
// the ProcessKeeper respawns it with a new secret.
|
|
|
|
cwd, _ := os.Getwd()
|
|
|
|
// We need to use the real Spawn which requires the binary in PATH
|
|
// For now, manually simulate what ProcessKeeper does
|
|
|
|
// Start first server
|
|
socketPath1, cleanup1 := startTestServer(t)
|
|
|
|
secret1 := "secret-for-first-server"
|
|
conn1, err := Dial(socketPath1, secret1)
|
|
if err != nil {
|
|
cleanup1()
|
|
t.Fatalf("first Dial failed: %v", err)
|
|
}
|
|
token1 := conn1.Token()
|
|
conn1.Close()
|
|
|
|
// Kill the server
|
|
cleanup1()
|
|
|
|
// Start second server (simulates respawn)
|
|
socketPath2, cleanup2 := startTestServer(t)
|
|
defer cleanup2()
|
|
|
|
// New server should accept a new secret (any secret, since it's fresh)
|
|
secret2 := "secret-for-second-server"
|
|
conn2, err := Dial(socketPath2, secret2)
|
|
if err != nil {
|
|
t.Fatalf("second Dial failed: %v", err)
|
|
}
|
|
token2 := conn2.Token()
|
|
conn2.Close()
|
|
|
|
// Tokens should be different (different servers)
|
|
if token1 == token2 {
|
|
t.Error("tokens should be different after respawn")
|
|
}
|
|
|
|
// Old secret should NOT work on new server
|
|
conn3, err := Dial(socketPath2, secret1)
|
|
if err == nil {
|
|
conn3.Close()
|
|
t.Error("old secret should not work on new server")
|
|
}
|
|
|
|
// Verify ProcessKeeper handles this correctly
|
|
proc := &Process{
|
|
SocketPath: socketPath2,
|
|
Secret: secret2,
|
|
}
|
|
keeper := NewProcessKeeper(context.Background(), proc, nil)
|
|
|
|
conn4, err := keeper.Dial()
|
|
if err != nil {
|
|
t.Fatalf("keeper Dial failed: %v", err)
|
|
}
|
|
if conn4.Token() != token2 {
|
|
t.Error("keeper should reconnect with same token")
|
|
}
|
|
conn4.Close()
|
|
|
|
t.Logf("Successfully verified: old_token=%s new_token=%s", token1[:8], token2[:8])
|
|
_ = cwd // silence unused warning
|
|
}
|