fix: wire contract provider timeout budgets
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 3m46s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 19m11s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 8m8s
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 3m46s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 19m11s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 8m8s
This commit is contained in:
@@ -72,6 +72,7 @@ yuqei-contract-platform/services/contract-api/migrations/versions/20260624_0007_
|
||||
1. `contract-review-worker` 的关键运行变量已从 compose 硬编码调整为 `.env` 可覆盖:
|
||||
- `CONTRACT_API_DATABASE_URL`
|
||||
- `CONTRACT_API_AI_PLATFORM_BASE_URL`
|
||||
- `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` (must exceed the AI Provider request timeout; staging uses 240 seconds for a 180-second Provider budget)
|
||||
- `CONTRACT_API_REVIEW_WORKER_NAME`
|
||||
- `CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS`
|
||||
- `CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# AI 合同平台 V2 最终生产就绪审计
|
||||
|
||||
日期:2026-08-11
|
||||
|
||||
## 结论
|
||||
|
||||
当前候选版本中可由代码和自动化完成的功能、回归、安全扫描与共享预发布技术验证已通过,最终差异审查未留下已知代码级 P0/P1。本轮修复了 Contract 调用 AI Platform 时固定使用 SDK 30 秒超时的问题,并在真实动态 Provider 上完成了合同审查闭环;同时关闭了生产 Web API 前缀丢失问题。
|
||||
|
||||
正式生产仍为 **No-Go**。未启动 production compose,未修改 DNS,未切换生产流量。阻断原因是具名法务、生产基础设施、双节点现场演练和 support access 外部安全集成尚无真实验收证据;这些证据不得由 AI、mock、模板或共享预发布结果代替。
|
||||
|
||||
## 本轮技术闭环
|
||||
|
||||
1. Contract API 新增 `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS`,范围为 `1..600` 秒,默认 30 秒;review worker、手工 run-once 和 Legal QA 代理均显式传递到 SDK。
|
||||
2. 共享预发布 Provider 请求预算为 180 秒,Contract 调用预算为 240 秒,避免同期限竞态。
|
||||
3. production preflight 强制 Contract timeout 至少比 Provider timeout 多 5 秒,并拒绝缺失、非数字、非有限值、越界和倒置预算。
|
||||
4. Nginx 为同步 `/contract/api/v1/legal/qa` 设置独立 660 秒上限,覆盖 Contract 允许的 600 秒最大值;其他 Contract 路由仍保持 120 秒。
|
||||
5. Compose 的 migrate、Contract API 和 review worker 三个服务均映射 timeout;共享预发布部署脚本显式写入 Contract 240 秒和 Provider 180 秒,避免重部署恢复默认 30 秒。
|
||||
6. 生产 Web 使用 `/contract`、`/ai` 边缘前缀时,Contract 与 AI 请求均保留 base path。统一出口还会规范化 33 个旧 AI 代理路由生成的无前缀 URL;角色判断和 Contract 公共健康检查使用移除边缘前缀后的逻辑 API 路径,避免修复路由时引入权限回归。
|
||||
7. 身份执行器、support access API 和破坏性身份写接口继续关闭。共享预发布 OpenAPI 中包含 `execute`、`support-access` 或 `support_access` 的路由数为 0。
|
||||
|
||||
## 自动化验证
|
||||
|
||||
| 范围 | 结果 |
|
||||
| --- | --- |
|
||||
| Contract API 全量 | `286 passed, 3 skipped`;跳过项仅为需显式 PostgreSQL URL 的并发专项 |
|
||||
| AI Platform API、AI worker、Python SDK | `162 passed` |
|
||||
| Infra production release tools | `62 passed` |
|
||||
| 本轮 timeout 定向回归 | `28 passed` |
|
||||
| Contract Web 单元测试 | `17 passed`,其中既有入口/会话/工作空间 12 项,生产 API base path、幂等规范化和逻辑权限路径 5 项 |
|
||||
| Contract Web typecheck | 通过 |
|
||||
| Next.js production build | 通过,生成 101 个页面/路由 |
|
||||
| npm audit | 0 vulnerabilities |
|
||||
| pip-audit | 0 known vulnerabilities |
|
||||
| Bandit | 0 findings,0 errors |
|
||||
| tracked source secret scan | 45 个已复核假阳性,0 个新发现 |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
另有 3 个真实 PostgreSQL 并发专项已在本轮之前使用 `192.168.3.90:5432/yuqei_contract` 的随机隔离 schema 完成 `3/3`,并已清理测试 schema。
|
||||
|
||||
## 共享预发布证据
|
||||
|
||||
- 宿主机:`192.168.3.90`
|
||||
- AI Provider:`ccswitch / gpt-5.6-sol`
|
||||
- Provider 调用:enabled
|
||||
- Provider fallback:false
|
||||
- Provider timeout:180 秒
|
||||
- Contract timeout:240 秒;API 与 review worker 容器均实际读取为 `240.0`
|
||||
- Contract API 镜像:`sha256:5072971b7bdda62036791cc5836faece78b5a8615a98fd98ad5e9b4999e3f51a`
|
||||
- Contract review worker 镜像:`sha256:35a76a9f6dd5dd44f6545d029cd0e621ea909251c7aabccd6e4cd860a37f11be`
|
||||
- 真实审查:合同 `contract-096914efc203`、任务 `review-e8100aa210d4`,约 93 秒后状态为 `succeeded`,并持久化 AI audit run id
|
||||
- post-deploy release gate:`pass`;Contract 与 AI schema-check 均为 `ok`
|
||||
- 8 个共享预发布服务全部 healthy
|
||||
- 根盘:92% 使用,约 17 GiB 可用;未自动清理镜像、卷或业务数据
|
||||
|
||||
远端变更前备份位于 `/opt/apps/ai-contract-platform-v2-smoke/.runtime/pre-contract-timeout-20260811-1650/`。本地 release gate 证据位于忽略目录 `.runtime/postdeploy-release-gate-timeout-fix-20260811/`,不包含服务密钥。
|
||||
|
||||
## 正式生产阻断项
|
||||
|
||||
1. 具名法务尚未对 10 张知识卡和 18 条真实 Provider 结果签署精确技术包,因此没有真实 `acceptance_run_id`、receipt v2 或 promotion bundle。
|
||||
2. 四份公开政策及合同生成、条款推荐黄金资产仍缺具名法务正式版本、生效时间和审批记录。
|
||||
3. support access 尚未接入真实 IdP/MFA、会话绑定、跨节点撤销、真实资源读取、告警消费和具名隐私/法务批准;当前必须保持 deny-only。
|
||||
4. 尚无正式 `/etc/yuqei/production.env`、production secrets、不可变镜像清单和非模板 production preflight 证据。
|
||||
5. 正式 TLS 证书、DNS、异地备份挂载、恢复演练和阿里云前端切流证据不存在。
|
||||
6. 尚未部署至少两个独立生产节点,无法执行真实 canary、撤销 SLA、kill switch 和完整回滚演练。
|
||||
7. 生产容量与回滚空间仍需运维确认;共享服务器根盘处于 92% 高水位。
|
||||
|
||||
## 固定上线顺序
|
||||
|
||||
1. 具名法务签署精确技术包和共享 evidence。
|
||||
2. 在共享预发布执行 `finalize`,生成并验证真实 `acceptance_run_id`、receipt v2 和 promotion bundle。
|
||||
3. 建立 production env/secrets、TLS、异地备份和不可变镜像,运行非模板 preflight、备份恢复及容量门禁。
|
||||
4. 部署至少两个生产节点,不切流。
|
||||
5. 执行只读 canary、跨节点撤销 SLA、kill switch 和回滚演练。
|
||||
6. 由法务、安全、隐私和运维具名批准后,另行授权 DNS/流量切换。
|
||||
|
||||
上述阻断项全部关闭前,禁止启动 production compose,也禁止把共享预发布重命名或宣称为生产。
|
||||
@@ -9,6 +9,7 @@
|
||||
"test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs",
|
||||
"test:identity-session": "node --no-warnings --experimental-strip-types --test src/lib/identity-session-mapping.test.mjs",
|
||||
"test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs",
|
||||
"test:api-base-url": "node --no-warnings --experimental-strip-types --test src/lib/api-base-url.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getContractWebSession } from "@/lib/contract-session";
|
||||
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url";
|
||||
|
||||
type AiPlatformAdminRole = "admin" | "legal";
|
||||
|
||||
@@ -8,7 +9,7 @@ const aiPlatformBaseUrl =
|
||||
"http://localhost:8101";
|
||||
|
||||
export function buildAiPlatformApiUrl(pathname: string) {
|
||||
return new URL(pathname, aiPlatformBaseUrl);
|
||||
return buildApiUrlWithBasePath(pathname, aiPlatformBaseUrl);
|
||||
}
|
||||
|
||||
export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
|
||||
@@ -17,7 +18,8 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
|
||||
return Response.json({ message: "请先登录。" }, { status: 401 });
|
||||
}
|
||||
|
||||
const allowedRoles = aiPlatformRolesForPath(targetUrl.pathname);
|
||||
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, aiPlatformBaseUrl);
|
||||
const allowedRoles = aiPlatformRolesForPath(apiPathname);
|
||||
if (!allowedRoles.includes(session.role.toLowerCase() as AiPlatformAdminRole)) {
|
||||
return Response.json({ message: "当前账号无权访问该 AI 管理接口。" }, { status: 403 });
|
||||
}
|
||||
@@ -31,7 +33,11 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) {
|
||||
if (serviceSecret) {
|
||||
headers.set("X-AI-Platform-Service-Key", serviceSecret);
|
||||
}
|
||||
return fetch(targetUrl, { cache: "no-store", ...init, headers });
|
||||
const normalizedTargetUrl = buildApiUrlWithBasePath(
|
||||
`${apiPathname}${targetUrl.search}${targetUrl.hash}`,
|
||||
aiPlatformBaseUrl
|
||||
);
|
||||
return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers });
|
||||
}
|
||||
|
||||
export function aiPlatformRolesForPath(pathname: string): readonly AiPlatformAdminRole[] {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "./api-base-url.ts";
|
||||
|
||||
test("preserves a Contract edge prefix and query parameters", () => {
|
||||
const url = buildApiUrlWithBasePath(
|
||||
"/api/v1/legal/qa?trace_id=smoke",
|
||||
"https://api.example.invalid/contract",
|
||||
);
|
||||
|
||||
assert.equal(url.toString(), "https://api.example.invalid/contract/api/v1/legal/qa?trace_id=smoke");
|
||||
});
|
||||
|
||||
test("preserves an AI edge prefix", () => {
|
||||
const url = buildApiUrlWithBasePath(
|
||||
"/api/v1/runtime/config-check",
|
||||
"https://api.example.invalid/ai/",
|
||||
);
|
||||
|
||||
assert.equal(url.toString(), "https://api.example.invalid/ai/api/v1/runtime/config-check");
|
||||
});
|
||||
|
||||
test("keeps direct service URLs unchanged", () => {
|
||||
const url = buildApiUrlWithBasePath("/api/v1/health", "http://contract-api:8100");
|
||||
|
||||
assert.equal(url.toString(), "http://contract-api:8100/api/v1/health");
|
||||
});
|
||||
|
||||
test("normalization is idempotent for an already-prefixed URL", () => {
|
||||
const url = buildApiUrlWithBasePath(
|
||||
"/ai/api/v1/audit/ai-runs?status=succeeded",
|
||||
"https://api.example.invalid/ai",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
url.toString(),
|
||||
"https://api.example.invalid/ai/api/v1/audit/ai-runs?status=succeeded",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the logical API path for authorization checks", () => {
|
||||
assert.equal(
|
||||
apiPathnameWithoutBasePath("/ai/api/v1/quality/golden-samples", "https://api.example.invalid/ai"),
|
||||
"/api/v1/quality/golden-samples",
|
||||
);
|
||||
assert.equal(
|
||||
apiPathnameWithoutBasePath("/contract/api/v1/health", "https://api.example.invalid/contract"),
|
||||
"/api/v1/health",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export function buildApiUrlWithBasePath(pathname: string, baseUrl: string): URL {
|
||||
const base = new URL(baseUrl);
|
||||
const requested = new URL(pathname, "http://yuqei-internal.invalid");
|
||||
const basePath = base.pathname.replace(/\/+$/, "");
|
||||
const target = new URL(base.origin);
|
||||
const apiPathname = apiPathnameWithoutBasePath(requested.pathname, baseUrl);
|
||||
|
||||
target.pathname = `${basePath}${apiPathname}` || "/";
|
||||
target.search = requested.search;
|
||||
target.hash = requested.hash;
|
||||
return target;
|
||||
}
|
||||
|
||||
export function apiPathnameWithoutBasePath(pathname: string, baseUrl: string): string {
|
||||
const basePath = new URL(baseUrl).pathname.replace(/\/+$/, "");
|
||||
if (!basePath || pathname === basePath) {
|
||||
return pathname === basePath ? "/" : pathname;
|
||||
}
|
||||
if (pathname.startsWith(`${basePath}/`)) {
|
||||
return pathname.slice(basePath.length);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getContractWebSession } from "@/lib/contract-session";
|
||||
import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url";
|
||||
|
||||
const contractApiBaseUrl =
|
||||
process.env.CONTRACT_API_BASE_URL ??
|
||||
@@ -6,7 +7,7 @@ const contractApiBaseUrl =
|
||||
"http://localhost:8100";
|
||||
|
||||
export function buildContractApiUrl(pathname: string) {
|
||||
return new URL(pathname, contractApiBaseUrl);
|
||||
return buildApiUrlWithBasePath(pathname, contractApiBaseUrl);
|
||||
}
|
||||
|
||||
export async function fetchContractApi(targetUrl: URL, init?: RequestInit) {
|
||||
@@ -26,7 +27,12 @@ export async function fetchContractApi(targetUrl: URL, init?: RequestInit) {
|
||||
headers.set("X-Contract-Proxy-Key", proxySecret);
|
||||
}
|
||||
|
||||
return fetch(targetUrl, { cache: "no-store", ...init, headers });
|
||||
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl);
|
||||
const normalizedTargetUrl = buildApiUrlWithBasePath(
|
||||
`${apiPathname}${targetUrl.search}${targetUrl.hash}`,
|
||||
contractApiBaseUrl
|
||||
);
|
||||
return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers });
|
||||
}
|
||||
|
||||
export function buildIdentityProxyHeaders(request: Request, initial?: HeadersInit) {
|
||||
@@ -47,7 +53,8 @@ export function buildIdentityProxyHeaders(request: Request, initial?: HeadersIni
|
||||
export async function proxyContractApiResponse(targetUrl: URL, init?: RequestInit) {
|
||||
try {
|
||||
const session = await getContractWebSession();
|
||||
if (!session && !isPublicContractApiPath(targetUrl.pathname)) {
|
||||
const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl);
|
||||
if (!session && !isPublicContractApiPath(apiPathname)) {
|
||||
return Response.json({ message: "请先登录。" }, { status: 401 });
|
||||
}
|
||||
const response = await fetchContractApi(targetUrl, init);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchContractApi } from "@/lib/contract-api";
|
||||
import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api";
|
||||
import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api";
|
||||
import { getContractWebSession } from "@/lib/contract-session";
|
||||
import type { UserRole } from "@/lib/access-control";
|
||||
@@ -1263,11 +1263,6 @@ export type UnifiedSearchQuery = {
|
||||
page_size?: number;
|
||||
};
|
||||
|
||||
const contractApiBaseUrl =
|
||||
process.env.CONTRACT_API_BASE_URL ??
|
||||
process.env.NEXT_PUBLIC_CONTRACT_API_BASE_URL ??
|
||||
"http://localhost:8100";
|
||||
|
||||
const serverFetchCache = new Map<string, { expiresAt: number; payload: unknown }>();
|
||||
|
||||
export async function getContracts(query: ContractQuery = {}): Promise<ContractListItem[]> {
|
||||
@@ -2026,7 +2021,7 @@ async function fetchAiJson<T>(
|
||||
|
||||
function resolveUrl(apiPathname: string, proxyPathname: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return new URL(apiPathname, contractApiBaseUrl).toString();
|
||||
return buildContractApiUrl(apiPathname).toString();
|
||||
}
|
||||
return proxyPathname;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ CONTRACT_API_SEED_DEMO_DATA=true
|
||||
CONTRACT_API_FILE_STORAGE_DIR=.runtime/contract-files
|
||||
CONTRACT_API_REDIS_URL=redis://localhost:6379/0
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://localhost:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30
|
||||
CONTRACT_API_UVICORN_WORKERS=1
|
||||
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
|
||||
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
|
||||
|
||||
@@ -65,6 +65,7 @@ CONTRACT_API_DATABASE_AUTO_CREATE=false
|
||||
CONTRACT_API_SEED_DEMO_DATA=false
|
||||
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
|
||||
CONTRACT_API_UVICORN_WORKERS=1
|
||||
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
|
||||
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
|
||||
@@ -76,6 +77,8 @@ CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true
|
||||
CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS
|
||||
```
|
||||
|
||||
`CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` covers the complete Contract-to-AI request and must exceed `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS` by at least five seconds. For example, use `240` when staging a Provider timeout of `180` seconds.
|
||||
|
||||
The disabled mode keeps login and administrator-managed one-time invitation links available while public company registration, password reset, and email verification remain off. When enterprise email becomes available, switch to `smtp`, set manual invitations to `false`, and configure the SMTP variables from `.env.example`. The production web proxy must overwrite public `X-Real-IP` / `X-Forwarded-For` headers before Contract Web forwards the validated client IP to Contract API for rate limiting.
|
||||
|
||||
After migrations, initialize the first production administrator from a protected password file. The command is idempotent and will not replace an existing credential unless `--force-rotate` is supplied:
|
||||
|
||||
+5
-2
@@ -302,8 +302,11 @@ def register_contract_core_routes(
|
||||
)
|
||||
def proxy_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
|
||||
authorization.require("start_review")
|
||||
client_options = {"service_key": settings.ai_platform_service_key} if settings.ai_platform_service_key else {}
|
||||
with AiPlatformClient(settings.ai_platform_base_url, **client_options) as client:
|
||||
with AiPlatformClient(
|
||||
settings.ai_platform_base_url,
|
||||
timeout=settings.ai_platform_timeout_seconds,
|
||||
service_key=settings.ai_platform_service_key or None,
|
||||
) as client:
|
||||
return client.run_legal_qa(
|
||||
request,
|
||||
context=RequestContext(tenant_id=get_request_tenant_id() or "default", user_id="contract-api"),
|
||||
|
||||
@@ -218,6 +218,7 @@ def register_review_routes(
|
||||
repository=repository,
|
||||
ai_platform_base_url=settings.ai_platform_base_url,
|
||||
ai_platform_service_key=settings.ai_platform_service_key,
|
||||
ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds,
|
||||
options=ReviewWorkerOptions(
|
||||
worker_name=payload.worker_name or settings.review_worker_name,
|
||||
limit=payload.limit,
|
||||
|
||||
@@ -22,6 +22,7 @@ class ContractApiSettings(BaseSettings):
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
ai_platform_base_url: str = "http://localhost:8101"
|
||||
ai_platform_service_key: str = ""
|
||||
ai_platform_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
|
||||
web_proxy_shared_secret: str = ""
|
||||
require_web_proxy_auth: bool = False
|
||||
review_worker_name: str = "contract-review-worker-1"
|
||||
@@ -163,7 +164,11 @@ def _check_ai_platform_config(settings: ContractApiSettings) -> RuntimeConfigChe
|
||||
name="ai_platform_base_url",
|
||||
status="ok",
|
||||
message="AI platform base URL is configured.",
|
||||
details={"scheme": parsed.scheme, "host": parsed.netloc},
|
||||
details={
|
||||
"scheme": parsed.scheme,
|
||||
"host": parsed.netloc,
|
||||
"timeout_seconds": settings.ai_platform_timeout_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+10
-2
@@ -27,6 +27,7 @@ def run_review_worker_once(
|
||||
repository: ContractRepository,
|
||||
ai_platform_base_url: str,
|
||||
ai_platform_service_key: str | None = None,
|
||||
ai_platform_timeout_seconds: float = 30.0,
|
||||
options: ReviewWorkerOptions,
|
||||
) -> ReviewWorkerRun:
|
||||
max_tasks = max(options.limit, 1)
|
||||
@@ -46,6 +47,7 @@ def run_review_worker_once(
|
||||
task=task,
|
||||
ai_platform_base_url=ai_platform_base_url,
|
||||
ai_platform_service_key=ai_platform_service_key,
|
||||
ai_platform_timeout_seconds=ai_platform_timeout_seconds,
|
||||
options=options,
|
||||
)
|
||||
if result.status == "succeeded":
|
||||
@@ -67,6 +69,7 @@ def execute_review_task(
|
||||
task: ReviewTask,
|
||||
ai_platform_base_url: str,
|
||||
ai_platform_service_key: str | None = None,
|
||||
ai_platform_timeout_seconds: float = 30.0,
|
||||
options: ReviewWorkerOptions,
|
||||
) -> ReviewTask:
|
||||
contract = repository.get_contract(task.contract_id)
|
||||
@@ -88,8 +91,11 @@ def execute_review_task(
|
||||
return failed or task
|
||||
|
||||
try:
|
||||
client_options = {"service_key": ai_platform_service_key} if ai_platform_service_key else {}
|
||||
with AiPlatformClient(ai_platform_base_url, **client_options) as client:
|
||||
with AiPlatformClient(
|
||||
ai_platform_base_url,
|
||||
timeout=ai_platform_timeout_seconds,
|
||||
service_key=ai_platform_service_key or None,
|
||||
) as client:
|
||||
review = client.run_contract_review(
|
||||
ContractReviewRequest(
|
||||
contract_title=contract.title,
|
||||
@@ -220,6 +226,7 @@ def main() -> None:
|
||||
repository=repository,
|
||||
ai_platform_base_url=settings.ai_platform_base_url,
|
||||
ai_platform_service_key=settings.ai_platform_service_key,
|
||||
ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds,
|
||||
options=ReviewWorkerOptions(
|
||||
worker_name=settings.review_worker_name,
|
||||
limit=settings.review_worker_batch_limit,
|
||||
@@ -235,6 +242,7 @@ def run_once(settings: ContractApiSettings | None = None, *, limit: int | None =
|
||||
repository=repository,
|
||||
ai_platform_base_url=resolved_settings.ai_platform_base_url,
|
||||
ai_platform_service_key=resolved_settings.ai_platform_service_key,
|
||||
ai_platform_timeout_seconds=resolved_settings.ai_platform_timeout_seconds,
|
||||
options=ReviewWorkerOptions(
|
||||
worker_name=resolved_settings.review_worker_name,
|
||||
limit=limit or resolved_settings.review_worker_batch_limit,
|
||||
|
||||
@@ -2275,8 +2275,16 @@ def test_postgresql_unified_search_keeps_full_text_as_bounded_fallback(monkeypat
|
||||
|
||||
def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, monkeypatch) -> None:
|
||||
class FakeAiPlatformClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: float,
|
||||
service_key: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url
|
||||
assert timeout == 180.0
|
||||
assert service_key is None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -2317,7 +2325,8 @@ def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, mon
|
||||
)
|
||||
|
||||
monkeypatch.setattr(contract_review_worker, "AiPlatformClient", FakeAiPlatformClient)
|
||||
app = create_app(make_settings(tmp_path))
|
||||
settings = make_settings(tmp_path).model_copy(update={"ai_platform_timeout_seconds": 180.0})
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
create_response = client.post(
|
||||
@@ -2387,7 +2396,12 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch
|
||||
}
|
||||
|
||||
monkeypatch.setattr("yuqei_contract_api.api.review.run_review_worker_once", fake_run_review_worker_once)
|
||||
settings = make_settings(tmp_path).model_copy(update={"ai_platform_service_key": "test-service-key"})
|
||||
settings = make_settings(tmp_path).model_copy(
|
||||
update={
|
||||
"ai_platform_service_key": "test-service-key",
|
||||
"ai_platform_timeout_seconds": 180.0,
|
||||
}
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -2395,6 +2409,7 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["ai_platform_service_key"] == "test-service-key"
|
||||
assert captured["ai_platform_timeout_seconds"] == 180.0
|
||||
|
||||
|
||||
def test_review_clause_repair_can_append_recommended_clause_to_contract_body(tmp_path: Path) -> None:
|
||||
@@ -3454,7 +3469,7 @@ def test_legal_change_impact_scan_uses_published_clause_snapshot(tmp_path: Path)
|
||||
|
||||
def test_review_worker_uses_uploaded_source_file_text_and_persists_ai_sources(tmp_path: Path, monkeypatch) -> None:
|
||||
class FakeAiPlatformClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
def __init__(self, base_url: str, **_kwargs) -> None:
|
||||
self.base_url = base_url
|
||||
|
||||
def __enter__(self):
|
||||
@@ -3532,7 +3547,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeAiPlatformClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
def __init__(self, base_url: str, **_kwargs) -> None:
|
||||
self.base_url = base_url
|
||||
|
||||
def __enter__(self):
|
||||
@@ -3584,7 +3599,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un
|
||||
|
||||
def test_failed_review_task_can_be_requeued(tmp_path: Path, monkeypatch) -> None:
|
||||
class FailingAiPlatformClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
def __init__(self, base_url: str, **_kwargs) -> None:
|
||||
self.base_url = base_url
|
||||
|
||||
def __enter__(self):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import text
|
||||
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
@@ -90,6 +92,12 @@ def test_runtime_config_check_flags_production_localhost_ai_platform() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", [0, 0.5, 600.1, 601])
|
||||
def test_ai_platform_timeout_must_stay_within_supported_range(timeout: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ContractApiSettings(ai_platform_timeout_seconds=timeout)
|
||||
|
||||
|
||||
def test_runtime_config_check_flags_empty_review_worker_name() -> None:
|
||||
app = create_app(
|
||||
ContractApiSettings(
|
||||
|
||||
@@ -17,6 +17,9 @@ def make_test_settings() -> ContractApiSettings:
|
||||
def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
|
||||
from yuqei_sdk import ai_platform
|
||||
|
||||
captured_options: dict[str, object] = {}
|
||||
fake_service_key = "legal-qa-service-key" # pragma: allowlist secret
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/legal/qa"
|
||||
assert request.headers["x-tenant-id"] == "tenant-qa"
|
||||
@@ -45,6 +48,7 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
|
||||
original_init = ai_platform.AiPlatformClient.__init__
|
||||
|
||||
def init_with_mock_transport(self, base_url: str, **kwargs) -> None:
|
||||
captured_options.update(kwargs)
|
||||
original_init(
|
||||
self,
|
||||
base_url,
|
||||
@@ -54,7 +58,13 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
|
||||
|
||||
monkeypatch.setattr(ai_platform.AiPlatformClient, "__init__", init_with_mock_transport)
|
||||
|
||||
app = create_app(make_test_settings())
|
||||
settings = make_test_settings().model_copy(
|
||||
update={
|
||||
"ai_platform_service_key": fake_service_key,
|
||||
"ai_platform_timeout_seconds": 45.0,
|
||||
}
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/legal/qa",
|
||||
@@ -65,3 +75,5 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None:
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["citations"][0]["reference"] == "\u7b2c\u4e94\u767e\u96f6\u4e5d\u6761"
|
||||
assert captured_options["timeout"] == 45.0
|
||||
assert captured_options["service_key"] == fake_service_key
|
||||
|
||||
@@ -251,6 +251,7 @@ CONTRACT_API_DATABASE_URL=postgresql+psycopg://.../yuqei_contract
|
||||
CONTRACT_API_DATABASE_AUTO_CREATE=false
|
||||
CONTRACT_API_SEED_DEMO_DATA=false
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
|
||||
CONTRACT_API_UVICORN_WORKERS=1
|
||||
CONTRACT_API_READ_CACHE_TTL_SECONDS=2
|
||||
CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1
|
||||
@@ -264,6 +265,8 @@ YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://.../yuqei_ai
|
||||
YUQEI_WORKER_DATABASE_AUTO_CREATE=false
|
||||
```
|
||||
|
||||
Keep `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` at least five seconds above `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS`; production preflight rejects missing, non-finite, out-of-range, or inverted timeout budgets.
|
||||
|
||||
Start from `yuqei-infra\compose\.env.production.example` and replace every `CHANGE_ME` value before deployment.
|
||||
|
||||
Run migrations explicitly before replacing application containers.
|
||||
|
||||
@@ -27,6 +27,7 @@ CONTRACT_API_SEED_DEMO_DATA=true
|
||||
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
|
||||
CONTRACT_API_REDIS_URL=redis://redis:6379/0
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30
|
||||
CONTRACT_API_WEB_PROXY_SHARED_SECRET=local-only-proxy-secret-change-me-32
|
||||
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=false
|
||||
CONTRACT_API_UVICORN_WORKERS=1
|
||||
|
||||
@@ -67,6 +67,7 @@ CONTRACT_API_SEED_DEMO_DATA=false
|
||||
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
|
||||
CONTRACT_API_REDIS_URL=redis://redis:6379/0
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60
|
||||
CONTRACT_API_WEB_PROXY_SHARED_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET
|
||||
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=true
|
||||
CONTRACT_API_UVICORN_WORKERS=1
|
||||
|
||||
@@ -86,6 +86,7 @@ services:
|
||||
CONTRACT_API_FILE_STORAGE_DIR: ${CONTRACT_API_FILE_STORAGE_DIR:-/data/contract-files}
|
||||
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
|
||||
CONTRACT_API_DB_POOL_SIZE: ${CONTRACT_API_DB_POOL_SIZE:-10}
|
||||
CONTRACT_API_DB_MAX_OVERFLOW: ${CONTRACT_API_DB_MAX_OVERFLOW:-20}
|
||||
CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30}
|
||||
@@ -113,6 +114,7 @@ services:
|
||||
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
|
||||
CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32}
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
|
||||
CONTRACT_API_WEB_PROXY_SHARED_SECRET: ${CONTRACT_API_WEB_PROXY_SHARED_SECRET:-local-only-proxy-secret-change-me-32}
|
||||
CONTRACT_API_REQUIRE_WEB_PROXY_AUTH: ${CONTRACT_API_REQUIRE_WEB_PROXY_AUTH:-false}
|
||||
CONTRACT_API_UVICORN_WORKERS: ${CONTRACT_API_UVICORN_WORKERS:-1}
|
||||
@@ -121,6 +123,12 @@ services:
|
||||
CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30}
|
||||
CONTRACT_API_DB_POOL_RECYCLE_SECONDS: ${CONTRACT_API_DB_POOL_RECYCLE_SECONDS:-1800}
|
||||
CONTRACT_API_READ_CACHE_TTL_SECONDS: ${CONTRACT_API_READ_CACHE_TTL_SECONDS:-2}
|
||||
CONTRACT_API_ANTI_ABUSE_MODE: ${CONTRACT_API_ANTI_ABUSE_MODE:-disabled}
|
||||
CONTRACT_API_ANTI_ABUSE_HASH_SECRET: ${CONTRACT_API_ANTI_ABUSE_HASH_SECRET:-}
|
||||
CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT:-1000}
|
||||
CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT:-1000}
|
||||
CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT:-50}
|
||||
CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT:-524288000}
|
||||
CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE: ${CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE:-development}
|
||||
CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED: ${CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED:-true}
|
||||
CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED: ${CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED:-false}
|
||||
@@ -184,6 +192,7 @@ services:
|
||||
CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0}
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101}
|
||||
CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32}
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30}
|
||||
CONTRACT_API_REVIEW_WORKER_NAME: ${CONTRACT_API_REVIEW_WORKER_NAME:-contract-review-worker-1}
|
||||
CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS: ${CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS:-5}
|
||||
CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT: ${CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT:-5}
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
@@ -78,7 +79,9 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
|
||||
"CONTRACT_POSTGRES_DB",
|
||||
"AI_PLATFORM_POSTGRES_DB",
|
||||
"CONTRACT_API_DATABASE_URL",
|
||||
"CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS",
|
||||
"AI_PLATFORM_DATABASE_URL",
|
||||
"AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS",
|
||||
"YUQEI_WORKER_DATABASE_URL",
|
||||
"MINIO_ROOT_USER",
|
||||
"MINIO_ROOT_PASSWORD",
|
||||
@@ -116,6 +119,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
|
||||
]
|
||||
checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}."))
|
||||
checks.append(_validate_identity_email_delivery(values))
|
||||
checks.append(_validate_ai_timeout_budget(values))
|
||||
|
||||
placeholders = [
|
||||
key
|
||||
@@ -421,6 +425,27 @@ def _parse_int(value: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_ai_timeout_budget(values: dict[str, str]) -> Check:
|
||||
contract_timeout = _parse_float(values.get("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS", ""))
|
||||
provider_timeout = _parse_float(values.get("AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS", ""))
|
||||
valid = (
|
||||
contract_timeout is not None
|
||||
and provider_timeout is not None
|
||||
and math.isfinite(contract_timeout)
|
||||
and math.isfinite(provider_timeout)
|
||||
and 1 <= contract_timeout <= 600
|
||||
and provider_timeout > 0
|
||||
and contract_timeout >= provider_timeout + 5
|
||||
)
|
||||
return _result(
|
||||
"ai_timeout_budget",
|
||||
valid,
|
||||
"Contract-to-AI timeout leaves at least five seconds beyond the Provider request budget."
|
||||
if valid
|
||||
else "Timeouts must be finite and positive, Contract must be within 1..600 seconds, and Contract must exceed Provider by at least five seconds.",
|
||||
)
|
||||
|
||||
|
||||
def _parse_float(value: str) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
|
||||
@@ -44,6 +44,20 @@ server {
|
||||
return 200 '{"status":"ok","service":"yuqei-v2-edge"}';
|
||||
}
|
||||
|
||||
location = /contract/api/v1/legal/qa {
|
||||
limit_req zone=yuqei_api_per_ip burst=20 nodelay;
|
||||
proxy_pass http://contract_api_upstream/api/v1/legal/qa;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
}
|
||||
|
||||
location /contract/ {
|
||||
limit_req zone=yuqei_api_per_ip burst=40 nodelay;
|
||||
proxy_pass http://contract_api_upstream/;
|
||||
|
||||
@@ -357,6 +357,8 @@ CONTRACT_API_SEED_DEMO_DATA=true
|
||||
CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files
|
||||
CONTRACT_API_REDIS_URL=redis://redis:6379/0
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101
|
||||
CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240
|
||||
AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180
|
||||
AI_PLATFORM_SERVICE_SHARED_SECRET=$aiPlatformServiceSecret
|
||||
AI_PLATFORM_REQUIRE_SERVICE_AUTH=true
|
||||
CONTRACT_API_WEB_PROXY_SHARED_SECRET=$webProxySharedSecret
|
||||
@@ -466,15 +468,15 @@ docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $
|
||||
set -euo pipefail
|
||||
cd $(ConvertTo-ShellSingleQuote $RemoteProjectPath)
|
||||
docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T postgres psql -U yuqei -d yuqei_contract -v ON_ERROR_STOP=1 -c "UPDATE review_tasks SET status = 'failed', status_label = 'smoke cleanup', error_message = 'Skipped before deterministic smoke verification.', completed_at = now(), updated_at = now() WHERE status IN ('queued', 'running');" < /dev/null
|
||||
CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}')
|
||||
CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}')
|
||||
SMOKE_CONTRACT_ID=`$(printf '%s' "`$CREATE_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
|
||||
TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}')
|
||||
TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}')
|
||||
SMOKE_TASK_ID=`$(printf '%s' "`$TASK_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])')
|
||||
SMOKE_OUTPUT=`$(docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-review-worker python -m yuqei_contract_api.review_worker --run-once --limit 1 < /dev/null)
|
||||
echo "`$SMOKE_OUTPUT"
|
||||
printf '%s' "`$SMOKE_OUTPUT" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(1 if payload.get("failed", 0) > 0 else 0)'
|
||||
for i in `$(seq 1 30); do
|
||||
TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret')
|
||||
TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin')
|
||||
TASK_STATUS=`$(printf '%s' "`$TASKS_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T -e SMOKE_TASK_ID="`$SMOKE_TASK_ID" contract-api python -c 'import json, os, sys; task_id = os.environ["SMOKE_TASK_ID"]; rows = json.load(sys.stdin); print(next((item.get("status", "missing") for item in rows if item.get("id") == task_id), "missing"))')
|
||||
echo "smoke_review_task_status=`$TASK_STATUS"
|
||||
if [ "`$TASK_STATUS" = "succeeded" ]; then
|
||||
|
||||
@@ -92,6 +92,23 @@ def test_production_preflight_accepts_strict_configuration() -> None:
|
||||
assert [check.key for check in checks if check.status == "fail"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("contract_timeout", "provider_timeout"),
|
||||
[("30", "30"), ("0", "30"), ("601", "30"), ("NaN", "30"), ("60", "inf")],
|
||||
)
|
||||
def test_production_preflight_rejects_invalid_ai_timeout_budgets(
|
||||
contract_timeout: str,
|
||||
provider_timeout: str,
|
||||
) -> None:
|
||||
values = production_values()
|
||||
values["CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS"] = contract_timeout
|
||||
values["AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS"] = provider_timeout
|
||||
|
||||
checks = {check.key: check for check in preflight.validate_environment(values)}
|
||||
|
||||
assert checks["ai_timeout_budget"].status == "fail"
|
||||
|
||||
|
||||
def test_production_preflight_rejects_promotion_image_digest_drift() -> None:
|
||||
values = production_values()
|
||||
values["AI_PLATFORM_RELEASE_IMAGE_DIGEST"] = f"sha256:{'f' * 64}"
|
||||
@@ -197,6 +214,55 @@ def test_production_compose_mounts_only_promotion_verification_material_read_onl
|
||||
assert "STAGING_DATABASE_URL" not in override
|
||||
|
||||
|
||||
def test_shared_compose_wires_contract_anti_abuse_controls() -> None:
|
||||
compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
for variable in (
|
||||
"CONTRACT_API_ANTI_ABUSE_MODE",
|
||||
"CONTRACT_API_ANTI_ABUSE_HASH_SECRET",
|
||||
"CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT",
|
||||
"CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT",
|
||||
"CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT",
|
||||
"CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT",
|
||||
):
|
||||
assert f"{variable}: ${{{variable}:-" in compose
|
||||
|
||||
|
||||
def test_shared_compose_and_smoke_deploy_wire_contract_ai_timeout() -> None:
|
||||
compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
deploy_script = (
|
||||
REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert compose.count("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${") == 3
|
||||
assert "CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240" in deploy_script
|
||||
assert "AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180" in deploy_script
|
||||
|
||||
|
||||
def test_legal_qa_edge_timeout_covers_contract_client_maximum() -> None:
|
||||
nginx = (
|
||||
REPO_ROOT / "yuqei-infra/nginx/templates/default.conf.template"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
location = nginx.split("location = /contract/api/v1/legal/qa", 1)[1].split("}", 1)[0]
|
||||
assert "proxy_read_timeout 660s;" in location
|
||||
assert "proxy_send_timeout 660s;" in location
|
||||
assert "proxy_pass http://contract_api_upstream/api/v1/legal/qa;" in location
|
||||
|
||||
|
||||
def test_smoke_review_requests_include_server_authorized_identity_context() -> None:
|
||||
deploy_script = (
|
||||
REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert deploy_script.count("-H 'X-Tenant-Id: default'") >= 3
|
||||
assert deploy_script.count("-H 'X-User-Id: user-current-admin'") >= 3
|
||||
|
||||
|
||||
def test_disabled_identity_email_does_not_require_smtp_secret_file() -> None:
|
||||
values = production_values()
|
||||
values.pop("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", None)
|
||||
|
||||
Reference in New Issue
Block a user