Files
reverse-engineering-anything/tests/boundary/process/androidMappingMcp.test.ts
T
17ceb77ca5 fix(mcp): preserve input validation issue attribution (#7981)
* fix(mcp): preserve input validation issue attribution

* fix(mcp): charge nested issue projection before allocation

* style(mcp): format server declarations

* fix(mcp): avoid duplicate fallback diagnostics

* test(mcp): assert structured validation issues

* test(mcp): align browser input issue assertions

* fix(mcp): retain SDK diagnostic parts once

* style(mcp): format diagnostic projection

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-29 15:39:10 +08:00

279 lines
10 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { createHash } from "node:crypto";
import { readFile, truncate, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test, onTestFinished } from "vitest";
import { z } from "zod";
import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js";
import { ANDROID_MAPPING_LIMITS } from "../../../src/domain/android/androidMapping.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import {
androidClassSummaryViewResultSchema,
androidMethodSummaryViewResultSchema,
} from "../../../src/domain/analysisView/analysisView.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const it = test.skipIf(process.platform === "win32");
/** One MCP client over the production server and a real owned fake JADX child. */
const connect = async () => {
const fixture = await createJadxProtocolFixture();
const session = createTestBinarySession(() => {
throw new Error("APK analysis must not acquire a deep binary provider");
});
const server = createServer(
{ kind: "session", session },
{
androidAnalysis: fixture.provider,
environment: { REA_MCP_TOOL_FAMILIES: "android,application,session" },
},
);
const client = new Client({
name: "android-mapping-regression",
version: "1",
});
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const inspect = (mapping_path: string) =>
client.callTool({
name: "inspect_android_class",
arguments: {
path: fixture.apk,
class_name: "fixture.Target",
mapping_path,
},
});
return { ...fixture, client, inspect, session };
};
const mappingText = (original: string) =>
[
"# compiler: R8",
"# pg_map_id: 7f00",
`${original} -> fixture.Target:`,
" void onCreate(android.os.Bundle) -> onCreate",
" void pick(java.lang.String) -> choose",
" void pick(int) -> choose",
].join("\n");
it("keeps exact class and selected-method facts in summaries with readable complete parents after source removal", async () => {
const { apk, client, session, launches } = await connect();
const root = await createTestTempDirectory("rea-android-mapping-summary-");
const mapping_path = join(root, "mapping.txt");
await writeFile(mapping_path, mappingText("com.example.Original"));
const catalog = (await client.listTools()).tools;
const parents = [];
for (const name of [
"inspect_android_class",
"inspect_android_method",
] as const) {
const definition = catalog.find((tool) => tool.name === name);
if (definition?.outputSchema === undefined)
throw new Error(`Missing ${name} schema`);
const validate = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(
z.record(z.string(), z.unknown()).parse(definition.outputSchema),
);
const args = {
path: apk,
class_name: "fixture.Target",
mapping_path,
detail: "summary",
...(name === "inspect_android_method"
? { method_name: "choose", overload_index: 1 }
: {}),
};
const response = await client.callTool({ name, arguments: args });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
validate(response.structuredContent),
JSON.stringify(validate.errors),
).toBe(true);
const evidence = parseEvidence(response.structuredContent);
expect(evidence.operation).toBe("inspect_analysis_view");
expect(evidence.raw_result).toBeNull();
const result = (
name === "inspect_android_class"
? androidClassSummaryViewResultSchema
: androidMethodSummaryViewResultSchema
).parse(evidence.normalized_result);
const parent = session.evidenceById(result.parent_evidence_id);
if (parent === undefined)
throw new Error("Complete source was not retained");
expect(parent.operation).toBe(name);
expect(parent.raw_result).not.toBeNull();
expect(result.summary).toEqual(parent.normalized_result);
parents.push(parent.evidence_id);
}
await unlink(apk);
await unlink(mapping_path);
for (const evidence_id of parents) {
const retained = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id },
view: { kind: "summary" },
},
});
expect(retained.isError, JSON.stringify(retained)).not.toBe(true);
expect(parseEvidence(retained.structuredContent).raw_result).toBeNull();
}
expect(launches).toHaveLength(1);
});
it("annotates class inspection with a caller-selected mapping and reuses one engine", async () => {
const { apk, client, inspect, launches } = await connect();
const root = await createTestTempDirectory("rea-android-mapping-");
const first = join(root, "first.txt");
const second = join(root, "second.txt");
await writeFile(first, mappingText("com.example.Original"));
await writeFile(second, mappingText("com.example.Renamed"));
const wire = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_android_class",
);
const contract = ANDROID_TOOL_CONTRACTS.find(
(candidate) => candidate.name === "inspect_android_class",
);
if (wire?.outputSchema === undefined || contract === undefined)
throw new Error("Missing inspect_android_class contract");
const validateOutput = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.record(z.string(), z.unknown()).parse(wire.outputSchema));
const apkSha256 = createHash("sha256")
.update(await readFile(apk))
.digest("hex");
for (const [path, original] of [
[first, "com.example.Original"],
[second, "com.example.Renamed"],
] as const) {
const response = await inspect(path);
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(validateOutput(response.structuredContent)).toBe(true);
const evidence = parseEvidence(
contract.outputSchema.parse(response.structuredContent),
);
expect(evidence.parameters.mapping_path).toBe(path);
expect(evidence.subject?.digest.sha256).toBe(apkSha256);
expect(evidence.limitations.join(" ")).toContain("pg_map_id");
const result = androidResultSchemas.inspect_android_class.parse(
evidence.normalized_result,
);
expect(result.methods.map(({ name }) => name)).toEqual([
"onCreate",
"choose",
"choose",
]);
expect(result.mapping).toMatchObject({
file: {
path,
sha256: createHash("sha256")
.update(await readFile(path))
.digest("hex"),
},
reported: { compiler: "R8", pg_map_id: "7f00" },
coverage: { symbols: 4, mapped: 4, ambiguous: 0, unmapped: 0 },
});
expect(result.mapping?.symbols[0]?.resolution).toEqual({
status: "mapped",
mapped: { name: original },
verified_by: "class",
});
expect(result.mapping?.symbols[3]?.resolution).toMatchObject({
mapped: { name: "pick", parameter_types: ["int"] },
});
}
expect(launches).toHaveLength(1);
// Content changes invalidate the mapping without restarting the APK engine.
await writeFile(second, mappingText("com.example.Updated"));
const refreshed = await inspect(second);
expect(refreshed.isError).not.toBe(true);
const updated = androidResultSchemas.inspect_android_class.parse(
parseEvidence(contract.outputSchema.parse(refreshed.structuredContent))
.normalized_result,
);
expect(updated.mapping?.symbols[0]?.resolution).toMatchObject({
status: "mapped",
mapped: { name: "com.example.Updated" },
});
const unannotated = await client.callTool({
name: "inspect_android_class",
arguments: { path: apk, class_name: "fixture.Target" },
});
expect(unannotated.isError).not.toBe(true);
expect(
androidResultSchemas.inspect_android_class.parse(
parseEvidence(contract.outputSchema.parse(unannotated.structuredContent))
.normalized_result,
).mapping,
).toBeUndefined();
expect(launches).toHaveLength(1);
});
it.each([
["missing", "invalid_request", "invalid_value"],
["relative", "invalid_request", "invalid_format"],
["not UTF-8", "invalid_request", "invalid_format"],
["no class records", "invalid_request", "invalid_format"],
["over budget", "resource_constraint", undefined],
] as const)(
"refuses a %s mapping before launching JADX",
async (scenario, code, reason) => {
const { inspect, launches } = await connect();
const root = await createTestTempDirectory("rea-android-mapping-");
const path = join(root, "mapping.txt");
if (scenario === "not UTF-8")
await writeFile(
path,
Buffer.concat([Buffer.from("a.B -> a.b:\n# "), Buffer.from([0xff])]),
);
if (scenario === "no class records")
await writeFile(path, "com.example.Unused\n void unused()\n");
if (scenario === "over budget") {
await writeFile(path, "");
await truncate(path, ANDROID_MAPPING_LIMITS.fileBytes + 1);
}
const error = parseMcpToolError(
await inspect(scenario === "relative" ? "mapping.txt" : path),
).error;
expect(error.code).toBe(code);
if (reason !== undefined)
expect(error.details).toMatchObject({
issues: [{ path: ["mapping_path"], reason }],
});
// The advertised schema refuses a relative path before Android admission.
if (scenario === "relative")
expect(error.details).toMatchObject({
issues: [
{
path: ["mapping_path"],
reason: "invalid_format",
expected: "regex",
},
],
});
if (scenario === "not UTF-8" || scenario === "no class records")
expect(JSON.stringify(error)).toContain(
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
);
expect(launches).toHaveLength(0);
},
);