chore(WP7-06): 合并高德发布门禁基线
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
# Conflicts: # package.json # supervisor/Dada.Supervisor/OfflineCommandRouter.cs
This commit is contained in:
@@ -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<unknown>;
|
||||
|
||||
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<unknown>((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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] = "";
|
||||
try {
|
||||
if (!configured) throw new Error("API credential client initialization failed.");
|
||||
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
|
||||
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<void>) {
|
||||
|
||||
@@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = {
|
||||
|
||||
export type ReverseGeocodeResponse = {
|
||||
"formatted_value": string;
|
||||
"service_mode": "mock";
|
||||
"service_mode": "mock" | "real";
|
||||
"status": "resolved";
|
||||
};
|
||||
|
||||
|
||||
@@ -5621,11 +5621,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"service_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"mock"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"real"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"resolved"
|
||||
|
||||
+2
-1
@@ -111,7 +111,8 @@
|
||||
"test:wp7-02:controlled": "node scripts/run-wp7-02-validation.mjs --controlled-real",
|
||||
"review:wp7-02": "node scripts/record-wp7-02-manual-review.mjs",
|
||||
"test:wp7-03": "node scripts/run-wp7-03-validation.mjs --phase green",
|
||||
"test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red"
|
||||
"test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red",
|
||||
"test:wp7-04": "node scripts/run-wp7-04-validation.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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, 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: evidenceCommand,
|
||||
exit_code: result.status ?? 1,
|
||||
finished_at: new Date().toISOString(),
|
||||
stderr: result.stderr ?? "",
|
||||
stdout: result.stdout ?? "",
|
||||
started_at: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
qps_limit_per_second: null,
|
||||
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", "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 || !qpsLimitValid || !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 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 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, 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 {}; }
|
||||
})();
|
||||
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 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 && productionAdapterPassed && consentPassed && supervisorSecurityPassed && 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 (!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"
|
||||
: !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,
|
||||
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,
|
||||
},
|
||||
mode: probePassed ? "controlled_real" : "blocked",
|
||||
real_calls: realCalls,
|
||||
status,
|
||||
schema_version: "1.1",
|
||||
};
|
||||
const externalCallsEvidence = {
|
||||
blocker,
|
||||
mode: probePassed ? "controlled_real" : "blocked",
|
||||
real_calls: realCalls,
|
||||
request_scope: ["geocoding", "reverse_geocoding"],
|
||||
service: "amap",
|
||||
source_command_status: safeProbeCode,
|
||||
status,
|
||||
schema_version: "1.1",
|
||||
};
|
||||
const manualReviewEvidence = {
|
||||
blocker,
|
||||
checks: {
|
||||
allowlist: consoleReview.allowlist,
|
||||
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,
|
||||
},
|
||||
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: 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: 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.1",
|
||||
status,
|
||||
task_id: "TASK-WP7-04",
|
||||
test_id: "TDD-WP7-EXT-003-real-amap",
|
||||
work_package: "WP-7",
|
||||
};
|
||||
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", "qps_limit_per_second"],
|
||||
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);
|
||||
@@ -38,6 +38,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);
|
||||
@@ -52,6 +53,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<InvalidDataException>(
|
||||
() => 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");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Dada.Supervisor;
|
||||
|
||||
internal static class AmapProbe
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
Write("amap_credentials_missing", false, 0, null, null);
|
||||
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, realCalls, reverse.Status, geocode.Status);
|
||||
return success ? 0 : 3;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Write("amap_probe_timeout", false, realCalls, null, null);
|
||||
return 3;
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, realCalls, null, null);
|
||||
return 3;
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||
{
|
||||
Write("amap_probe_invalid_response", false, realCalls, null, null);
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ProbeResponse> Call(string endpoint, string 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();
|
||||
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<JsonDocument> 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<byte>.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<byte>.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 }));
|
||||
|
||||
private sealed record ProbeResponse(string Status, string? InfoCode);
|
||||
}
|
||||
@@ -84,6 +84,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();
|
||||
}
|
||||
@@ -199,7 +201,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; validate-external", false);
|
||||
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear|probe; admin-allowlist add|remove|status; doctor; validate-external", false);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user