From d474768a2b91c448b4014853d47072c7fe4a454e Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 13:35:10 +0800 Subject: [PATCH 1/7] test: record WP7-04 Amap external blocker --- package.json | 3 +- scripts/run-wp7-04-validation.mjs | 100 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 scripts/run-wp7-04-validation.mjs diff --git a/package.json b/package.json index 554c8f9..544cd10 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,8 @@ "test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red", "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts", "test:wp7-01": "node scripts/run-wp7-01-validation.mjs", - "review:wp7-01": "node scripts/record-wp7-01-manual-review.mjs" + "review:wp7-01": "node scripts/record-wp7-01-manual-review.mjs", + "test:wp7-04": "node scripts/run-wp7-04-validation.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/run-wp7-04-validation.mjs b/scripts/run-wp7-04-validation.mjs new file mode 100644 index 0000000..f8c14dd --- /dev/null +++ b/scripts/run-wp7-04-validation.mjs @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-04-amap-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-EXT-003-real-amap"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +function run(command, args) { + const startedAt = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args; + const result = spawnSync(executable, actualArgs, { encoding: "utf8" }); + return { + command: [command, ...args].join(" "), + exit_code: result.status ?? 1, + finished_at: new Date().toISOString(), + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + started_at: startedAt, + }; +} + +const build = run("pnpm", ["build:workspace-packages"]); +const contract = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts", "tests/api/wp6-03-services.test.ts"]) + : { command: "pnpm exec vitest run tests/api/wp4-04-location.test.ts tests/api/wp6-03-services.test.ts", exit_code: 1, finished_at: new Date().toISOString(), started_at: new Date().toISOString() }; +const external = run("pnpm", ["validate:external", "--", "--service", "amap", "--run-id", runId]); +const trace = run("pnpm", ["validate:tdd-trace"]); +const commands = [build, contract, external, trace].map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at })); +const parsedExternal = (() => { + try { return JSON.parse(external.stdout.trim()); } catch { return { status: "invalid_output" }; } +})(); +const blocker = "real_amap_credentials_absent"; +const contractPassed = contract.exit_code === 0; +const tracePassed = trace.exit_code === 0; +writeFileSync(resolve(caseDirectory, "amap-contract.json"), `${JSON.stringify({ + checks: { + location_and_reverse_geocode: "mock_contract_passed", + qps_and_allowlist: "not_verified", + security_binding: "not_verified", + dyn004_hard_stop: "mock_contract_passed", + monthly_hard_limit_1000: "not_verified", + }, + mode: "controlled_real", + real_calls: 0, + status: "externally_blocked", + blocker, + schema_version: "1.0", +}, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "external-calls.json"), `${JSON.stringify({ + blocker, + mode: parsedExternal.mode ?? "mock", + real_calls: 0, + service: "amap", + source_command_status: parsedExternal.status ?? "unknown", + status: "externally_blocked", + schema_version: "1.0", +}, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "redaction.json"), `${JSON.stringify({ + findings: [], + stored_fields: ["service", "status", "logical_limit", "evidence_sha256"], + forbidden_fields_present: false, + status: "passed", + schema_version: "1.0", +}, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "manual-review.json"), `${JSON.stringify({ + blocker, + required_before_release: ["controlled_amap_key", "security_binding", "allowlist_confirmation", "real_location_and_reverse_geocode", "qps_and_1000_call_hard_stop"], + status: "externally_blocked", + schema_version: "1.0", +}, null, 2)}\n`); +const result = { + acceptance_criteria: ["AC-17", "AC-41", "AC-47"], + automation: ["controlled_real", "manual_review"], + blocker, + contract_regression: contractPassed ? "passed" : "failed", + external_calls: 0, + finished_at: new Date().toISOString(), + manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }, + missing_evidence: [], + parent_family: "TDD-WP7-EXT-003", + phase: "green", + release_gate: ["release:P0-A"], + requirements: ["DYN-04", "PRIV-03"], + run_id: runId, + schema_version: "1.0", + status: contractPassed && tracePassed ? "externally_blocked" : "failed", + task_id: "TASK-WP7-04", + test_id: "TDD-WP7-EXT-003-real-amap", + work_package: "WP-7", +}; +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.0" }, null, 2)}\n`); +console.log(JSON.stringify(result, null, 2)); +if (result.status === "failed") process.exit(1); From 4ec8327f5e7420c21401afaa2b75dc01f0ab7ff0 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 14:52:23 +0800 Subject: [PATCH 2/7] feat: add controlled Amap credential probe --- scripts/run-wp7-04-validation.mjs | 29 ++++++---- supervisor/Dada.Supervisor/AmapProbe.cs | 57 +++++++++++++++++++ .../Dada.Supervisor/OfflineCommandRouter.cs | 4 +- 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 supervisor/Dada.Supervisor/AmapProbe.cs diff --git a/scripts/run-wp7-04-validation.mjs b/scripts/run-wp7-04-validation.mjs index f8c14dd..6cede54 100644 --- a/scripts/run-wp7-04-validation.mjs +++ b/scripts/run-wp7-04-validation.mjs @@ -28,35 +28,40 @@ const build = run("pnpm", ["build:workspace-packages"]); const contract = build.exit_code === 0 ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts", "tests/api/wp6-03-services.test.ts"]) : { command: "pnpm exec vitest run tests/api/wp4-04-location.test.ts tests/api/wp6-03-services.test.ts", exit_code: 1, finished_at: new Date().toISOString(), started_at: new Date().toISOString() }; -const external = run("pnpm", ["validate:external", "--", "--service", "amap", "--run-id", runId]); +const supervisorBuild = run("dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configuration", "Release"]); +const supervisorExe = resolve("supervisor", "Dada.Supervisor", "bin", "Release", "net8.0-windows", "Dada.Supervisor.exe"); +const external = supervisorBuild.exit_code === 0 && existsSync(supervisorExe) + ? run(supervisorExe, ["secrets", "probe", "api-amap"]) + : { command: `${supervisorExe} secrets probe api-amap`, exit_code: 3, finished_at: new Date().toISOString(), started_at: new Date().toISOString(), stderr: "supervisor_probe_unavailable", stdout: "" }; const trace = run("pnpm", ["validate:tdd-trace"]); const commands = [build, contract, external, trace].map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at })); const parsedExternal = (() => { - try { return JSON.parse(external.stdout.trim()); } catch { return { status: "invalid_output" }; } + try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return { status: "invalid_output" }; } })(); -const blocker = "real_amap_credentials_absent"; +const probePassed = external.exit_code === 0 && parsedExternal.code === "amap_probe_passed" && parsedExternal.real_calls === 2; +const blocker = probePassed ? "manual_acceptance_required" : (parsedExternal.code ?? "real_amap_validation_failed"); const contractPassed = contract.exit_code === 0; const tracePassed = trace.exit_code === 0; writeFileSync(resolve(caseDirectory, "amap-contract.json"), `${JSON.stringify({ checks: { - location_and_reverse_geocode: "mock_contract_passed", + location_and_reverse_geocode: probePassed ? "real_probe_passed" : "not_verified", qps_and_allowlist: "not_verified", security_binding: "not_verified", - dyn004_hard_stop: "mock_contract_passed", + dyn004_hard_stop: "contract_passed", monthly_hard_limit_1000: "not_verified", }, - mode: "controlled_real", - real_calls: 0, + mode: probePassed ? "controlled_real" : "blocked", + real_calls: parsedExternal.real_calls ?? 0, status: "externally_blocked", blocker, schema_version: "1.0", }, null, 2)}\n`); writeFileSync(resolve(caseDirectory, "external-calls.json"), `${JSON.stringify({ blocker, - mode: parsedExternal.mode ?? "mock", - real_calls: 0, + mode: probePassed ? "controlled_real" : "blocked", + real_calls: parsedExternal.real_calls ?? 0, service: "amap", - source_command_status: parsedExternal.status ?? "unknown", + source_command_status: parsedExternal.code ?? "unknown", status: "externally_blocked", schema_version: "1.0", }, null, 2)}\n`); @@ -69,7 +74,7 @@ writeFileSync(resolve(caseDirectory, "redaction.json"), `${JSON.stringify({ }, null, 2)}\n`); writeFileSync(resolve(caseDirectory, "manual-review.json"), `${JSON.stringify({ blocker, - required_before_release: ["controlled_amap_key", "security_binding", "allowlist_confirmation", "real_location_and_reverse_geocode", "qps_and_1000_call_hard_stop"], + required_before_release: probePassed ? ["security_binding", "allowlist_confirmation", "qps_and_1000_call_hard_stop"] : ["controlled_amap_key", "security_binding", "allowlist_confirmation", "real_location_and_reverse_geocode", "qps_and_1000_call_hard_stop"], status: "externally_blocked", schema_version: "1.0", }, null, 2)}\n`); @@ -78,7 +83,7 @@ const result = { automation: ["controlled_real", "manual_review"], blocker, contract_regression: contractPassed ? "passed" : "failed", - external_calls: 0, + external_calls: parsedExternal.real_calls ?? 0, finished_at: new Date().toISOString(), manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }, missing_evidence: [], diff --git a/supervisor/Dada.Supervisor/AmapProbe.cs b/supervisor/Dada.Supervisor/AmapProbe.cs new file mode 100644 index 0000000..5a40075 --- /dev/null +++ b/supervisor/Dada.Supervisor/AmapProbe.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Text.Json; + +namespace Dada.Supervisor; + +internal static class AmapProbe +{ + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + + internal static int Run(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + { + Write("amap_credentials_missing", false, 0, null, null); + return 3; + } + + try + { + var reverse = Call("https://restapi.amap.com/v3/geocode/regeo?location=120.6994,27.9943&extensions=base", key).GetAwaiter().GetResult(); + var geocode = Call($"https://restapi.amap.com/v3/geocode/geo?address={Uri.EscapeDataString("北京市天安门")}", key).GetAwaiter().GetResult(); + var success = reverse.Status == "1" && geocode.Status == "1"; + Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, 2, reverse.Status, geocode.Status); + return success ? 0 : 3; + } + catch (TaskCanceledException) + { + Write("amap_probe_timeout", false, 0, null, null); + return 3; + } + catch (HttpRequestException exception) + { + Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, 0, null, null); + return 3; + } + catch (JsonException) + { + Write("amap_probe_invalid_response", false, 0, null, null); + return 3; + } + } + + private static async Task Call(string endpoint, string key) + { + using var response = await Client.GetAsync($"{endpoint}&key={Uri.EscapeDataString(key)}"); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + var root = document.RootElement; + return new ProbeResponse(root.GetProperty("status").GetString() ?? "", root.TryGetProperty("infocode", out var code) ? code.GetString() : null); + } + + private static void Write(string code, bool success, int realCalls, string? reverseStatus, string? geocodeStatus) => + Console.WriteLine(JsonSerializer.Serialize(new { code, success, real_calls = realCalls, reverse_status = reverseStatus, geocode_status = geocodeStatus })); + + private sealed record ProbeResponse(string Status, string? InfoCode); +} diff --git a/supervisor/Dada.Supervisor/OfflineCommandRouter.cs b/supervisor/Dada.Supervisor/OfflineCommandRouter.cs index 05700b5..a92fc58 100644 --- a/supervisor/Dada.Supervisor/OfflineCommandRouter.cs +++ b/supervisor/Dada.Supervisor/OfflineCommandRouter.cs @@ -83,6 +83,8 @@ internal static class OfflineCommandRouter store.Write(target, value); WriteResult("credential_saved", true); return 0; + case "probe" when target == CredentialCatalog.ApiAmap: + return AmapProbe.Run(store.Read(target)); default: return Usage(); } @@ -198,7 +200,7 @@ internal static class OfflineCommandRouter private static int Usage() { - WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false); + WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear|probe; admin-allowlist add|remove|status; doctor", false); return 2; } } From 2bfb5f295399ca69a2c31815e0f0d368584825ba Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 15:39:46 +0800 Subject: [PATCH 3/7] test: prove Amap monthly egress hard stop --- tests/api/wp7-04-amap-release-gate.test.ts | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/api/wp7-04-amap-release-gate.test.ts diff --git a/tests/api/wp7-04-amap-release-gate.test.ts b/tests/api/wp7-04-amap-release-gate.test.ts new file mode 100644 index 0000000..affc4fd --- /dev/null +++ b/tests/api/wp7-04-amap-release-gate.test.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js"; +import { createApp } from "../../apps/api/src/app.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const roots: string[] = []; +const registrations: RegistrationService[] = []; + +afterEach(() => { + for (const registration of registrations.splice(0)) registration.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +function createRegistration() { + const now = Date.parse("2026-08-04T09:00:00.000Z"); + const root = mkdtempSync(join(tmpdir(), "dada-wp7-04-amap-")); + roots.push(root); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0xa1), + clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0xa2), + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0xa3), + }); + registrations.push(registration); + + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) + VALUES (?, ?, 'user', 'active', 1, ?, ?) + `).run(userId, `${userId}@example.invalid`, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Release Gate User', '@release_gate')").run(userId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now); + return { registration, session: registration.issueAuthenticatedSession(userId, "user") }; +} + +describe("TDD-WP7-EXT-003 Amap release hard stop", () => { + it("admits the simulated 1000th request and blocks the 1001st before provider egress", async () => { + const { registration, session } = createRegistration(); + registration.database.prepare(` + UPDATE external_service_usage + SET used_count = 999, service_status = 'active', pause_reason = NULL + WHERE service_id = 'amap_web_service' AND period_type = 'monthly' + `).run(); + + const amap = new MockAmapAdapter(); + const app = await createApp({ amap, browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const headers = { + cookie: `dada_session=${session.sessionToken}`, + host: "127.0.0.1:43121", + origin: "http://127.0.0.1:43121", + "x-csrf-token": registration.issueUserCsrfToken(session.sessionToken), + }; + + const thousandth = await app.inject({ + headers, + method: "POST", + payload: { latitude: 0, longitude: 0 }, + url: "/api/v1/location/reverse-geocode", + }); + const thousandAndFirst = await app.inject({ + headers, + method: "POST", + payload: { latitude: 0, longitude: 0 }, + url: "/api/v1/location/reverse-geocode", + }); + + expect(thousandth.statusCode).toBe(200); + expect(thousandAndFirst.statusCode).toBe(503); + expect(amap.calls).toHaveLength(1); + expect(registration.serviceUsage.read("amap_web_service")).toEqual([ + expect.objectContaining({ hardLimit: 1_000, status: "paused_quota", usedCount: 1_000 }), + ]); + await app.close(); + }); +}); From a7772a7e926541aaae4adc7cb5a3125ddb94407e Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 15:49:25 +0800 Subject: [PATCH 4/7] test: harden WP7-04 release gate evidence --- scripts/run-wp7-04-validation.mjs | 235 +++++++++++++++++++++++------- 1 file changed, 186 insertions(+), 49 deletions(-) diff --git a/scripts/run-wp7-04-validation.mjs b/scripts/run-wp7-04-validation.mjs index 6cede54..c06d80d 100644 --- a/scripts/run-wp7-04-validation.mjs +++ b/scripts/run-wp7-04-validation.mjs @@ -9,13 +9,13 @@ const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-EXT-003-real-amap" if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); mkdirSync(caseDirectory, { recursive: true }); -function run(command, args) { +function run(command, args, evidenceCommand = [command, ...args].join(" ")) { const startedAt = new Date().toISOString(); const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command; const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args; const result = spawnSync(executable, actualArgs, { encoding: "utf8" }); return { - command: [command, ...args].join(" "), + command: evidenceCommand, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), stderr: result.stderr ?? "", @@ -24,82 +24,219 @@ function run(command, args) { }; } +function skipped(command) { + const timestamp = new Date().toISOString(); + return { command, exit_code: 1, finished_at: timestamp, started_at: timestamp, stderr: "prerequisite_failed", stdout: "" }; +} + +function readConsoleReview() { + const raw = process.env.DADA_AMAP_CONSOLE_REVIEW_JSON; + const fallback = { + allowlist: "not_verified", + auto_scaling: "not_verified", + paid_fallback: "not_verified", + qps: "not_verified", + reviewed_at: null, + security_restriction: "not_verified", + service_binding: "not_verified", + source: "not_supplied", + valid: true, + }; + if (!raw) return fallback; + try { + const parsed = JSON.parse(raw); + const requiredKeys = ["allowlist", "auto_scaling", "paid_fallback", "qps", "reviewed_at", "security_restriction", "service_binding", "source"]; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).sort().join("|") !== requiredKeys.sort().join("|")) { + return { ...fallback, source: "invalid_input", valid: false }; + } + const statusFields = ["allowlist", "qps", "security_restriction", "service_binding"]; + const statusValid = statusFields.every((field) => ["failed", "not_verified", "passed"].includes(parsed[field])); + const disabledFieldsValid = ["auto_scaling", "paid_fallback"].every((field) => ["disabled", "not_verified"].includes(parsed[field])); + const reviewedAtValid = typeof parsed.reviewed_at === "string" && Number.isFinite(Date.parse(parsed.reviewed_at)); + if (!statusValid || !disabledFieldsValid || !reviewedAtValid || parsed.source !== "amap_console_manual_review") { + return { ...fallback, source: "invalid_input", valid: false }; + } + return { ...parsed, valid: true }; + } catch { + return { ...fallback, source: "invalid_input", valid: false }; + } +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function probeCode(value) { + return typeof value === "string" && /^[a-z0-9_]{1,64}$/.test(value) ? value : "invalid_output"; +} + +function containsForbiddenEvidence(value) { + const serialized = JSON.stringify(value); + return [ + /[A-Za-z]:[\\/](?:Users|Documents)[\\/]/i, + /"(?:api[_-]?key|credential|secret_value|latitude|longitude|coordinates|email_address|ip_address)"\s*:/i, + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + ].some((pattern) => pattern.test(serialized)); +} + +const consoleReview = readConsoleReview(); const build = run("pnpm", ["build:workspace-packages"]); -const contract = build.exit_code === 0 - ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts", "tests/api/wp6-03-services.test.ts"]) - : { command: "pnpm exec vitest run tests/api/wp4-04-location.test.ts tests/api/wp6-03-services.test.ts", exit_code: 1, finished_at: new Date().toISOString(), started_at: new Date().toISOString() }; +const locationRegression = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts"]) + : skipped("pnpm exec vitest run tests/api/wp4-04-location.test.ts"); +const serviceRegression = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp6-03-services.test.ts"]) + : skipped("pnpm exec vitest run tests/api/wp6-03-services.test.ts"); +const localHardStop = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-release-gate.test.ts"]) + : skipped("pnpm exec vitest run tests/api/wp7-04-amap-release-gate.test.ts"); +const consentFlow = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/unit/wp4-04-palette-dynamic.test.ts"]) + : skipped("pnpm exec vitest run tests/unit/wp4-04-palette-dynamic.test.ts"); const supervisorBuild = run("dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configuration", "Release"]); const supervisorExe = resolve("supervisor", "Dada.Supervisor", "bin", "Release", "net8.0-windows", "Dada.Supervisor.exe"); const external = supervisorBuild.exit_code === 0 && existsSync(supervisorExe) - ? run(supervisorExe, ["secrets", "probe", "api-amap"]) - : { command: `${supervisorExe} secrets probe api-amap`, exit_code: 3, finished_at: new Date().toISOString(), started_at: new Date().toISOString(), stderr: "supervisor_probe_unavailable", stdout: "" }; + ? run(supervisorExe, ["secrets", "probe", "api-amap"], "Dada.Supervisor.exe secrets probe api-amap") + : skipped("Dada.Supervisor.exe secrets probe api-amap"); const trace = run("pnpm", ["validate:tdd-trace"]); -const commands = [build, contract, external, trace].map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at })); +const security = run("pnpm", ["test:security"]); +const commands = [build, locationRegression, serviceRegression, localHardStop, consentFlow, supervisorBuild, external, trace, security] + .map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at })); const parsedExternal = (() => { - try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return { status: "invalid_output" }; } + try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return {}; } })(); -const probePassed = external.exit_code === 0 && parsedExternal.code === "amap_probe_passed" && parsedExternal.real_calls === 2; -const blocker = probePassed ? "manual_acceptance_required" : (parsedExternal.code ?? "real_amap_validation_failed"); -const contractPassed = contract.exit_code === 0; -const tracePassed = trace.exit_code === 0; -writeFileSync(resolve(caseDirectory, "amap-contract.json"), `${JSON.stringify({ +const safeProbeCode = probeCode(parsedExternal.code); +const realCalls = Number.isSafeInteger(parsedExternal.real_calls) && parsedExternal.real_calls >= 0 && parsedExternal.real_calls <= 2 + ? parsedExternal.real_calls + : 0; +const probePassed = external.exit_code === 0 && safeProbeCode === "amap_probe_passed" && realCalls === 2; +const hardStopPassed = localHardStop.exit_code === 0; +const consentPassed = consentFlow.exit_code === 0; +const regressionPassed = locationRegression.exit_code === 0 && serviceRegression.exit_code === 0; +const automatedPassed = build.exit_code === 0 && hardStopPassed && consentPassed && regressionPassed && trace.exit_code === 0 && security.exit_code === 0; +const manualReviewPassed = consoleReview.valid + && consoleReview.service_binding === "passed" + && consoleReview.qps === "passed" + && consoleReview.allowlist === "passed" + && consoleReview.security_restriction === "passed" + && consoleReview.paid_fallback === "disabled" + && consoleReview.auto_scaling === "disabled"; + +const requiredBeforeRelease = []; +if (!probePassed) requiredBeforeRelease.push("real_location_and_reverse_geocode"); +if (consoleReview.service_binding !== "passed") requiredBeforeRelease.push("service_binding"); +if (consoleReview.qps !== "passed") requiredBeforeRelease.push("qps_confirmation"); +if (consoleReview.allowlist !== "passed") requiredBeforeRelease.push("allowlist_confirmation"); +if (consoleReview.security_restriction !== "passed") requiredBeforeRelease.push("security_restriction"); +if (consoleReview.paid_fallback !== "disabled") requiredBeforeRelease.push("paid_fallback_disabled"); +if (consoleReview.auto_scaling !== "disabled") requiredBeforeRelease.push("auto_scaling_disabled"); +if (!hardStopPassed) requiredBeforeRelease.push("monthly_1000_hard_stop"); +if (!consentPassed) requiredBeforeRelease.push("dyn004_confirmation"); + +const blocker = !automatedPassed + ? "automated_regression_failed" + : !probePassed + ? safeProbeCode + : !consoleReview.valid + ? "manual_review_invalid" + : consoleReview.allowlist === "failed" + ? "amap_allowlist_unconfigured" + : consoleReview.security_restriction === "failed" + ? "amap_security_restriction_unconfigured" + : !manualReviewPassed + ? "manual_acceptance_required" + : null; +const status = !automatedPassed ? "failed" : probePassed && manualReviewPassed ? "passed" : "externally_blocked"; + +const contractEvidence = { + blocker, checks: { + allowlist: consoleReview.allowlist === "passed" ? "manual_console_passed" : consoleReview.allowlist, + auto_scaling: consoleReview.auto_scaling, + dyn004_hard_stop: consentPassed ? "confirmation_contract_passed" : "failed", location_and_reverse_geocode: probePassed ? "real_probe_passed" : "not_verified", - qps_and_allowlist: "not_verified", - security_binding: "not_verified", - dyn004_hard_stop: "contract_passed", - monthly_hard_limit_1000: "not_verified", + monthly_hard_limit_1000: hardStopPassed ? "local_pre_egress_passed" : "failed", + paid_fallback: consoleReview.paid_fallback, + provider_qps: consoleReview.qps === "passed" ? "manual_console_passed" : consoleReview.qps, + security_binding: consoleReview.security_restriction === "passed" ? "manual_console_passed" : consoleReview.security_restriction, + service_binding: consoleReview.service_binding === "passed" ? "manual_console_passed" : consoleReview.service_binding, }, mode: probePassed ? "controlled_real" : "blocked", - real_calls: parsedExternal.real_calls ?? 0, - status: "externally_blocked", - blocker, - schema_version: "1.0", -}, null, 2)}\n`); -writeFileSync(resolve(caseDirectory, "external-calls.json"), `${JSON.stringify({ + real_calls: realCalls, + status, + schema_version: "1.1", +}; +const externalCallsEvidence = { blocker, mode: probePassed ? "controlled_real" : "blocked", - real_calls: parsedExternal.real_calls ?? 0, + real_calls: realCalls, + request_scope: ["geocoding", "reverse_geocoding"], service: "amap", - source_command_status: parsedExternal.code ?? "unknown", - status: "externally_blocked", - schema_version: "1.0", -}, null, 2)}\n`); -writeFileSync(resolve(caseDirectory, "redaction.json"), `${JSON.stringify({ - findings: [], - stored_fields: ["service", "status", "logical_limit", "evidence_sha256"], - forbidden_fields_present: false, - status: "passed", - schema_version: "1.0", -}, null, 2)}\n`); -writeFileSync(resolve(caseDirectory, "manual-review.json"), `${JSON.stringify({ + source_command_status: safeProbeCode, + status, + schema_version: "1.1", +}; +const manualReviewEvidence = { blocker, - required_before_release: probePassed ? ["security_binding", "allowlist_confirmation", "qps_and_1000_call_hard_stop"] : ["controlled_amap_key", "security_binding", "allowlist_confirmation", "real_location_and_reverse_geocode", "qps_and_1000_call_hard_stop"], - status: "externally_blocked", - schema_version: "1.0", -}, null, 2)}\n`); + checks: { + allowlist: consoleReview.allowlist, + auto_scaling: consoleReview.auto_scaling, + paid_fallback: consoleReview.paid_fallback, + qps: consoleReview.qps, + security_restriction: consoleReview.security_restriction, + service_binding: consoleReview.service_binding, + }, + required_before_release: requiredBeforeRelease, + reviewed_at: consoleReview.reviewed_at, + source: consoleReview.source, + status, + schema_version: "1.1", +}; const result = { acceptance_criteria: ["AC-17", "AC-41", "AC-47"], automation: ["controlled_real", "manual_review"], blocker, - contract_regression: contractPassed ? "passed" : "failed", - external_calls: parsedExternal.real_calls ?? 0, + contract_regression: automatedPassed ? "passed" : "failed", + external_calls: realCalls, finished_at: new Date().toISOString(), manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }, - missing_evidence: [], + missing_evidence: requiredBeforeRelease, parent_family: "TDD-WP7-EXT-003", phase: "green", release_gate: ["release:P0-A"], requirements: ["DYN-04", "PRIV-03"], run_id: runId, - schema_version: "1.0", - status: contractPassed && tracePassed ? "externally_blocked" : "failed", + schema_version: "1.1", + status, task_id: "TASK-WP7-04", test_id: "TDD-WP7-EXT-003-real-amap", work_package: "WP-7", }; -writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); -writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); -writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.0" }, null, 2)}\n`); +const commandsEvidence = { commands, run_id: runId, schema_version: "1.1" }; +const evidenceIndex = { cases: [{ status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.1" }; +const redactionInputs = [contractEvidence, externalCallsEvidence, manualReviewEvidence, result, commandsEvidence, evidenceIndex]; +const evidenceRedactionPassed = !redactionInputs.some(containsForbiddenEvidence); +const redactionEvidence = { + findings: evidenceRedactionPassed ? [] : ["forbidden_evidence_field"], + forbidden_fields_present: !evidenceRedactionPassed, + source_security_scan: security.exit_code === 0 ? "passed" : "failed", + status: evidenceRedactionPassed && security.exit_code === 0 ? "passed" : "failed", + stored_fields: ["service", "status", "logical_limit", "manual_review_status"], + schema_version: "1.1", +}; +if (redactionEvidence.status !== "passed") { + result.status = "failed"; + result.blocker = "redaction_failed"; + evidenceIndex.status = "failed"; + evidenceIndex.cases[0].status = "failed"; +} + +writeJson(resolve(caseDirectory, "amap-contract.json"), contractEvidence); +writeJson(resolve(caseDirectory, "external-calls.json"), externalCallsEvidence); +writeJson(resolve(caseDirectory, "redaction.json"), redactionEvidence); +writeJson(resolve(caseDirectory, "manual-review.json"), manualReviewEvidence); +writeJson(resolve(caseDirectory, "commands.json"), commandsEvidence); +writeJson(resolve(caseDirectory, "result.json"), result); +writeJson(resolve(runDirectory, "evidence.json"), evidenceIndex); console.log(JSON.stringify(result, null, 2)); if (result.status === "failed") process.exit(1); From 8c349fb56cf8b92d332f8dc13fa92253b238e58c Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:09:04 +0800 Subject: [PATCH 5/7] fix: wire production Amap adapter --- apps/api/src/amap-adapter.ts | 151 +++++++++++++++++- apps/api/src/main.ts | 9 +- apps/api/src/supervisor-channel.ts | 15 +- apps/web/src/generated/api/types.gen.ts | 2 +- openapi/openapi.json | 18 ++- packages/shared-contracts/src/location.ts | 2 +- .../wp7-04-amap-production-adapter.test.ts | 47 ++++++ 7 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 tests/api/wp7-04-amap-production-adapter.test.ts diff --git a/apps/api/src/amap-adapter.ts b/apps/api/src/amap-adapter.ts index 17ae82c..97ba7f1 100644 --- a/apps/api/src/amap-adapter.ts +++ b/apps/api/src/amap-adapter.ts @@ -1,5 +1,31 @@ +import { request as httpsRequest } from "node:https"; + +const amapHostname = "restapi.amap.com" as const; +const amapMaxResponseBytes = 65_536; +const amapTimeoutMs = 15_000; + +export interface AmapHttpRequest { + allowRedirects: false; + hostname: typeof amapHostname; + maxResponseBytes: number; + method: "GET"; + path: string; + protocol: "https:"; + rejectUnauthorized: true; + timeoutMs: number; +} + +type AmapRequester = (request: AmapHttpRequest) => Promise; + +export class AmapAdapterError extends Error { + constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") { + super(code); + } +} + export interface AmapAdapter { - reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>; + dispose?(): void; + reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>; } export class MockAmapAdapter implements AmapAdapter { @@ -13,3 +39,126 @@ export class MockAmapAdapter implements AmapAdapter { }; } } + +function requestAmapJson(input: AmapHttpRequest) { + return new Promise((resolve, reject) => { + if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) { + reject(new AmapAdapterError("amap_invalid_request")); + return; + } + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + callback(); + }; + const request = httpsRequest({ + headers: { Accept: "application/json" }, + hostname: input.hostname, + method: input.method, + path: input.path, + port: 443, + protocol: input.protocol, + rejectUnauthorized: input.rejectUnauthorized, + servername: input.hostname, + }, (response) => { + const statusCode = response.statusCode ?? 0; + if (statusCode >= 300 && statusCode < 400) { + response.resume(); + finish(() => reject(new AmapAdapterError("amap_redirect_rejected"))); + return; + } + if (statusCode !== 200) { + response.resume(); + finish(() => reject(new AmapAdapterError("amap_provider_unavailable"))); + return; + } + const declaredLength = Number(response.headers["content-length"] ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) { + response.destroy(); + finish(() => reject(new AmapAdapterError("amap_response_too_large"))); + return; + } + const chunks: Buffer[] = []; + let receivedBytes = 0; + response.on("data", (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += bytes.length; + if (receivedBytes > input.maxResponseBytes) { + response.destroy(); + finish(() => reject(new AmapAdapterError("amap_response_too_large"))); + return; + } + chunks.push(bytes); + }); + response.on("end", () => { + finish(() => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + reject(new AmapAdapterError("amap_invalid_response")); + } finally { + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + } + }); + }); + }); + request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout"))); + request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable")))); + request.end(); + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export class RealAmapAdapter implements AmapAdapter { + private readonly credential: Buffer; + private readonly requester: AmapRequester; + private disposed = false; + + constructor(value: string, options: { request?: AmapRequester } = {}) { + if (!value.trim()) throw new AmapAdapterError("amap_invalid_request"); + this.credential = Buffer.from(value, "utf8"); + this.requester = options.request ?? requestAmapJson; + } + + async reverseGeocode(coordinates: { latitude: number; longitude: number }) { + if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed"); + if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90 + || !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) { + throw new AmapAdapterError("amap_invalid_request"); + } + const query = new URLSearchParams({ + extensions: "base", + key: this.credential.toString("utf8"), + location: `${coordinates.longitude},${coordinates.latitude}`, + }); + const response = await this.requester({ + allowRedirects: false, + hostname: amapHostname, + maxResponseBytes: amapMaxResponseBytes, + method: "GET", + path: `/v3/geocode/regeo?${query.toString()}`, + protocol: "https:", + rejectUnauthorized: true, + timeoutMs: amapTimeoutMs, + }); + if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) { + throw new AmapAdapterError("amap_provider_rejected"); + } + const formattedValue = typeof response.regeocode.formatted_address === "string" + ? response.regeocode.formatted_address.trim() + : ""; + if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response"); + return { formattedValue, serviceMode: "real" as const }; + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.credential.fill(0); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0af86c9..f7034b4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -17,7 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; import { ModelConfigurationService } from "./model-configuration.js"; -import { MockAmapAdapter } from "./amap-adapter.js"; +import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js"; import { StickerReleaseService } from "./sticker-releases.js"; import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; @@ -30,10 +30,12 @@ let latestExports: LatestExportService | undefined; let models: ModelConfigurationService | undefined; let recentAssets: RecentAssetService | undefined; let stickers: StickerReleaseService | undefined; +let amap: AmapAdapter = new MockAmapAdapter(); const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { const clients = initializeApiCredentialClients(await receiveApiCredentials()); try { + amap = clients.amap; const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) .update(`Dada/P0A/${purpose}/v1`, "utf8") .digest(); @@ -57,6 +59,8 @@ if (credentialChannelEnabled) { recentAssets = new RecentAssetService({ database: registration.database }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { + amap.dispose?.(); + amap = new MockAmapAdapter(); stickers?.close(); stickers = undefined; latestExports?.close(); @@ -92,7 +96,7 @@ const adminDiagnostics = adminServicesStorage const app = await createApp({ ...(adminServicesStorage ? { adminServicesStorage } : {}), ...(adminDiagnostics ? { adminDiagnostics } : {}), - amap: new MockAmapAdapter(), + amap, ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), ...(latestExports ? { latestExports } : {}), @@ -115,6 +119,7 @@ if (controlPipeIndex >= 0) { if (!controlPipe) throw new Error("Supervisor control pipe name is required."); const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + amap.dispose?.(); latestExports?.close(); credits?.close(); projects?.close(); diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 17b8cc8..c2339bb 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -1,5 +1,7 @@ import { createConnection } from "node:net"; +import { RealAmapAdapter } from "./amap-adapter.js"; + const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { @@ -26,10 +28,15 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); - const adminPepperValue = credentials["Dada/P0A/admin/pepper"]; - for (const name of API_CREDENTIALS) credentials[name] = ""; - if (!configured) throw new Error("API credential client initialization failed."); - return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") }; + try { + if (!configured) throw new Error("API credential client initialization failed."); + return { + adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"), + amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]), + }; + } finally { + for (const name of API_CREDENTIALS) credentials[name] = ""; + } } export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index a38445e..f38130c 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = { export type ReverseGeocodeResponse = { "formatted_value": string; - "service_mode": "mock"; + "service_mode": "mock" | "real"; "status": "resolved"; }; diff --git a/openapi/openapi.json b/openapi/openapi.json index a1e7e95..8464f1e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -5621,10 +5621,20 @@ "type": "string" }, "service_mode": { - "enum": [ - "mock" - ], - "type": "string" + "anyOf": [ + { + "enum": [ + "mock" + ], + "type": "string" + }, + { + "enum": [ + "real" + ], + "type": "string" + } + ] }, "status": { "enum": [ diff --git a/packages/shared-contracts/src/location.ts b/packages/shared-contracts/src/location.ts index 6ec9b88..7ab010a 100644 --- a/packages/shared-contracts/src/location.ts +++ b/packages/shared-contracts/src/location.ts @@ -7,7 +7,7 @@ export const ReverseGeocodeRequestSchema = Type.Object({ export const ReverseGeocodeResponseSchema = Type.Object({ formatted_value: Type.String({ maxLength: 200, minLength: 1 }), - service_mode: Type.Literal("mock"), + service_mode: Type.Union([Type.Literal("mock"), Type.Literal("real")]), status: Type.Literal("resolved"), }, { additionalProperties: false, $id: "ReverseGeocodeResponse" }); diff --git a/tests/api/wp7-04-amap-production-adapter.test.ts b/tests/api/wp7-04-amap-production-adapter.test.ts new file mode 100644 index 0000000..bd8ee75 --- /dev/null +++ b/tests/api/wp7-04-amap-production-adapter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { RealAmapAdapter } from "../../apps/api/src/amap-adapter.js"; +import { initializeApiCredentialClients } from "../../apps/api/src/supervisor-channel.js"; + +describe("TDD-WP7-EXT-003 production Amap adapter", () => { + it("uses the fixed HTTPS provider boundary and returns only the formatted location", async () => { + const request = vi.fn(async () => ({ + regeocode: { formatted_address: "模拟省模拟市" }, + status: "1", + })); + const adapter = new RealAmapAdapter("fixture-amap-value", { request }); + + await expect(adapter.reverseGeocode({ latitude: 12.3456, longitude: 65.4321 })).resolves.toEqual({ + formattedValue: "模拟省模拟市", + serviceMode: "real", + }); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0]?.[0]).toMatchObject({ + allowRedirects: false, + hostname: "restapi.amap.com", + maxResponseBytes: 65_536, + method: "GET", + protocol: "https:", + rejectUnauthorized: true, + timeoutMs: 15_000, + }); + expect(request.mock.calls[0]?.[0].path).toContain("/v3/geocode/regeo?"); + expect(request.mock.calls[0]?.[0].path).toContain("location=65.4321%2C12.3456"); + adapter.dispose(); + await expect(adapter.reverseGeocode({ latitude: 0, longitude: 0 })).rejects.toThrow("amap_adapter_disposed"); + }); + + it("constructs the real client from the API credential channel and clears the source values", () => { + const credentials = { + "Dada/P0A/admin/pepper": "fixture-admin-value", + "Dada/P0A/api/amap": "fixture-amap-value", + "Dada/P0A/api/resend": "fixture-resend-value", + }; + + const clients = initializeApiCredentialClients(credentials); + expect(clients.amap).toBeInstanceOf(RealAmapAdapter); + expect(Object.values(credentials)).toEqual(["", "", ""]); + clients.amap.dispose(); + clients.adminAllowlistPepper.fill(0); + }); +}); From d03b491d2f436af5169daa71781ad53a3b328e11 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:28:24 +0800 Subject: [PATCH 6/7] fix: harden controlled Amap probe --- supervisor/Dada.Supervisor.Tests/Program.cs | 15 +++++ supervisor/Dada.Supervisor/AmapProbe.cs | 68 ++++++++++++++++++--- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/supervisor/Dada.Supervisor.Tests/Program.cs b/supervisor/Dada.Supervisor.Tests/Program.cs index c148984..f173b01 100644 --- a/supervisor/Dada.Supervisor.Tests/Program.cs +++ b/supervisor/Dada.Supervisor.Tests/Program.cs @@ -32,6 +32,7 @@ internal static class Program { var security = await TestCredentialBoundaryAsync(); var supervisor = await TestSupervisorLifecycleAsync(); + await TestAmapProbeSecurityAsync(); TestSecureConfigurationPersistence(); TestStructuredLogging(); WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security); @@ -46,6 +47,20 @@ internal static class Program } } + private static async Task TestAmapProbeSecurityAsync() + { + using var handler = AmapProbe.CreateHandler(); + False(handler.AllowAutoRedirect, "Amap probe redirects disabled"); + Equal(1, handler.MaxConnectionsPerServer, "Amap probe per-server connection cap"); + True(AmapProbe.IsAllowedEndpoint(new Uri("https://restapi.amap.com/v3/geocode/regeo")), "Amap fixed HTTPS endpoint accepted"); + False(AmapProbe.IsAllowedEndpoint(new Uri("http://restapi.amap.com/v3/geocode/regeo")), "Amap HTTP endpoint rejected"); + False(AmapProbe.IsAllowedEndpoint(new Uri("https://example.invalid/v3/geocode/regeo")), "Amap alternate host rejected"); + using var oversized = new ByteArrayContent(new byte[AmapProbe.MaximumResponseBytes + 1]); + await ThrowsAsync( + () => AmapProbe.ReadBoundedJsonAsync(oversized), + "Amap oversized response must be rejected before parsing"); + } + private static void TestStructuredLogging() { Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit"); diff --git a/supervisor/Dada.Supervisor/AmapProbe.cs b/supervisor/Dada.Supervisor/AmapProbe.cs index 5a40075..2c6a650 100644 --- a/supervisor/Dada.Supervisor/AmapProbe.cs +++ b/supervisor/Dada.Supervisor/AmapProbe.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Net; using System.Text.Json; @@ -5,7 +6,24 @@ namespace Dada.Supervisor; internal static class AmapProbe { - private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + internal const int MaximumResponseBytes = 65_536; + private const string ProviderHostname = "restapi.amap.com"; + private static readonly HttpClient Client = new(CreateHandler()) { Timeout = TimeSpan.FromSeconds(15) }; + + internal static SocketsHttpHandler CreateHandler() => new() + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(10), + MaxConnectionsPerServer = 1, + }; + + internal static bool IsAllowedEndpoint(Uri endpoint) => + endpoint.Scheme == Uri.UriSchemeHttps + && endpoint.Host.Equals(ProviderHostname, StringComparison.OrdinalIgnoreCase) + && (endpoint.IsDefaultPort || endpoint.Port == 443) + && string.IsNullOrEmpty(endpoint.UserInfo) + && endpoint.AbsolutePath is "/v3/geocode/regeo" or "/v3/geocode/geo"; internal static int Run(string? key) { @@ -15,41 +33,73 @@ internal static class AmapProbe return 3; } + var realCalls = 0; try { + realCalls += 1; var reverse = Call("https://restapi.amap.com/v3/geocode/regeo?location=120.6994,27.9943&extensions=base", key).GetAwaiter().GetResult(); + realCalls += 1; var geocode = Call($"https://restapi.amap.com/v3/geocode/geo?address={Uri.EscapeDataString("北京市天安门")}", key).GetAwaiter().GetResult(); var success = reverse.Status == "1" && geocode.Status == "1"; - Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, 2, reverse.Status, geocode.Status); + Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, realCalls, reverse.Status, geocode.Status); return success ? 0 : 3; } catch (TaskCanceledException) { - Write("amap_probe_timeout", false, 0, null, null); + Write("amap_probe_timeout", false, realCalls, null, null); return 3; } catch (HttpRequestException exception) { - Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, 0, null, null); + Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, realCalls, null, null); return 3; } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or InvalidDataException) { - Write("amap_probe_invalid_response", false, 0, null, null); + Write("amap_probe_invalid_response", false, realCalls, null, null); return 3; } } private static async Task Call(string endpoint, string key) { - using var response = await Client.GetAsync($"{endpoint}&key={Uri.EscapeDataString(key)}"); + var requestUri = new Uri($"{endpoint}&key={Uri.EscapeDataString(key)}", UriKind.Absolute); + if (!IsAllowedEndpoint(requestUri)) throw new InvalidDataException("amap_endpoint_rejected"); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); + using var document = await ReadBoundedJsonAsync(response.Content); var root = document.RootElement; return new ProbeResponse(root.GetProperty("status").GetString() ?? "", root.TryGetProperty("infocode", out var code) ? code.GetString() : null); } + internal static async Task ReadBoundedJsonAsync(HttpContent content) + { + if (content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large"); + await using var stream = await content.ReadAsStreamAsync(); + using var buffered = new MemoryStream(); + var buffer = ArrayPool.Shared.Rent(4_096); + try + { + var total = 0; + while (true) + { + var read = await stream.ReadAsync(buffer); + if (read == 0) break; + total += read; + if (total > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large"); + await buffered.WriteAsync(buffer.AsMemory(0, read)); + } + buffered.Position = 0; + return await JsonDocument.ParseAsync(buffered); + } + finally + { + Array.Clear(buffer); + ArrayPool.Shared.Return(buffer); + } + } + private static void Write(string code, bool success, int realCalls, string? reverseStatus, string? geocodeStatus) => Console.WriteLine(JsonSerializer.Serialize(new { code, success, real_calls = realCalls, reverse_status = reverseStatus, geocode_status = geocodeStatus })); From f7bed92e6142f08809838410e3858fa04f5c89be Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:31:25 +0800 Subject: [PATCH 7/7] test: cover Amap production security gates --- scripts/run-wp7-04-validation.mjs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/run-wp7-04-validation.mjs b/scripts/run-wp7-04-validation.mjs index c06d80d..4f2d93d 100644 --- a/scripts/run-wp7-04-validation.mjs +++ b/scripts/run-wp7-04-validation.mjs @@ -36,6 +36,7 @@ function readConsoleReview() { auto_scaling: "not_verified", paid_fallback: "not_verified", qps: "not_verified", + qps_limit_per_second: null, reviewed_at: null, security_restriction: "not_verified", service_binding: "not_verified", @@ -45,15 +46,18 @@ function readConsoleReview() { if (!raw) return fallback; try { const parsed = JSON.parse(raw); - const requiredKeys = ["allowlist", "auto_scaling", "paid_fallback", "qps", "reviewed_at", "security_restriction", "service_binding", "source"]; + const requiredKeys = ["allowlist", "auto_scaling", "paid_fallback", "qps", "qps_limit_per_second", "reviewed_at", "security_restriction", "service_binding", "source"]; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).sort().join("|") !== requiredKeys.sort().join("|")) { return { ...fallback, source: "invalid_input", valid: false }; } const statusFields = ["allowlist", "qps", "security_restriction", "service_binding"]; const statusValid = statusFields.every((field) => ["failed", "not_verified", "passed"].includes(parsed[field])); const disabledFieldsValid = ["auto_scaling", "paid_fallback"].every((field) => ["disabled", "not_verified"].includes(parsed[field])); + const qpsLimitValid = parsed.qps === "passed" + ? Number.isSafeInteger(parsed.qps_limit_per_second) && parsed.qps_limit_per_second >= 1 && parsed.qps_limit_per_second <= 1_000 + : parsed.qps_limit_per_second === null; const reviewedAtValid = typeof parsed.reviewed_at === "string" && Number.isFinite(Date.parse(parsed.reviewed_at)); - if (!statusValid || !disabledFieldsValid || !reviewedAtValid || parsed.source !== "amap_console_manual_review") { + if (!statusValid || !disabledFieldsValid || !qpsLimitValid || !reviewedAtValid || parsed.source !== "amap_console_manual_review") { return { ...fallback, source: "invalid_input", valid: false }; } return { ...parsed, valid: true }; @@ -90,17 +94,23 @@ const serviceRegression = build.exit_code === 0 const localHardStop = build.exit_code === 0 ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-release-gate.test.ts"]) : skipped("pnpm exec vitest run tests/api/wp7-04-amap-release-gate.test.ts"); +const productionAdapter = build.exit_code === 0 + ? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-production-adapter.test.ts"]) + : skipped("pnpm exec vitest run tests/api/wp7-04-amap-production-adapter.test.ts"); const consentFlow = build.exit_code === 0 ? run("pnpm", ["exec", "vitest", "run", "tests/unit/wp4-04-palette-dynamic.test.ts"]) : skipped("pnpm exec vitest run tests/unit/wp4-04-palette-dynamic.test.ts"); const supervisorBuild = run("dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configuration", "Release"]); +const supervisorSecurity = supervisorBuild.exit_code === 0 + ? run("dotnet", ["run", "--project", "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", "--configuration", "Release"]) + : skipped("dotnet run --project supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj --configuration Release"); const supervisorExe = resolve("supervisor", "Dada.Supervisor", "bin", "Release", "net8.0-windows", "Dada.Supervisor.exe"); const external = supervisorBuild.exit_code === 0 && existsSync(supervisorExe) ? run(supervisorExe, ["secrets", "probe", "api-amap"], "Dada.Supervisor.exe secrets probe api-amap") : skipped("Dada.Supervisor.exe secrets probe api-amap"); const trace = run("pnpm", ["validate:tdd-trace"]); const security = run("pnpm", ["test:security"]); -const commands = [build, locationRegression, serviceRegression, localHardStop, consentFlow, supervisorBuild, external, trace, security] +const commands = [build, locationRegression, serviceRegression, localHardStop, productionAdapter, consentFlow, supervisorBuild, supervisorSecurity, external, trace, security] .map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at })); const parsedExternal = (() => { try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return {}; } @@ -111,9 +121,11 @@ const realCalls = Number.isSafeInteger(parsedExternal.real_calls) && parsedExter : 0; const probePassed = external.exit_code === 0 && safeProbeCode === "amap_probe_passed" && realCalls === 2; const hardStopPassed = localHardStop.exit_code === 0; +const productionAdapterPassed = productionAdapter.exit_code === 0; const consentPassed = consentFlow.exit_code === 0; +const supervisorSecurityPassed = supervisorSecurity.exit_code === 0; const regressionPassed = locationRegression.exit_code === 0 && serviceRegression.exit_code === 0; -const automatedPassed = build.exit_code === 0 && hardStopPassed && consentPassed && regressionPassed && trace.exit_code === 0 && security.exit_code === 0; +const automatedPassed = build.exit_code === 0 && hardStopPassed && productionAdapterPassed && consentPassed && supervisorSecurityPassed && regressionPassed && trace.exit_code === 0 && security.exit_code === 0; const manualReviewPassed = consoleReview.valid && consoleReview.service_binding === "passed" && consoleReview.qps === "passed" @@ -131,7 +143,9 @@ if (consoleReview.security_restriction !== "passed") requiredBeforeRelease.push( if (consoleReview.paid_fallback !== "disabled") requiredBeforeRelease.push("paid_fallback_disabled"); if (consoleReview.auto_scaling !== "disabled") requiredBeforeRelease.push("auto_scaling_disabled"); if (!hardStopPassed) requiredBeforeRelease.push("monthly_1000_hard_stop"); +if (!productionAdapterPassed) requiredBeforeRelease.push("production_amap_adapter"); if (!consentPassed) requiredBeforeRelease.push("dyn004_confirmation"); +if (!supervisorSecurityPassed) requiredBeforeRelease.push("controlled_probe_security"); const blocker = !automatedPassed ? "automated_regression_failed" @@ -153,11 +167,15 @@ const contractEvidence = { checks: { allowlist: consoleReview.allowlist === "passed" ? "manual_console_passed" : consoleReview.allowlist, auto_scaling: consoleReview.auto_scaling, + client_security_controls: productionAdapterPassed && supervisorSecurityPassed ? "automated_passed" : "failed", + controlled_probe_security: supervisorSecurityPassed ? "automated_passed" : "failed", dyn004_hard_stop: consentPassed ? "confirmation_contract_passed" : "failed", location_and_reverse_geocode: probePassed ? "real_probe_passed" : "not_verified", monthly_hard_limit_1000: hardStopPassed ? "local_pre_egress_passed" : "failed", paid_fallback: consoleReview.paid_fallback, + production_adapter: productionAdapterPassed ? "credential_channel_real_adapter_passed" : "failed", provider_qps: consoleReview.qps === "passed" ? "manual_console_passed" : consoleReview.qps, + provider_qps_limit_per_second: consoleReview.qps_limit_per_second, security_binding: consoleReview.security_restriction === "passed" ? "manual_console_passed" : consoleReview.security_restriction, service_binding: consoleReview.service_binding === "passed" ? "manual_console_passed" : consoleReview.service_binding, }, @@ -183,6 +201,7 @@ const manualReviewEvidence = { auto_scaling: consoleReview.auto_scaling, paid_fallback: consoleReview.paid_fallback, qps: consoleReview.qps, + qps_limit_per_second: consoleReview.qps_limit_per_second, security_restriction: consoleReview.security_restriction, service_binding: consoleReview.service_binding, }, @@ -221,7 +240,7 @@ const redactionEvidence = { forbidden_fields_present: !evidenceRedactionPassed, source_security_scan: security.exit_code === 0 ? "passed" : "failed", status: evidenceRedactionPassed && security.exit_code === 0 ? "passed" : "failed", - stored_fields: ["service", "status", "logical_limit", "manual_review_status"], + stored_fields: ["service", "status", "logical_limit", "manual_review_status", "qps_limit_per_second"], schema_version: "1.1", }; if (redactionEvidence.status !== "passed") {