// Phase 4 acceptance tests (P4-T7, plan §4.7): they drive the real
// binary over stdio against an httptest mock backend, reusing the
// frame-driving helpers from main_test.go (P4-T6). Coverage:
//
//   - tools/list returns exactly the 20 tools: the 14 read-only tools
//     named in plan §4.3 plus the 5 write tools of plan §3.8 (P3-T6)
//     plus the whoami identity tool;
//   - the "查某 IP 暴露面 → 关联漏洞" chain: search_assets(q=IP) →
//     list_ports → search_vulnerabilities(search=关键词) →
//     get_vulnerability(public_id) 聚合 detail+events+comments;
//   - the write chain (P3-T6, plan §3.9): submit_vulnerability →
//     update_vulnerability → transition_vulnerability →
//     add_vulnerability_comment → upload_vulnerability_attachment，
//     mock 后端记录 method/body，逐工具断言端点与 body 字段（含裁剪
//     字段不出现、上传三步调用序列）；
//   - 契约一致性（P3-T6 守门）：tools/list 输出的写工具参数名 ⊆
//     swagger DTO 字段，防工具定义漂移于契约（编排优化②的配套校验）；
//   - error paths: missing SECURITY_CENTER_API_KEY → exit 1 + 提示文案;
//     invalid key (401 envelope) → IsError + code 401;
//     missing required argument → JSON-RPC -32602;
//   - audit attribution (plan §4.5): every backend request carries
//     exactly X-API-Key: <env 配置的调用方 key>，后端 AgentAuth 据此把
//     调用归因到 key owner。
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"sync"
	"testing"

	"security-center-mcp/internal/config"
)

// wantToolNames is the authoritative tool list, hardcoded here (rather
// than derived from tools.Registry) so the acceptance check compares
// the wire result against the plan, not against the implementation:
// the 14 read-only tools of plan §4.3 plus the 5 write tools of
// plan §3.8 (P3-T4/T5) plus the whoami identity tool.
var wantToolNames = []string{
	"search_assets",
	"get_asset",
	"list_ports",
	"list_high_risk_ports",
	"list_dns_records",
	"list_domains",
	"get_relationship_graph",
	"list_cloud_accounts",
	"list_sync_jobs",
	"list_api_route_assets",
	"search_vulnerabilities",
	"get_vulnerability",
	"list_vuln_lookup_options",
	"get_vuln_tags_stats",
	"submit_vulnerability",
	"update_vulnerability",
	"transition_vulnerability",
	"add_vulnerability_comment",
	"upload_vulnerability_attachment",
	"whoami",
}

// recordedRequest captures one HTTP request the MCP server made to the
// backend, for routing/attribution assertions.
type recordedRequest struct {
	method string
	path   string
	query  url.Values
	apiKey string
}

// recordingBackend answers the chain-test endpoints with envelope-wrapped
// payloads and records every request. Unexpected paths get a bare 404,
// which the client surfaces as an "upstream" structured error and fails
// the calling assertion in the test goroutine.
type recordingBackend struct {
	mu       sync.Mutex
	requests []recordedRequest
}

