Files
gronod e7728cdbce Add integration verification suite: raw JSON-RPC harness (Phase 08)
Exercises the compiled binary end to end over stdio rather than
calling handlers in-process, so config loading, auth, transport and
envelope mapping are all covered. 32 contract tests run against a
mock Ombi (undocumented-field fixtures, auth enforcement, recorded
upstream calls) behind the integration build tag; live tests engage
when OMBI_* env vars are set. No handler or schema changes were
needed — findings recorded in 06-verification.md.
2026-09-18 19:14:17 +01:00

429 lines
11 KiB
Go

//go:build integration
// Package integration_test exercises the compiled ombi-mcp binary end
// to end: it spawns the server as a subprocess and issues raw MCP
// JSON-RPC messages over stdio, against either a mock Ombi instance
// (httptest) or a live one selected by environment variables.
//
// The whole package is behind the "integration" build tag so standard
// CI (`go test ./...`) never compiles or runs it. Run explicitly with:
//
// go test -tags integration ./internal/integration_test/
package integration_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// serverBin is the compiled server under test, built once in TestMain.
var serverBin string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "ombi-mcp-itest")
if err != nil {
fmt.Fprintln(os.Stderr, "mktemp:", err)
os.Exit(1)
}
defer os.RemoveAll(dir)
bin := filepath.Join(dir, "ombi-mcp")
root, err := filepath.Abs("../..")
if err != nil {
fmt.Fprintln(os.Stderr, "resolve repo root:", err)
os.Exit(1)
}
build := exec.Command("go", "build", "-o", bin, "./cmd/ombi-mcp")
build.Dir = root
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := build.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "build server: %v\n%s\n", err, out)
os.Exit(1)
}
serverBin = bin
os.Exit(m.Run())
}
// serverEnv returns os.Environ with every OMBI_* variable replaced by
// the given overrides, so a spawned server sees exactly the intended
// configuration regardless of the parent environment.
func serverEnv(overrides map[string]string) []string {
env := []string{}
for _, e := range os.Environ() {
if strings.HasPrefix(e, "OMBI_") {
continue
}
env = append(env, e)
}
for k, v := range overrides {
env = append(env, k+"="+v)
}
return env
}
// --- raw JSON-RPC stdio client ---
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
}
// syncBuf captures the server's stderr for diagnostics on failure.
type syncBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.buf.Len() < 64<<10 {
return b.buf.Write(p)
}
return len(p), nil
}
func (b *syncBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// rpcClient is a spawned ombi-mcp server spoken to over stdio with
// newline-delimited JSON-RPC 2.0. A single reader goroutine decodes
// all inbound messages onto a channel; request() matches responses
// by id and answers stray server-initiated requests with -32601.
type rpcClient struct {
t *testing.T
cmd *exec.Cmd
stdin io.WriteCloser
msgs chan *rpcMessage
nextID atomic.Int64
stderr *syncBuf
done chan struct{}
}
func spawnServer(t *testing.T, env map[string]string) *rpcClient {
t.Helper()
cmd := exec.Command(serverBin)
cmd.Env = serverEnv(env)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatalf("stdin pipe: %v", err)
}
stderr := &syncBuf{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start server: %v", err)
}
c := &rpcClient{
t: t,
cmd: cmd,
stdin: stdin,
msgs: make(chan *rpcMessage, 64),
stderr: stderr,
done: make(chan struct{}),
}
go c.readLoop(stdout)
t.Cleanup(func() {
c.stdin.Close()
select {
case <-c.done:
case <-time.After(5 * time.Second):
c.cmd.Process.Kill()
<-c.done
}
})
return c
}
func (c *rpcClient) readLoop(r io.Reader) {
defer close(c.done)
defer close(c.msgs)
dec := json.NewDecoder(r)
for {
var m rpcMessage
if err := dec.Decode(&m); err != nil {
return
}
c.msgs <- &m
}
}
// request sends one JSON-RPC request and waits for its response.
// Stray server-initiated requests get a method-not-found reply;
// notifications and unrelated responses are skipped.
func (c *rpcClient) request(method string, params any) (json.RawMessage, error) {
id := c.nextID.Add(1)
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0", ID: id, Method: method, Params: params,
})
if err != nil {
return nil, err
}
if _, err := c.stdin.Write(append(body, '\n')); err != nil {
return nil, fmt.Errorf("write %s: %w", method, err)
}
timer := time.NewTimer(60 * time.Second)
defer timer.Stop()
for {
select {
case m, ok := <-c.msgs:
if !ok {
return nil, fmt.Errorf("server stdout closed (stderr: %s)", c.stderr)
}
if m.Method != "" && len(m.ID) > 0 {
// A server-initiated request: refuse politely and
// keep waiting for our own response.
c.replyError(m.ID, -32601, "client does not implement "+m.Method)
continue
}
var got int64
if err := json.Unmarshal(m.ID, &got); err != nil || got != id {
continue // notification or other response — skip
}
if m.Error != nil {
return nil, m.Error
}
return m.Result, nil
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for %s response", method)
}
}
}
func (c *rpcClient) replyError(id json.RawMessage, code int, msg string) {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": code, "message": msg},
})
c.stdin.Write(append(body, '\n'))
}
func (c *rpcClient) notify(method string, params any) {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "method": method, "params": params,
})
c.stdin.Write(append(body, '\n'))
}
// handshake performs initialize + notifications/initialized.
func (c *rpcClient) handshake(t *testing.T) {
t.Helper()
res, err := c.request("initialize", map[string]any{
"protocolVersion": "2025-06-18",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "integration-harness", "version": "0.1.0"},
})
if err != nil {
t.Fatalf("initialize: %v (stderr: %s)", err, c.stderr)
}
var init struct {
ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"serverInfo"`
}
if err := json.Unmarshal(res, &init); err != nil {
t.Fatalf("initialize result decode: %v", err)
}
if init.ServerInfo.Name != "ombi-mcp" {
t.Fatalf("unexpected server identity %q", init.ServerInfo.Name)
}
c.notify("notifications/initialized", map[string]any{})
}
// --- tool call envelope ---
type toolError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
HTTPStatus *int `json:"http_status"`
Field string `json:"field"`
RetryAfterSeconds *int `json:"retry_after_seconds"`
}
type envelope struct {
OK bool `json:"ok"`
Operation string `json:"operation"`
Data json.RawMessage `json:"data"`
Error *toolError `json:"error"`
Warnings []string `json:"warnings"`
Truncated bool `json:"truncated"`
CorrelationID string `json:"correlation_id"`
}
// callOutcome carries the decoded tools/call result plus the raw
// result object for leak scanning.
type callOutcome struct {
Raw json.RawMessage
Envelope *envelope
IsError bool
Text string
}
// callTool issues a raw tools/call and decodes the result envelope.
// Protocol-level errors (JSON-RPC error) fail the test.
func (c *rpcClient) callTool(t *testing.T, name string, args any) *callOutcome {
t.Helper()
res, err := c.request("tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
t.Fatalf("tools/call %s: %v (stderr: %s)", name, err, c.stderr)
}
return decodeCallOutcome(t, res)
}
func decodeCallOutcome(t *testing.T, res json.RawMessage) *callOutcome {
t.Helper()
var parsed struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Structured *envelope `json:"structuredContent"`
IsError bool `json:"isError"`
Raw json.RawMessage `json:"-"`
}
if err := json.Unmarshal(res, &parsed); err != nil {
t.Fatalf("tools/call result not JSON: %v\n%s", err, res)
}
out := &callOutcome{Raw: res, IsError: parsed.IsError}
if parsed.Structured == nil {
t.Fatalf("tools/call result has no structuredContent: %s", res)
}
out.Envelope = parsed.Structured
for _, blk := range parsed.Content {
if blk.Type == "text" {
out.Text = blk.Text
}
}
return out
}
// --- shared assertions ---
// requireOK asserts the envelope is a success and returns its data.
func requireOK(t *testing.T, o *callOutcome) json.RawMessage {
t.Helper()
env := o.Envelope
if env.CorrelationID == "" {
t.Errorf("missing correlation_id")
}
if o.IsError {
t.Fatalf("isError=true with envelope: %s", o.Raw)
}
if !env.OK {
t.Fatalf("ok=false: %s", o.Raw)
}
if len(env.Data) == 0 || string(env.Data) == "null" {
t.Fatalf("ok=true but data missing: %s", o.Raw)
}
// Text fallback must carry the same bounded envelope.
var textEnv envelope
if err := json.Unmarshal([]byte(o.Text), &textEnv); err != nil {
t.Fatalf("text content is not the serialized envelope: %v", err)
}
if textEnv.OK != env.OK || textEnv.CorrelationID != env.CorrelationID {
t.Fatalf("text/structured envelopes disagree")
}
return env.Data
}
// requireErr asserts the envelope is a mapped ToolError with the
// expected code and runs the leak scan over the whole raw result.
func requireErr(t *testing.T, o *callOutcome, code string) *toolError {
t.Helper()
env := o.Envelope
if env.OK {
t.Fatalf("expected error %s, got ok=true: %s", code, o.Raw)
}
if !o.IsError {
t.Errorf("isError=false on error result")
}
if env.Error == nil {
t.Fatalf("ok=false but no error object: %s", o.Raw)
}
if env.Error.Code != code {
t.Fatalf("error code = %q, want %q: %s", env.Error.Code, code, o.Raw)
}
assertNoLeak(t, o.Raw)
return env.Error
}
// forbiddenLeak substrings that must never appear in tool output:
// upstream credentials, HTML error pages and stack-trace shapes.
func assertNoLeak(t *testing.T, raw json.RawMessage) {
t.Helper()
s := string(raw)
for _, bad := range []string{
"Bearer ", "Authorization", "<html", "<!DOCTYPE", "<HTML",
"ApiKey", "api_key", "System.Exception", "NullReferenceException",
" at ", "StackTrace", "traceback",
} {
if strings.Contains(s, bad) {
t.Errorf("tool output leaks %q:\n%s", bad, raw)
}
}
}
// listTools returns the advertised tool names.
func (c *rpcClient) listTools(t *testing.T) []string {
t.Helper()
res, err := c.request("tools/list", map[string]any{})
if err != nil {
t.Fatalf("tools/list: %v", err)
}
var out struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(res, &out); err != nil {
t.Fatalf("tools/list decode: %v", err)
}
names := make([]string, 0, len(out.Tools))
for _, tl := range out.Tools {
names = append(names, tl.Name)
}
return names
}