ollie/toolsrv/integration_test.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
}