func (b *recordingBackend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	b.mu.Lock()
	b.requests = append(b.requests, recordedRequest{
		method: r.Method,
		path:   r.URL.Path,
		query:  r.URL.Query(),
		apiKey: r.Header.Get("X-API-Key"),
	})
	b.mu.Unlock()

	var data string
	switch r.URL.Path {
	case "/api/v1/agent/whoami":
		data = `{"actor_type":"agent","agent_id":12,"agent_name":"sec-bot","user_id":7,"name":"User A","email":"a@example.com","employee_id":88,"permission_scope":["vuln:view_findings"]}`
	case "/api/v1/caasm/ip-assets":
		data = `{"items":[{"ip":"10.1.2.3","exposure":"public"}],"total":1}`
	case "/api/v1/caasm/ports":
		data = `{"items":[{"id":11,"port":3306,"protocol":"tcp","exposure":"public"}],"total":1}`
	case "/api/v1/vulnerabilities":
		data = `{"items":[{"public_id":"VULN-2026-0001","severity":"high","title":"OpenSSL buffer overflow"}],"total":1}`
	case "/api/v1/vulnerabilities/VULN-2026-0001":
		data = `{"public_id":"VULN-2026-0001","severity":"high","title":"OpenSSL buffer overflow"}`
	case "/api/v1/vulnerabilities/VULN-2026-0001/events":
		data = `{"items":[{"id":1,"event":"created"}],"total":1}`
	case "/api/v1/vulnerabilities/VULN-2026-0001/comments":
		data = `{"items":[{"id":1,"content":"确认中"}],"total":1}`
	default:
		http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	fmt.Fprintf(w, `{"code":0,"message":"ok","data":%s,"timestamp":1}`, data)
}

// snapshot returns a copy of the recorded requests.
func (b *recordingBackend) snapshot() []recordedRequest {
	b.mu.Lock()
	defer b.mu.Unlock()
	out := make([]recordedRequest, len(b.requests))
	copy(out, b.requests)
	return out
}

// callResult mirrors the MCP tools/call result payload.
type callResult struct {
	Content []struct {
		Type string `json:"type"`
		Text string `json:"text"`
	} `json:"content"`
	IsError bool `json:"isError"`
}

// callTool sends one tools/call frame and decodes the result; a JSON-RPC
// level error fails the test (tool-level failures arrive as results with
// IsError set).
func callTool(t *testing.T, stdin *os.File, frames chan rpcFrame, id int64, name string, args map[string]any) callResult {
	t.Helper()
	send(t, stdin, id, "tools/call", map[string]any{"name": name, "arguments": args})
	resp := await(t, frames, id)
	if resp.Error != nil {
		t.Fatalf("tools/call %s: rpc error %d: %s", name, resp.Error.Code, resp.Error.Message)
	}
	var res callResult
	if err := json.Unmarshal(resp.Result, &res); err != nil {
		t.Fatalf("tools/call %s: decode result: %v", name, err)
	}
	return res
}

// text returns the single text content payload, failing on any other
// content shape.
func (r callResult) text(t *testing.T) string {
	t.Helper()
	if len(r.Content) != 1 || r.Content[0].Type != "text" {
		t.Fatalf("unexpected tool content %+v", r.Content)
	}
	return r.Content[0].Text
}

// dataObject decodes the tool result text as a JSON object after
// asserting the call did not report an error.
func (r callResult) dataObject(t *testing.T) map[string]any {
	t.Helper()
	if r.IsError {
		t.Fatalf("tool reported error: %s", r.text(t))
	}
	var data map[string]any
	if err := json.Unmarshal([]byte(r.text(t)), &data); err != nil {
		t.Fatalf("decode tool payload: %v (text %s)", err, r.text(t))
	}
	return data
}

// TestAcceptanceToolList asserts tools/list returns exactly the 20
// tools (14 read-only of plan §4.3 + 5 write tools of plan §3.8 + the
// whoami identity tool), set equality, order-insensitive.
func TestAcceptanceToolList(t *testing.T) {
	backend := &recordingBackend{}
	srv := httptest.NewServer(backend)
	defer srv.Close()

	stdin, frames := startServer(t, buildServer(t), srv.URL)
	initialize(t, stdin, frames)

	send(t, stdin, 2, "tools/list", map[string]any{})
	resp := await(t, frames, 2)
	if resp.Error != nil {
		t.Fatalf("tools/list: rpc error %d: %s", resp.Error.Code, resp.Error.Message)
	}
	var list struct {
		Tools []struct {
			Name string `json:"name"`
		} `json:"tools"`
	}
	if err := json.Unmarshal(resp.Result, &list); err != nil {
		t.Fatalf("tools/list: decode result: %v", err)
	}
	got := make([]string, 0, len(list.Tools))
	for _, tool := range list.Tools {
		got = append(got, tool.Name)
	}
	want := append([]string(nil), wantToolNames...)
	sort.Strings(got)
	sort.Strings(want)
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("tools/list: got %d tools %v, want the plan §4.3 set %v", len(got), got, want)
	}
}

// TestAcceptanceExposureToVulnerabilityChain drives the plan §4.7 chain
// "查某 IP 暴露面 → 关联漏洞" end to end over stdio and asserts backend
// routing, parameter/data pass-through, result aggregation and audit
// attribution (X-API-Key 恒为 env 配置的调用方 key).
func TestAcceptanceExposureToVulnerabilityChain(t *testing.T) {
	backend := &recordingBackend{}
	srv := httptest.NewServer(backend)
	defer srv.Close()

	stdin, frames := startServer(t, buildServer(t), srv.URL)
	initialize(t, stdin, frames)

	const ip = "10.1.2.3"

	// Step 1 — 按 IP 查暴露面（IP 资产维度）：q=<IP> 与 exposure 过滤
	// 都必须透传到 /caasm/ip-assets 的查询串。
	res := callTool(t, stdin, frames, 2, "search_assets", map[string]any{"q": ip, "exposure": "public"})
	data := res.dataObject(t)
	if items, ok := data["items"].([]any); !ok || len(items) != 1 {
		t.Fatalf("search_assets: data not passed through: %v", data)
	}

	// Step 2 — 任务规定的链路环节：list_ports 查端口暴露面。
	// 注意：/caasm/ports 在 swagger 中未声明 q 参数，dispatch 按设计丢弃
	// 未映射参数（resolveEndpoint 只序列化 ParamMap 内的参数），因此 q=<IP>
	// 不会出现在查询串——按 IP 定位暴露面由上一步 search_assets 完成。
	// 本步断言请求打到 /caasm/ports、exposure 过滤透传、data 透传。
	res = callTool(t, stdin, frames, 3, "list_ports", map[string]any{"q": ip, "exposure": "public"})
	data = res.dataObject(t)
	if items, ok := data["items"].([]any); !ok || len(items) != 1 {
		t.Fatalf("list_ports: data not passed through: %v", data)
	}

	// Step 3 — 关联漏洞：search_vulnerabilities(search=关键词)。
	res = callTool(t, stdin, frames, 4, "search_vulnerabilities", map[string]any{"search": "OpenSSL", "severity": "high"})
	data = res.dataObject(t)
	if items, ok := data["items"].([]any); !ok || len(items) != 1 {
		t.Fatalf("search_vulnerabilities: data not passed through: %v", data)
	}

	// Step 4 — get_vulnerability(public_id) 聚合 detail+events+comments
	// 三键（实现键名 vulnerability/events/comments，见 internal/tools/vuln.go）。
	res = callTool(t, stdin, frames, 5, "get_vulnerability", map[string]any{"public_id": "VULN-2026-0001"})
	data = res.dataObject(t)
	for _, key := range []string{"vulnerability", "events", "comments"} {
		if _, ok := data[key]; !ok {
			t.Fatalf("get_vulnerability: aggregated result missing key %q: %v", key, data)
		}
	}
	detail, ok := data["vulnerability"].(map[string]any)
	if !ok || detail["public_id"] != "VULN-2026-0001" {
		t.Fatalf("get_vulnerability: detail not passed through: %v", data["vulnerability"])
	}

	// Backend routing: exact path sequence of the whole chain.
	got := backend.snapshot()
	wantPaths := []string{
		"/api/v1/caasm/ip-assets",
		"/api/v1/caasm/ports",
		"/api/v1/vulnerabilities",
		"/api/v1/vulnerabilities/VULN-2026-0001",
		"/api/v1/vulnerabilities/VULN-2026-0001/events",
		"/api/v1/vulnerabilities/VULN-2026-0001/comments",
	}
	if len(got) != len(wantPaths) {
		t.Fatalf("backend received %d requests %v, want %d %v", len(got), requestPaths(got), len(wantPaths), wantPaths)
	}
	for i, want := range wantPaths {
		if got[i].path != want {
			t.Fatalf("request #%d path = %q, want %q (full sequence %v)", i+1, got[i].path, want, requestPaths(got))
		}
	}

	// Parameter pass-through spot checks.
	if q := got[0].query.Get("q"); q != ip {
		t.Fatalf("ip-assets: q = %q, want %q", q, ip)
	}
	if e := got[0].query.Get("exposure"); e != "public" {
		t.Fatalf("ip-assets: exposure = %q, want public", e)
	}
	if e := got[1].query.Get("exposure"); e != "public" {
		t.Fatalf("ports: exposure = %q, want public", e)
	}
	if q := got[1].query.Get("q"); q != "" {
		t.Fatalf("ports: q must be dropped (not a declared parameter), got %q", q)
	}
	if s := got[2].query.Get("search"); s != "OpenSSL" {
		t.Fatalf("vulnerabilities: search = %q, want OpenSSL", s)
	}

	// 审计归因（plan §4.5，静态核验）：client 发出的每个请求必须恒为
	// X-API-Key: <env 配置的调用方 key>，后端 AgentAuth 据此把调用归因到
	// key owner（audit_logs.actor_type='agent'）。startServer 把
	// SECURITY_CENTER_API_KEY 设为 smokeAPIKey，这里逐请求断言该头与之一致。
	for i, r := range got {
		if r.apiKey != smokeAPIKey {
			t.Fatalf("request #%d (%s): X-API-Key = %q, want env-configured key %q", i+1, r.path, r.apiKey, smokeAPIKey)
		}
	}
}

// TestAcceptanceWhoami drives the whoami identity tool over stdio and
// asserts the MCP server issues exactly GET /api/v1/agent/whoami (no
// query, audit key attached) and passes the identity payload through
// verbatim — the chain an LLM runs before any "我的漏洞" filtering.
func TestAcceptanceWhoami(t *testing.T) {
	backend := &recordingBackend{}
	srv := httptest.NewServer(backend)
	defer srv.Close()

	stdin, frames := startServer(t, buildServer(t), srv.URL)
	initialize(t, stdin, frames)

	res := callTool(t, stdin, frames, 2, "whoami", map[string]any{})
	data := res.dataObject(t)
	if data["actor_type"] != "agent" || data["user_id"] != float64(7) || data["employee_id"] != float64(88) {
		t.Fatalf("whoami: data not passed through: %v", data)
	}

	got := backend.snapshot()
	if len(got) != 1 {
		t.Fatalf("backend received %d requests %v, want exactly one", len(got), requestPaths(got))
	}
	if got[0].method != http.MethodGet || got[0].path != "/api/v1/agent/whoami" {
		t.Fatalf("request = %s %s, want GET /api/v1/agent/whoami", got[0].method, got[0].path)
	}
	if len(got[0].query) != 0 {
		t.Fatalf("whoami must not send query parameters, got %v", got[0].query)
	}
	if got[0].apiKey != smokeAPIKey {
		t.Fatalf("whoami: X-API-Key = %q, want env-configured key %q", got[0].apiKey, smokeAPIKey)
	}
}

// TestAcceptanceMissingAPIKey asserts the process refuses to start without
// SECURITY_CENTER_API_KEY: exit code 1 plus the guidance message on stderr.
func TestAcceptanceMissingAPIKey(t *testing.T) {
	cmd := exec.Command(buildServer(t))
	env := make([]string, 0, len(os.Environ()))
	for _, e := range os.Environ() {
		if strings.HasPrefix(e, config.EnvAPIKey+"=") || strings.HasPrefix(e, config.EnvBaseURL+"=") {
			continue
		}
		env = append(env, e)
	}
	cmd.Env = env

	out, err := cmd.CombinedOutput()
	var exitErr *exec.ExitError
	if !errors.As(err, &exitErr) {
		t.Fatalf("want non-zero exit, got err=%v output=%s", err, out)
	}
	if exitErr.ExitCode() != 1 {
		t.Fatalf("exit code = %d, want 1 (output %s)", exitErr.ExitCode(), out)
	}
	if !strings.Contains(string(out), config.EnvAPIKey+" is required") {
		t.Fatalf("stderr missing %q hint, got: %s", config.EnvAPIKey+" is required", out)
	}
}

// TestAcceptanceInvalidAPIKey asserts a backend 401 envelope (HTTP 200 +
// body code 401, the backend's error convention) surfaces as a tool
// result with IsError set and the structured code 401.
func TestAcceptanceInvalidAPIKey(t *testing.T) {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"code":401,"message":"invalid api key","data":null,"timestamp":1}`)
	}))
	defer backend.Close()

	stdin, frames := startServer(t, buildServer(t), backend.URL)
	initialize(t, stdin, frames)

	res := callTool(t, stdin, frames, 2, "search_assets", map[string]any{"q": "web"})
	if !res.IsError {
		t.Fatalf("tools/call: want IsError on 401 envelope, got %s", res.text(t))
	}
	var payload struct {
		Error struct {
			Code    int    `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	if err := json.Unmarshal([]byte(res.text(t)), &payload); err != nil {
		t.Fatalf("decode error payload: %v (text %s)", err, res.text(t))
	}
	if payload.Error.Code != http.StatusUnauthorized {
		t.Fatalf("error code = %d, want 401 (payload %s)", payload.Error.Code, res.text(t))
	}
}

// TestAcceptanceMissingRequiredArgument asserts a missing required
// argument surfaces as JSON-RPC invalid params (-32602), not as a tool
// result. main_test.go covers the CAASM path (get_asset); this covers
// the vuln path (get_vulnerability 缺 public_id).
func TestAcceptanceMissingRequiredArgument(t *testing.T) {
	backend := &recordingBackend{}
	srv := httptest.NewServer(backend)
	defer srv.Close()

	stdin, frames := startServer(t, buildServer(t), srv.URL)
	initialize(t, stdin, frames)

	send(t, stdin, 2, "tools/call", map[string]any{
		"name":      "get_vulnerability",
		"arguments": map[string]any{},
	})
	resp := await(t, frames, 2)
	if resp.Error == nil {
		t.Fatalf("tools/call get_vulnerability: want rpc error, got result %s", resp.Result)
	}
	if resp.Error.Code != -32602 {
		t.Fatalf("tools/call get_vulnerability: error code = %d, want -32602 (%s)", resp.Error.Code, resp.Error.Message)
	}
	if !strings.Contains(resp.Error.Message, "public_id") {
		t.Fatalf("error message should name the missing argument, got %q", resp.Error.Message)
	}
	// The argument error must be caught before any backend call.
	if got := backend.snapshot(); len(got) != 0 {
		t.Fatalf("backend received %d requests %v, want none", len(got), requestPaths(got))
	}
}

// requestPaths flattens recorded requests to their paths, for failure
// messages.
func requestPaths(reqs []recordedRequest) []string {
	paths := make([]string, 0, len(reqs))
	for _, r := range reqs {
		paths = append(paths, r.path)
	}
	return paths
}

// ---------------------------------------------------------------------------
// P3-T6 写链路（plan §3.9）：5 个写工具经 stdio 公共入口打到正确端点，
// mock 后端记录 method/body，逐工具断言 body 字段齐全、裁剪字段不出现、
// 上传工具三步调用序列按序执行。
// ---------------------------------------------------------------------------

// writeRequest captures one HTTP request the MCP server made during the
// write-chain test: the API calls and the raw object-store PUT alike.
type writeRequest struct {
	method      string
	path        string
	contentType string
	apiKey      string
	body        map[string]any // decoded JSON body (API calls); nil for the raw PUT
	rawBody     []byte
}

// writeBackend answers the five write-tool endpoints plus the presigned
// object-store PUT (the upload_url it hands out points back at itself
// under /mock-s3/) and records every request in order. Unexpected
// requests get a bare 404, which the client surfaces as an "upstream"
// structured error and fails the calling assertion in the test goroutine.
type writeBackend struct {
	mu       sync.Mutex
	requests []writeRequest
	selfURL  string // set once the httptest server is up
}

func (b *writeBackend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	raw, _ := io.ReadAll(r.Body)
	rec := writeRequest{
		method:      r.Method,
		path:        r.URL.Path,
		contentType: r.Header.Get("Content-Type"),
		apiKey:      r.Header.Get("X-API-Key"),
		rawBody:     raw,
	}
	if r.Header.Get("Content-Type") == "application/json" && len(raw) > 0 {
		_ = json.Unmarshal(raw, &rec.body)
	}
	b.mu.Lock()
	b.requests = append(b.requests, rec)
	b.mu.Unlock()

	var data string
	switch {
	case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vulnerabilities":
		data = `{"public_id":"VULN-2026-0002","status":"active"}`
	case r.Method == http.MethodPut && r.URL.Path == "/api/v1/vulnerabilities/VULN-2026-0002":
		data = `{"public_id":"VULN-2026-0002","title":"OpenSSL buffer overflow（修正）"}`
	case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vulnerabilities/VULN-2026-0002/transition":
		data = `{"public_id":"VULN-2026-0002","status":"retesting"}`
	case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vulnerabilities/VULN-2026-0002/comments":
		data = `{"id":1,"content":"已修复，请复测"}`
	case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vulnerabilities/uploads":
		data = fmt.Sprintf(`{"attachment_id":123,"upload_url":%q,"key":"vuln-attachments/key-1.png","expires_in":900}`,
			b.selfURL+"/mock-s3/vuln-attachments/key-1.png?X-Amz-Signature=redacted&X-Amz-Expires=900")
	case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/mock-s3/"):
		w.WriteHeader(http.StatusOK)
		return
	case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vulnerabilities/uploads/confirm":
		data = `{"id":123,"status":"ready"}`
	default:
		http.Error(w, "unexpected request "+r.Method+" "+r.URL.Path, http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	fmt.Fprintf(w, `{"code":0,"message":"ok","data":%s,"timestamp":1}`, data)
}

// snapshot returns a copy of the recorded requests.
func (b *writeBackend) snapshot() []writeRequest {
	b.mu.Lock()
	defer b.mu.Unlock()
	out := make([]writeRequest, len(b.requests))
	copy(out, b.requests)
	return out
}

// startWriteBackend brings up the write-chain mock backend.
func startWriteBackend(t *testing.T) *writeBackend {
	t.Helper()
	backend := &writeBackend{}
	srv := httptest.NewServer(backend)
	backend.selfURL = srv.URL
	t.Cleanup(srv.Close)
	return backend
}

// writeRequestDescs flattens recorded write requests to "METHOD path"
// strings, so mock mismatches show the expected vs actual sequence.
func writeRequestDescs(reqs []writeRequest) []string {
	descs := make([]string, 0, len(reqs))
	for _, r := range reqs {
		descs = append(descs, r.method+" "+r.path)
	}
	return descs
}

// TestAcceptanceWriteToolChain drives all five write tools over stdio
// and asserts each hits its contract endpoint with the contract body:
// required fields present, deliberately cut fields (tags, server-forced
// fields, the agent-403 five) absent, and the upload tool running its
// three-step sequence (presign → raw PUT → confirm) in order.
func TestAcceptanceWriteToolChain(t *testing.T) {
	backend := startWriteBackend(t)

	stdin, frames := startServer(t, buildServer(t), backend.selfURL)
	initialize(t, stdin, frames)

	// 1) submit：必填四字段进 body；tags/服务端强制字段（submitter_user_id/
	//    submitted_at/sla_deadline）即使传了也不得进入 body。
	res := callTool(t, stdin, frames, 2, "submit_vulnerability", map[string]any{
		"title":                  "OpenSSL buffer overflow",
		"severity_level":         "high",
		"status":                 "active",
		"discoverer_employee_id": 42,
		"description":            "<p>详情</p>",
		"tags":                   []any{"web"},
		"submitter_user_id":      999,
		"submitted_at":           "2026-01-01T00:00:00Z",
		"sla_deadline":           "2030-01-01T00:00:00Z",
	})
	if got := res.dataObject(t); got["public_id"] != "VULN-2026-0002" {
		t.Fatalf("submit_vulnerability: data not passed through: %v", got)
	}

	// 2) update：普通字段进 body；五个 agent 403 字段与 tags 不得进入。
	res = callTool(t, stdin, frames, 3, "update_vulnerability", map[string]any{
		"public_id":            "VULN-2026-0002",
		"title":                "OpenSSL buffer overflow（修正）",
		"poc_text":             "rm -rf",
		"verification_steps":   "steps",
		"has_poc_verification": true,
		"submitted_at":         "2026-01-01T00:00:00Z",
		"sla_deadline":         "2030-01-01T00:00:00Z",
		"tags":                 []any{"web"},
	})
	res.dataObject(t)

	// 3) transition：body 恰好 status（五目标态之一）+ message。
	res = callTool(t, stdin, frames, 4, "transition_vulnerability", map[string]any{
		"public_id": "VULN-2026-0002",
		"status":    "retesting",
		"message":   "已修复，请复测",
	})
	res.dataObject(t)

	// 4) comment：body 恰好 content。
	res = callTool(t, stdin, frames, 5, "add_vulnerability_comment", map[string]any{
		"public_id": "VULN-2026-0002",
		"content":   "已修复，请复测",
	})
	res.dataObject(t)

	// 5) upload：本地 PNG 走三步编排（预签名 → 直传 → 确认），返回
	//    attachment_id 透出。
	fileContent := []byte("\x89PNG\r\n\x1a\n-fake-screenshot-bytes")
	filePath := filepath.Join(t.TempDir(), "evidence.png")
	if err := os.WriteFile(filePath, fileContent, 0o600); err != nil {
		t.Fatalf("write temp file: %v", err)
	}
	res = callTool(t, stdin, frames, 6, "upload_vulnerability_attachment", map[string]any{
		"file_path": filePath,
	})
	if got := res.dataObject(t); got["attachment_id"] != float64(123) {
		t.Fatalf("upload_vulnerability_attachment: attachment_id not passed through: %v", got)
	}

	// 后端路由：全链路的精确 method+path 序列（含上传三步）。
	got := backend.snapshot()
	wantSeq := []struct{ method, path string }{
		{http.MethodPost, "/api/v1/vulnerabilities"},
		{http.MethodPut, "/api/v1/vulnerabilities/VULN-2026-0002"},
		{http.MethodPost, "/api/v1/vulnerabilities/VULN-2026-0002/transition"},
		{http.MethodPost, "/api/v1/vulnerabilities/VULN-2026-0002/comments"},
		{http.MethodPost, "/api/v1/vulnerabilities/uploads"},
		{http.MethodPut, "/mock-s3/vuln-attachments/key-1.png"},
		{http.MethodPost, "/api/v1/vulnerabilities/uploads/confirm"},
	}
	if len(got) != len(wantSeq) {
		t.Fatalf("backend received %d requests %v, want %d %v",
			len(got), writeRequestDescs(got), len(wantSeq), wantSeq)
	}
	for i, want := range wantSeq {
		if got[i].method != want.method || got[i].path != want.path {
			t.Fatalf("request #%d = %s %s, want %s %s (full sequence %v)",
				i+1, got[i].method, got[i].path, want.method, want.path, writeRequestDescs(got))
		}
	}

	// ① submit body：必填齐全 + 裁剪/服务端强制字段不出现。
	submit := got[0].body
	for _, k := range []string{"title", "severity_level", "status", "discoverer_employee_id"} {
		if _, ok := submit[k]; !ok {
			t.Errorf("submit body missing required field %q (body %v)", k, submit)
		}
	}
	for _, k := range []string{"tags", "submitter_user_id", "submitted_at", "sla_deadline"} {
		if _, ok := submit[k]; ok {
			t.Errorf("submit body must not contain %q (cut/server-forced), body %v", k, submit)
		}
	}

	// ② update body：五个 agent 403 字段 + tags 不出现（public_id 走路径）。
	update := got[1].body
	if update["title"] != "OpenSSL buffer overflow（修正）" {
		t.Errorf("update body title = %v, want 修正后标题 (body %v)", update["title"], update)
	}
	for _, k := range []string{"poc_text", "verification_steps", "has_poc_verification", "submitted_at", "sla_deadline", "tags", "public_id"} {
		if _, ok := update[k]; ok {
			t.Errorf("update body must not contain %q (agent-403/cut/path param), body %v", k, update)
		}
	}

	// ③ transition body：恰好 status + message。
	transition := got[2].body
	if transition["status"] != "retesting" || transition["message"] != "已修复，请复测" {
		t.Errorf("transition body = %v, want status=retesting + message", transition)
	}
	if len(transition) != 2 {
		t.Errorf("transition body must carry exactly status+message, got %v", transition)
	}

	// ④ comment body：恰好 content。
	comment := got[3].body
	if comment["content"] != "已修复，请复测" || len(comment) != 1 {
		t.Errorf("comment body = %v, want exactly content", comment)
	}

	// ⑤ upload 三步：预签名 body 声明 filename+content_type；直传字节
	//    原样、Content-Type 用申请时声明值、不带 X-API-Key（预签名本身
	//    即凭证）；确认 body 只有 attachment_id。
	presign, put, confirm := got[4], got[5], got[6]
	if presign.body["filename"] != "evidence.png" || presign.body["content_type"] != "image/png" {
		t.Errorf("presign body = %v, want filename=evidence.png + content_type=image/png", presign.body)
	}
	if string(put.rawBody) != string(fileContent) {
		t.Errorf("put body = %q, want exact file bytes %q", put.rawBody, fileContent)
	}
	if put.contentType != "image/png" {
		t.Errorf("put Content-Type = %q, want image/png (预签名申请时的声明值)", put.contentType)
	}
	if put.apiKey != "" {
		t.Errorf("put must not carry X-API-Key (预签名本身即凭证), got %q", put.apiKey)
	}
	if confirm.body["attachment_id"] != float64(123) || len(confirm.body) != 1 {
		t.Errorf("confirm body = %v, want exactly attachment_id=123", confirm.body)
	}

	// 审计归因（plan §4.5）：API 请求（非 S3 直传）恒带 env 配置的调用方 key。
	for i, r := range got {
		if strings.HasPrefix(r.path, "/mock-s3/") {
			continue
		}
		if r.apiKey != smokeAPIKey {
			t.Errorf("request #%d (%s %s): X-API-Key = %q, want env-configured key %q",
				i+1, r.method, r.path, r.apiKey, smokeAPIKey)
		}
	}
}

// ---------------------------------------------------------------------------
// P3-T6 契约一致性守门（编排优化②的配套校验）：P3-T4 解耦 swagger 快照后，
// 工具定义可能漂移于契约——本测试把 wire 上 tools/list 输出的写工具参数名
// 与 swagger 快照（P2-T2 新版）的 DTO 字段逐项比对。
// ---------------------------------------------------------------------------

// writeToolBodyContract pins each JSON write tool's contract endpoint and
// the body fields deliberately NOT exposed (server-forced or agent-403
// fields, plus the W0 tags cut).
var writeToolBodyContract = map[string]struct {
	method   string
	path     string
	pathArgs []string // inputSchema 参数中走 URL 路径、不进 body 的
	excluded []string // swagger DTO 里刻意不暴露的字段
}{
	"submit_vulnerability": {
		method: "post",
		path:   "/api/v1/vulnerabilities",
		// 服务端强制（提交人=Key 主人、时间字段）+ tags 首版裁剪。
		excluded: []string{"tags", "submitter_user_id", "submitted_at", "sla_deadline"},
	},
	"update_vulnerability": {
		method:   "put",
		path:     "/api/v1/vulnerabilities/{public_id}",
		pathArgs: []string{"public_id"},
		// agent 触碰即整体 403 的五个字段 + tags 首版裁剪。
		excluded: []string{"tags", "poc_text", "verification_steps", "has_poc_verification", "submitted_at", "sla_deadline"},
	},
	"transition_vulnerability": {
		method:   "post",
		path:     "/api/v1/vulnerabilities/{public_id}/transition",
		pathArgs: []string{"public_id"},
	},
	"add_vulnerability_comment": {
		method:   "post",
		path:     "/api/v1/vulnerabilities/{public_id}/comments",
		pathArgs: []string{"public_id"},
	},
}

// swaggerContractDoc is the slice of the swagger snapshot the acceptance
// contract check needs: operations' in-body parameter $ref plus the
// referenced definitions (required list and property names).
type swaggerContractDoc struct {
	Paths map[string]map[string]struct {
		Parameters []struct {
			Name   string `json:"name"`
			In     string `json:"in"`
			Schema struct {
				Ref string `json:"$ref"`
			} `json:"schema"`
		} `json:"parameters"`
	} `json:"paths"`
	Definitions map[string]struct {
		Required   []string       `json:"required"`
		Properties map[string]any `json:"properties"`
	} `json:"definitions"`
}

// loadSwaggerContract reads the P2-T2 snapshot from disk (the same file
// internal/tools embeds), so the acceptance layer validates the wire
// output against the very same contract source.
func loadSwaggerContract(t *testing.T) *swaggerContractDoc {
	t.Helper()
	raw, err := os.ReadFile(filepath.Join("internal", "tools", "schemas", "swagger.json"))
	if err != nil {
		t.Fatalf("read swagger snapshot: %v", err)
	}
	var doc swaggerContractDoc
	if err := json.Unmarshal(raw, &doc); err != nil {
		t.Fatalf("parse swagger snapshot: %v", err)
	}
	return &doc
}

// bodyDTO resolves the request body definition of the given operation
// and returns its required list and property name set.
func bodyDTO(t *testing.T, doc *swaggerContractDoc, path, method string) (required []string, props map[string]bool) {
	t.Helper()
	ops, ok := doc.Paths[path]
	if !ok {
		t.Fatalf("swagger: path %s not found", path)
	}
	op, ok := ops[method]
	if !ok {
		t.Fatalf("swagger: path %s has no %s operation", path, method)
	}
	ref := ""
	for _, p := range op.Parameters {
		if p.In == "body" {
			ref = p.Schema.Ref
		}
	}
	if ref == "" {
		t.Fatalf("swagger: %s %s declares no in-body parameter", method, path)
	}
	name := strings.TrimPrefix(ref, "#/definitions/")
	def, ok := doc.Definitions[name]
	if !ok {
		t.Fatalf("swagger: definition %s not found", name)
	}
	props = make(map[string]bool, len(def.Properties))
	for fname := range def.Properties {
		props[fname] = true
	}
	return def.Required, props
}

// TestAcceptanceWriteToolArgsMatchSwagger is the P3-T6 contract gate at
// the acceptance layer: it compares the write-tool argument names as
// served on the wire (tools/list inputSchema) against the swagger DTO
// fields — every non-path argument must be a real DTO field, every
// swagger-required body field must be reachable through an argument,
// and the deliberately cut fields must stay undeclared. This catches
// tool definitions drifting away from the contract after P3-T4
// decoupled from the swagger snapshot (编排优化②).
func TestAcceptanceWriteToolArgsMatchSwagger(t *testing.T) {
	backend := startWriteBackend(t)

	stdin, frames := startServer(t, buildServer(t), backend.selfURL)
	initialize(t, stdin, frames)

	send(t, stdin, 2, "tools/list", map[string]any{})
	resp := await(t, frames, 2)
	if resp.Error != nil {
		t.Fatalf("tools/list: rpc error %d: %s", resp.Error.Code, resp.Error.Message)
	}
	var list struct {
		Tools []struct {
			Name        string `json:"name"`
			InputSchema struct {
				Properties map[string]json.RawMessage `json:"properties"`
			} `json:"inputSchema"`
		} `json:"tools"`
	}
	if err := json.Unmarshal(resp.Result, &list); err != nil {
		t.Fatalf("tools/list: decode result: %v", err)
	}
	wireProps := make(map[string]map[string]bool, len(list.Tools))
	for _, tool := range list.Tools {
		props := make(map[string]bool, len(tool.InputSchema.Properties))
		for name := range tool.InputSchema.Properties {
			props[name] = true
		}
		wireProps[tool.Name] = props
	}

	doc := loadSwaggerContract(t)

	for name, want := range writeToolBodyContract {
		args, ok := wireProps[name]
		if !ok {
			t.Errorf("tools/list: write tool %q missing from wire output", name)
			continue
		}
		required, dto := bodyDTO(t, doc, want.path, want.method)

		pathArgs := make(map[string]bool, len(want.pathArgs))
		for _, p := range want.pathArgs {
			pathArgs[p] = true
		}

		// 参数名 ⊆ swagger DTO 字段（路径参数除外）。
		for arg := range args {
			if pathArgs[arg] {
				continue
			}
			if !dto[arg] {
				t.Errorf("tool %s: wire argument %q not in swagger %s body DTO — 工具定义漂移于契约", name, arg, want.path)
			}
		}
		// swagger 必填 body 字段必须有参数可达（BodyMap 为恒等映射，
		// 参数名即字段名）。
		for _, req := range required {
			if !args[req] {
				t.Errorf("tool %s: swagger-required body field %q has no wire argument", name, req)
			}
		}
		// 裁剪字段保持不声明，且仍存在于契约（契约变了要重检裁剪决策）。
		for _, cut := range want.excluded {
			if args[cut] {
				t.Errorf("tool %s: cut field %q must stay undeclared on the wire", name, cut)
			}
			if !dto[cut] {
				t.Fatalf("tool %s: excluded field %q not in swagger DTO — contract changed, re-check the cut", name, cut)
			}
		}
	}

	// upload 是编排工具：file_path 本地消费（契约字段是 filename，由编排
	// 映射），content_type 直达预签名 DTO；确认 DTO 必填 attachment_id
	// 由第①步响应透出。
	uploadArgs, ok := wireProps["upload_vulnerability_attachment"]
	if !ok {
		t.Fatal("tools/list: write tool upload_vulnerability_attachment missing from wire output")
	}
	_, presignDTO := bodyDTO(t, doc, "/api/v1/vulnerabilities/uploads", "post")
	confirmRequired, _ := bodyDTO(t, doc, "/api/v1/vulnerabilities/uploads/confirm", "post")
	for arg := range uploadArgs {
		if arg == "file_path" {
			continue // 本地路径，服务端契约字段是 filename（由编排映射）
		}
		if !presignDTO[arg] {
			t.Errorf("upload tool: wire argument %q not in swagger uploads body DTO", arg)
		}
	}
	if !presignDTO["filename"] {
		t.Error("swagger uploads DTO lost the filename field (file_path 的契约映射目标)")
	}
	foundAttachmentID := false
	for _, req := range confirmRequired {
		if req == "attachment_id" {
			foundAttachmentID = true
		}
	}
	if !foundAttachmentID {
		t.Errorf("swagger confirm DTO required = %v, want attachment_id", confirmRequired)
	}
}
