完成40%

This commit is contained in:
rain
2026-07-23 17:21:27 +08:00
parent f1edc6b533
commit bb6431b319
114 changed files with 10931 additions and 877 deletions
+6 -5
View File
@@ -220,25 +220,26 @@ $smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
foreach ($contract in @(
'const activeLoginMethod = ref("sms")',
'const activeLoginMethod = ref("password")',
'const passwordVisible = ref(false)',
'login-tab--unavailable',
'aria-disabled="true"',
'const switchLoginMethod = (method) =>',
'const togglePasswordVisibility = () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_TAC_SCENE.SMS_LOGIN',
'normalizeTacSuccess',
'appApi.loginWithPassword',
'appApi.loginWithSms',
'PASSWORD_TAC_BLOCKED_MESSAGE',
'calcMD5(password.value)',
'const preparePasswordLogin = async () =>',
'login-submit',
'class="agreement-error"',
'const agreementError = ref(false)'
)) {
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
}
Assert-NotContains -Content $entry -Unexpected 'a01:last-login-method' -Message '密码登录后端未闭环前不得恢复为首屏或记忆入口。'
Assert-NotContains -Content $entry -Unexpected 'login-tab--unavailable' -Message 'A01 密码登录页签不得继续显示为不可用。'
Assert-NotContains -Content $entry -Unexpected 'PASSWORD_TAC_BLOCKED_MESSAGE' -Message 'A01 密码登录不得继续被旧硬关闭文案阻断。'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $entry -Unexpected $nativeUi -Message "A01 must not use native UniApp feedback: $nativeUi"
+207
View File
@@ -0,0 +1,207 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.join(__dirname, "../pages/auth/a01-entry.vue"),
"utf8",
);
const match = source.match(/<script setup>([\s\S]*?)<\/script>/);
assert(match, "A01 script setup is missing");
const pageScript = match[1].replace(
/import[\s\S]*?from\s+["'][^"']+["'];\s*/g,
"",
);
const createHarnessFactory = (goRootResult = true, goRootError = null) => new Function(
"goRootResult",
"goRootError",
`
"use strict";
const calls = [];
const ref = (value) => ({ value });
const onBackPress = () => {};
const onShow = (callback) => callback();
const onUnload = () => {};
const AuthPageShell = {};
const AppToast = {};
const TacVerification = {};
const createRequestController = () => ({
abort() {},
bind() {},
release() {},
});
const isRequestCancelled = () => false;
const isSmsDeliveryOutcomeUnknown = () => false;
const createAuthSmsCooldown = ({ onChange }) => ({
start() {
onChange(60);
},
sync() {
onChange(0);
return 0;
},
dispose() {},
});
const appApi = {
async getCaptchaRequirement(payload) {
calls.push({ type: "require", payload: { ...payload } });
return {
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: payload.sceneCode,
ttlSeconds: 300,
};
},
async loginWithPassword(payload) {
calls.push({ type: "password-login", payload: { ...payload } });
return { access_token: "token-password" };
},
async sendSmsCode(payload) {
calls.push({ type: "sms-code", payload: { ...payload } });
return null;
},
async loginWithSms(payload) {
calls.push({ type: "sms-login", payload: { ...payload } });
return { access_token: "token-sms" };
},
};
const AUTH_TAC_SCENE = Object.freeze({ SMS_LOGIN: "APP_SMS_LOGIN" });
const createTacRenderContext = (value) => ({ ...value });
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
const normalizeCaptchaRequirement = (value, expectedScene) => {
if (value?.sceneCode !== expectedScene || value?.required !== true) {
throw new Error("requirement invalid");
}
return value;
};
const normalizeTacSuccess = (value, expectedRequestId) => {
if (
!value ||
value.requestId !== expectedRequestId ||
typeof value.validToken !== "string" ||
!value.validToken
) {
throw new Error("TAC result invalid");
}
return value;
};
const runtimeConfig = {
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const calcMD5 = (value) => "md5:" + value;
const goRoot = async (pageId) => {
calls.push({ type: "go-root", pageId });
if (goRootError) throw goRootError;
return goRootResult;
};
const handleBackPress = () => false;
const openPage = () => {};
const runBackGuard = () => false;
const setTimeout = () => 1;
const clearTimeout = () => {};
const setInterval = () => 1;
const clearInterval = () => {};
${pageScript}
return {
calls,
phone,
password,
agreed,
tacVisible,
tacContext,
submitting,
authenticationCommitted,
feedbackMessage,
submitLogin,
completeTac,
closeTac,
setPendingTacAction(value) {
pendingTacAction = value;
},
};
`,
)(goRootResult, goRootError);
const createHarness = (options = {}) =>
createHarnessFactory(
options.goRootResult ?? true,
options.goRootError ?? null,
);
const countCalls = (harness, type) =>
harness.calls.filter((call) => call.type === type).length;
const preparePassword = async (harness, passwordValue = "secret-1") => {
harness.phone.value = "13800138000";
harness.password.value = passwordValue;
harness.agreed.value = true;
await harness.submitLogin();
assert.deepStrictEqual(harness.calls.map((call) => call.type), ["require"]);
assert.strictEqual(harness.submitting.value, false);
assert.strictEqual(harness.tacVisible.value, true);
assert(harness.tacContext.value?.requestId?.startsWith("a01-password-"));
};
const completePasswordTac = (harness) =>
harness.completeTac({
requestId: harness.tacContext.value.requestId,
validToken: "ticket-1",
expireSeconds: 300,
});
const run = async () => {
const success = createHarness();
await preparePassword(success);
assert.strictEqual(countCalls(success, "password-login"), 0);
await completePasswordTac(success);
assert.deepStrictEqual(
success.calls.map((call) => call.type),
["require", "password-login", "go-root"],
);
assert.deepStrictEqual(success.calls[1].payload, {
phone: "13800138000",
passwordHash: "md5:secret-1",
});
assert.strictEqual(success.calls[2].pageId, "G01");
assert.strictEqual(countCalls(success, "require"), 1);
assert.strictEqual(countCalls(success, "sms-code"), 0);
assert.strictEqual(success.tacVisible.value, false);
assert.strictEqual(success.tacContext.value, null);
const navigationRejected = createHarness({ goRootResult: false });
await preparePassword(navigationRejected);
await completePasswordTac(navigationRejected);
assert.strictEqual(navigationRejected.authenticationCommitted.value, true);
assert.match(navigationRejected.feedbackMessage.value, /登录已完成/);
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
await navigationRejected.submitLogin();
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
assert.strictEqual(countCalls(navigationRejected, "go-root"), 2);
const cancelled = createHarness();
await preparePassword(cancelled);
cancelled.closeTac();
await cancelled.completeTac({
requestId: "stale-request",
validToken: "ticket-stale",
expireSeconds: 300,
});
assert.strictEqual(countCalls(cancelled, "password-login"), 0);
assert.strictEqual(countCalls(cancelled, "sms-code"), 0);
process.stdout.write("A01-PASSWORD-LOGIN-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+2 -2
View File
@@ -151,8 +151,8 @@ const run = async () => {
assert(smsMetrics.agreementBottom <= smsMetrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "document.querySelector('.app-toast__copy')?.textContent === '密码登录的服务端安全验证尚未开放,请先使用验证码登录'", `A01 password unavailable reason was not announced at ${size.width}x${size.height}`)
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')"), false, `A01 exposed the unsafe password form at ${size.width}x${size.height}`)
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", `A01 password tab did not activate at ${size.width}x${size.height}`)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), true, `A01 password form did not render at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelector('.login-submit').click()")
@@ -8,6 +8,9 @@ $remoteBusinessOwners = @(
'pages/auth/a01-entry.vue',
'pages/auth/a04-register.vue',
'pages/auth/a05-reset-password.vue',
'pages/genealogy/g01-my-genealogies.vue',
'pages/genealogy/g05-genealogy-overview.vue',
'pages/tree/t01-tree-overview.vue',
'pages/profile/m07-feedback.vue'
)
@@ -21,7 +24,7 @@ foreach ($relativePath in $activePaths) {
throw "$relativePath must remain local-design only until its own tested interface batch"
}
if (-not $usesRemoteBusiness -and $relativePath -in $remoteBusinessOwners) {
throw "$relativePath lost its owned authentication interface"
throw "$relativePath lost its owned remote business interface"
}
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
}
+23
View File
@@ -133,6 +133,29 @@ const run = async () => {
assert.strictEqual(login.access_token, "token-1");
assert.deepStrictEqual(savedTokens, ["token-1"]);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-password" } },
});
const passwordLogin = await appApi.loginWithPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
});
assert.strictEqual(passwordLogin.access_token, "token-password");
const passwordRequest = requests.at(-1);
assert.strictEqual(passwordRequest.url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/login");
assert.strictEqual(passwordRequest.method, "POST");
assert.strictEqual(passwordRequest.header.clientid, "client-1");
assert.strictEqual(passwordRequest.header.Authorization, undefined);
assert.deepStrictEqual(passwordRequest.data, {
clientId: "client-1",
tenantId: "000000",
grantType: "password",
phone: "13800138000",
password: "a".repeat(32),
});
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
holdResponse = true;
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
@@ -0,0 +1,40 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$global = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8
Assert-Contains $global '.auth-page.auth-page .auth-plain-button[disabled]' '认证页禁用按钮必须由全局认证样式覆盖原生默认底色'
Assert-Contains $global 'background: transparent !important;' '认证页禁用按钮必须保持透明背景'
foreach ($relativePath in @(
'pages/auth/a01-entry.vue',
'pages/auth/a04-register.vue',
'pages/auth/a05-reset-password.vue'
)) {
$page = Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding utf8
foreach ($required in @(
'class="auth-plain-button get-code"',
'class="{ ''get-code--disabled'': sendingCode || cooldownSeconds > 0 }"',
'createAuthSmsCooldown({',
'const sentPhone = ref("");',
'isSmsDeliveryOutcomeUnknown',
'flex: 0 0 176rpx;',
'white-space: nowrap;',
'background: transparent !important;'
)) {
Assert-Contains $page $required "$relativePath 的验证码操作缺少视觉合同:$required"
}
if ($page.Contains('cooldownSeconds.value -= 1')) {
throw "$relativePath still uses a decrementing cooldown that freezes in background"
}
if ($page -match '(?s)v-model\.trim="phone".{0,300}:disabled="[^"]*cooldownSeconds') {
throw "$relativePath must not lock an empty phone field when a scene cooldown is restored"
}
}
Write-Output 'AUTH-CODE-ACTION-VISUAL-CONTRACT PASS'
@@ -0,0 +1,68 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-RequiredFile([string]$relativePath) {
$path = Join-Path $root $relativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED`n- missing file: $relativePath"
}
return Get-Content -LiteralPath $path -Raw -Encoding UTF8
}
$issues = [System.Collections.Generic.List[string]]::new()
$a01 = Read-RequiredFile 'pages/auth/a01-entry.vue'
$a04 = Read-RequiredFile 'pages/auth/a04-register.vue'
$pages = Read-RequiredFile 'pages.json'
function Require-Text(
[string]$content,
[string]$expected,
[string]$message
) {
if (-not $content.Contains($expected)) {
$script:issues.Add($message)
}
}
function Require-Pattern(
[string]$content,
[string]$pattern,
[string]$message
) {
if ($content -notmatch $pattern) {
$script:issues.Add($message)
}
}
Require-Text -content $a01 -expected 'const authenticationCommitted = ref(false);' -message 'A01 must remember that remote authentication already committed'
Require-Text -content $a01 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A01 must own a navigation-only retry after authentication commits'
Require-Text -content $a01 -expected 'authenticationCommitted.value = true;' -message 'A01 must mark the remote authentication result before navigation'
Require-Text -content $a01 -expected 'const authenticationNavigationFailure =' -message 'A01 must own a navigation-specific failure message'
Require-Pattern -content $a01 -pattern '(?s)if \(authenticationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A01 repeated submit after commit must retry only the local navigation'
Require-Text -content $a01 -expected 'const opened = await goRoot("G01");' -message 'A01 must inspect the navigation gateway boolean result'
Require-Text -content $a01 -expected 'if (opened !== true)' -message 'A01 must treat a false navigation result as retryable failure'
Require-Text -content $a04 -expected 'const registrationCommitted = ref(false);' -message 'A04 must remember that the remote registration already committed'
Require-Text -content $a04 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A04 must own a navigation-only retry after registration commits'
Require-Text -content $a04 -expected 'registrationCommitted.value = true;' -message 'A04 must mark the remote registration result before navigation'
Require-Text -content $a04 -expected 'const registrationNavigationFailure =' -message 'A04 must own a navigation-specific failure message'
Require-Pattern -content $a04 -pattern '(?s)if \(registrationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A04 repeated submit after commit must retry only the local navigation'
Require-Pattern -content $a04 -pattern '(?s)await appApi\.registerWithPassword\([\s\S]*?registrationCommitted\.value = true;\s*\}\s*catch' -message 'A04 must establish the registration commit before leaving the remote-write catch'
Require-Pattern -content $a04 -pattern '(?s)\}\s*catch \(error\) \{[\s\S]*?\}\s*finally[\s\S]*?\}\s*if \(!pageActive\) return;\s*await enterAuthenticatedRoot\(\);' -message 'A04 navigation must execute after the registration failure boundary'
Require-Text -content $a04 -expected 'const opened = await goRoot("G01");' -message 'A04 must inspect the navigation gateway boolean result'
Require-Text -content $a04 -expected 'if (opened !== true)' -message 'A04 must treat a false navigation result as retryable failure'
Require-Pattern -content $a04 -pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\?\s*enterAuthenticatedRoot\(\)' -message 'A04 committed registration must not enter the unsaved discard flow'
Require-Pattern -content $pages -pattern '"navigationBarTextStyle"\s*:\s*"white"' -message 'custom red headers require light Android status-bar content'
if ($issues.Count -gt 0) {
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED')
foreach ($issue in $issues) {
$lines.Add("- $issue")
}
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'AUTH-POST-COMMIT-NAVIGATION-CONTRACT PASS'
+87
View File
@@ -0,0 +1,87 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/auth-sms-cooldown.js"),
"utf8",
);
const { createAuthSmsCooldown } = await import(toDataModuleUrl(source));
let now = 1_000_000;
const timers = new Map();
let nextTimerId = 1;
const setIntervalFn = (callback) => {
const id = nextTimerId++;
timers.set(id, callback);
return id;
};
const clearIntervalFn = (id) => timers.delete(id);
const firstValues = [];
const first = createAuthSmsCooldown({
sceneCode: "APP_SMS_LOGIN",
onChange: (value) => firstValues.push(value),
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(first.sync(), 0);
first.start();
assert.strictEqual(firstValues.at(-1), 60);
now += 10_400;
assert.strictEqual(first.sync(), 50);
first.dispose();
assert.strictEqual(timers.size, 0, "dispose must stop only the page timer");
const restoredValues = [];
const restored = createAuthSmsCooldown({
sceneCode: "APP_SMS_LOGIN",
onChange: (value) => restoredValues.push(value),
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(
restored.sync(),
50,
"a recreated page must restore the scene cooldown without storing a phone number",
);
const other = createAuthSmsCooldown({
sceneCode: "APP_REGISTER",
onChange: () => {},
now: () => now,
setIntervalFn,
clearIntervalFn,
});
assert.strictEqual(other.sync(), 0, "cooldowns must be isolated by TAC scene");
now += 50_000;
assert.strictEqual(restored.sync(), 0);
restored.dispose();
other.dispose();
assert.throws(
() =>
createAuthSmsCooldown({
sceneCode: "",
onChange: () => {},
}),
/sceneCode/,
);
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+8 -2
View File
@@ -88,7 +88,12 @@ foreach ($page in @($a01, $a04, $a05)) {
}
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
Require-Text -Content $page -Text ':disabled="sendingCode || cooldownSeconds > 0 || submitting"' -Label '短信流程手机号锁定'
$phoneLock = if ($page -eq $a01) {
':disabled="sendingCode || cooldownSeconds > 0 || submitting || tacVisible"'
} else {
':disabled="sendingCode || cooldownSeconds > 0 || submitting"'
}
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
throw '认证页面必须在策略、短信与最终提交的异步回流前拒绝卸载后的旧结果'
}
@@ -105,9 +110,10 @@ foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) re
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
}
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithSms', '/^\d{4}$/', 'PASSWORD_TAC_BLOCKED_MESSAGE')) {
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
Require-Text -Content $a01 -Text $token -Label 'A01'
}
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
foreach ($token in @('AUTH_TAC_SCENE.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
+27 -2
View File
@@ -14,12 +14,12 @@ const run = async () => {
);
const {
AUTH_TAC_SCENE,
PASSWORD_TAC_BLOCKED_MESSAGE,
isAuthPhone,
assertSmsCode,
normalizeCaptchaRequirement,
createTacRenderContext,
normalizeTacSuccess,
isSmsDeliveryOutcomeUnknown,
} = await import(toDataModuleUrl(source));
assert.deepStrictEqual(
@@ -31,7 +31,6 @@ const run = async () => {
},
"认证 TAC 场景必须由受保护 OpenAPI 的唯一枚举拥有",
);
assert.match(PASSWORD_TAC_BLOCKED_MESSAGE, /服务端|安全验证|验证码登录/);
assert.strictEqual(isAuthPhone("13800138000"), true);
for (const invalid of ["", "12800138000", "1380013800", "138001380000", 13800138000, null]) {
@@ -123,6 +122,32 @@ const run = async () => {
/票据/,
);
for (const error of [
{ code: "REQUEST_TIMEOUT" },
{ code: "NETWORK_ERROR" },
{ code: "RESPONSE_INVALID" },
{ code: "REQUEST_CANCELLED" },
{ code: "HTTP_ERROR", httpStatus: 201 },
{ code: "HTTP_ERROR", httpStatus: 204 },
{ code: "HTTP_ERROR", httpStatus: 302 },
{ code: "HTTP_ERROR", httpStatus: 408 },
{ code: "HTTP_ERROR", httpStatus: 500 },
{ code: "BUSINESS_ERROR", businessCode: 408 },
{ code: "BUSINESS_ERROR", businessCode: 500 },
]) {
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), true);
}
for (const error of [
null,
{ code: "HTTP_ERROR", httpStatus: 400 },
{ code: "HTTP_ERROR", httpStatus: 401 },
{ code: "HTTP_ERROR", httpStatus: 422 },
{ code: "HTTP_ERROR", httpStatus: 429 },
{ code: "BUSINESS_ERROR", businessCode: 429 },
]) {
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), false);
}
process.stdout.write("AUTH-VERIFICATION-RUNTIME-SMOKE PASS\n");
};
+4 -16
View File
@@ -58,8 +58,8 @@ foreach ($token in @('currentStep.value = "ancestor"', 'submitAncestor', 'geneal
throw "Missing G03 first-person flow: $token"
}
}
if ($config -notmatch "mode:\s*'mock'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
throw 'Runtime config must retain mock isolation while owning the new HTTPS backend base URL'
if ($config -notmatch "mode:\s*'remote'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
throw 'Runtime config must enable the requested remote test backend over HTTPS'
}
if ($config -match '182\.61\.18\.23|http://backend-api\.ddxcjp\.cn|https://backend-api\.ddxcjp\.cn/') {
throw 'Runtime config retains an obsolete, insecure or trailing-slash backend URL'
@@ -78,20 +78,8 @@ if ($api -match 'if \(isMockMode\(\)\)') {
throw 'Auth transports must not treat every non-mock mode as remote'
}
foreach ($token in @(
'async auditApplication(genealogyId, applicationId, { status, auditRemark = '''' })',
'data: { status, auditRemark }',
'payload.relationDesc',
'payload.applyReason'
)) {
if (-not $api.Contains($token)) { throw "G-series API adapter contract missing: $token" }
}
if ($api.Contains('data: { approved }') -or $api.Contains('payload.message')) {
throw 'G-series API adapter retains the deleted approved/message payload contract'
}
if ($api -notmatch '!/\^\[12\]\$/\.test\(status\)' -or $api -notmatch 'Array\.from\(auditRemark\)\.length > 500') {
throw 'G-series audit adapter does not enforce the OpenAPI status pattern or remark length'
}
# 普通加入申请的旧数字审核体与宽松 adapter 已移交给专项 OpenAPI 门禁;
# 页面在后端门禁通过前继续由 G08-G10 合同证明未接入这些遗留方法。
if ($createFlow -match 'step=ancestor|query\?\.step') { throw 'G03 first-person flow must not restore the retired route step contract' }
if ($createFlow -match 'createPerson') { throw 'G03 must not retain the removed createPerson entrypoint' }
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
+245 -11
View File
@@ -6,7 +6,8 @@ $currentDocuments = @(
'docs/家谱项目全量治理设计.md',
'docs/家谱项目全量治理实施计划.md',
'docs/视觉资产与构建基线.md',
'docs/接口与页面映射总表.md'
'docs/接口与页面映射总表.md',
'docs/今晚全量联调与明早测试执行计划.md'
)
$combined = ''
@@ -50,8 +51,11 @@ if ($designDocument -notmatch '导航任务 1—10 的静态实施和零债务
if ($mappingDocument -notmatch '实际 `64/64` 个 Vue 文件') {
throw '接口与页面映射总表没有同步退役通用页面后的响应式覆盖实数'
}
if ($overviewDocument -notmatch 'G01/G05、G03、M06、M01/M02/M03 个人资料读写、N01/N02/M01/G01 通知域、M10 退出域、M04 密码凭证域与 M05 换绑域已完成三人接口审查和失败门禁' -or
$overviewDocument -notmatch '继续审查下一个不依赖现有红灯的业务域') {
if ($overviewDocument -notmatch '任务 36 普通加入申请' -or
$overviewDocument -notmatch '任务 37 邀请码签发与直接加入' -or
$overviewDocument -notmatch '任务 38 G11 家谱设置版本化写入' -or
$overviewDocument -notmatch '任务 39 G12 字辈集合版本化保存' -or
$overviewDocument -notmatch '任务 40 F01/F03 家族动态读取') {
throw '项目总览没有写明领域上下文与 M07 完成后的当前精确实施入口'
}
if ($overviewDocument -notmatch 'API-T01-001') {
@@ -72,8 +76,9 @@ if ($visualDocument -notmatch '`static/tac/` 当前共 5 个文件' -or
}
if ($mappingDocument -notmatch 'A01、A04、A05 已接入同一个 `TacVerification`' -or
$mappingDocument -notmatch '不能把本地滑动成功冒充服务端验证' -or
$mappingDocument -notmatch '密码登录入口保持不可用') {
throw '接口页面映射没有准确登记 A01/A04/A05 的 TAC 客户端状态与密码登录硬关闭'
$mappingDocument -notmatch '开发/联调 wire 已按线上' -or
$mappingDocument -notmatch '`validToken` 未进入或被服务端消费') {
throw '接口页面映射没有准确登记 A01 密码联调接线、TAC 客户端状态与生产红灯'
}
if ($mappingDocument -notmatch '任务 5 实施前' -or $mappingDocument -notmatch '这只是历史视觉基线' -or $mappingDocument -notmatch '任务 4—6 的当前代码仍须.*MuMu 流程矩阵') {
throw '接口页面映射把历史 MuMu 证据越界成了当前代码验收'
@@ -133,8 +138,11 @@ $navigationFoundation = [regex]::Match(
if ([string]::IsNullOrWhiteSpace($navigationFoundation) -or $navigationFoundation -match '- \[ \]') {
throw '实施计划没有把已经验证的导航任务 1/2 精确标为完成'
}
if ($planDocument -notmatch 'M04 密码凭证任务 33、M05 手机号换绑任务 34 和 G03 原子创建任务 35 已完成三人审查及 OpenAPI 红灯' -or
$planDocument -notmatch '等待后端期间转向 G/F/R 下一域') {
if ($planDocument -notmatch '任务 36:建立普通加入申请闭环远端硬门禁' -or
$planDocument -notmatch '任务 37:建立邀请码签发与直接加入远端硬门禁' -or
$planDocument -notmatch '任务 38:建立 G11 家谱设置版本化写入远端硬门禁' -or
$planDocument -notmatch '任务 39:建立 G12 字辈集合版本化保存远端硬门禁' -or
$planDocument -notmatch '任务 40:建立 F01/F03 家族动态读取远端硬门禁') {
throw '实施计划没有登记领域上下文与 M07 完成后的独立实施入口'
}
$taskThree = [regex]::Match(
@@ -362,13 +370,43 @@ if ($navigationSource -match '(?m)^export\s+(const|function)\s+replaceStep\b' -o
if ($mappingDocument -match '将在阶段 1.*确定' -or $mappingDocument -match '阶段 1 依赖') {
throw '接口映射总表仍把已收口导航或未来业务错误绑定为阶段 1 待定项'
}
if ($overviewDocument -notmatch '当前物理库存为 `140` 个 PowerShell 合同、`47` 个 Node 文件' -or
$overviewDocument -notmatch 'PowerShell `126/140` 通过' -or
$overviewDocument -notmatch 'Node 语法 `47/47` 通过' -or
$overviewDocument -notmatch '纯 Node 冒烟 `19/19` 通过' -or
if ($overviewDocument -notmatch '当前物理库存为 `147` 个 PowerShell 合同、`48` 个 Node 文件' -or
$overviewDocument -notmatch 'PowerShell `128/147` 通过' -or
$overviewDocument -notmatch 'Node 语法 `48/48` 通过' -or
$overviewDocument -notmatch '纯 Node 冒烟 `20/20` 通过' -or
$overviewDocument -notmatch 'Vue 脚本模块语法 `64/64` 通过') {
throw '项目总览没有登记当前测试库存与最新全量验证证据'
}
if ($overviewDocument -notmatch '当前物理库存更新为 PowerShell `141`、Node `47`' -or
$overviewDocument -notmatch 'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED') {
throw '项目总览没有登记任务 36 新增门禁后的物理库存和红灯'
}
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `142`、Node `47`' -or
$overviewDocument -notmatch '全量 fresh 为 `126/142`' -or
$overviewDocument -notmatch 'INVITE-TICKET-OPENAPI-CONTRACT BLOCKED') {
throw '项目总览没有登记任务 37 新增门禁后的物理库存、fresh 结果和红灯'
}
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `143`、Node `47`' -or
$overviewDocument -notmatch '全量 fresh 为 `126/143`' -or
$overviewDocument -notmatch 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED' -or
$overviewDocument -notmatch 'Issues: 48') {
throw '项目总览没有登记任务 38 新增门禁后的物理库存、fresh 结果和红灯'
}
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `145`、Node `48`' -or
$overviewDocument -notmatch '全量 fresh 为 `127/145`' -or
$overviewDocument -notmatch 'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED' -or
$overviewDocument -notmatch 'Issues: 72' -or
$overviewDocument -notmatch 'G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS' -or
$overviewDocument -notmatch 'G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS') {
throw '项目总览没有登记任务 39 门禁、对抗证据和 fresh 库存'
}
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `147`、Node `48`' -or
$overviewDocument -notmatch '全量 fresh 为 `128/147`' -or
$overviewDocument -notmatch 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED' -or
$overviewDocument -notmatch 'Issues: 85' -or
$overviewDocument -notmatch 'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58') {
throw '项目总览没有登记任务 40 家族动态读取门禁和 fresh 库存'
}
foreach ($completedDomainFact in @(
'appApi.submitFeedback',
'/genealogy/app/feedback',
@@ -390,6 +428,19 @@ foreach ($workspaceGateFact in @(
'RListAppGenealogyVo',
'RAppGenealogyVo',
'canView',
'appListMyGenealogies',
'appGetGenealogyOverview',
'GENEALOGY_ID_INVALID',
'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE',
'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS',
'旁路详情',
'允许非语义 tracing header',
'禁止 ETag',
'traceparent/tracestate/x-request-id/x-correlation-id',
'内联 Header Object',
'非 null 的 string schema',
'readOnly/writeOnly',
'allOf',
'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($workspaceGateFact) -and
@@ -537,6 +588,189 @@ foreach ($g03BootstrapGateFact in @(
throw "当前文档没有登记 G03 原子创建门禁事实:$g03BootstrapGateFact"
}
}
foreach ($joinApplicationGateFact in @(
'API-JOIN-001',
'API-JOIN-006',
'join-application-openapi-contract.ps1',
'appSearchPublicGenealogies',
'appCreateGenealogyJoinApplication',
'appGetGenealogyJoinApplicationRequest',
'appListMyGenealogyJoinApplications',
'appWithdrawGenealogyJoinApplication',
'appListPendingGenealogyJoinApplications',
'appReviewGenealogyJoinApplication',
'GenealogyJoinApplicationRequestKey',
'JOIN_APPLICATION_TEXT_V1',
'FAILED_NO_COMMIT',
'WHERE status=PENDING',
'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED',
'邀请码成功后直接加入且不生成审核记录',
'G10 手机号',
'消息中心承诺',
'LOCAL_WITHDRAWN',
'数据库观测',
'静态门禁单独转绿不开放页面'
)) {
if ($overviewDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
$designDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
$mappingDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
$planDocument -notmatch [regex]::Escape($joinApplicationGateFact)) {
throw "当前文档没有登记普通加入申请门禁事实:$joinApplicationGateFact"
}
}
foreach ($inviteTicketGateFact in @(
'API-INVITE-001',
'API-INVITE-006',
'invite-ticket-openapi-contract.ps1',
'appListMyGenealogyInviteTickets',
'appIssueGenealogyInviteTicket',
'appRevokeGenealogyInviteTicket',
'appResolveGenealogyInviteTicket',
'appRedeemGenealogyInviteTicket',
'appGetGenealogyInviteRedemptionRequest',
'INVITE-TICKET-OPENAPI-CONTRACT BLOCKED',
'G06',
'不再进入 G08',
'单次、24 小时',
'600 秒',
'HMAC',
'KMS',
'ACTIVE→CONSUMED',
'ACTIVE_PENDING_APPLICATION',
'PENDING/SUCCEEDED/FAILED_NO_COMMIT',
'TalkBack'
)) {
if ($overviewDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
$designDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
$mappingDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
$planDocument -notmatch [regex]::Escape($inviteTicketGateFact)) {
throw "当前文档没有登记邀请码直接加入门禁事实:$inviteTicketGateFact"
}
}
foreach ($settingsGateFact in @(
'API-SETTINGS-001',
'API-SETTINGS-006',
'g11-settings-openapi-contract.ps1',
'appUpdateGenealogySettings',
'GenealogyName',
'GenealogyIntro',
'GenealogySettingsVersion',
'AppGenealogySettingsUpdateBody',
'GENEALOGY_SETTINGS_VERSION_CHANGED',
'GENEALOGY_NOT_READY',
'ACTIVE_PENDING_APPLICATIONS',
'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED',
'Issues: 48',
'dirty-only',
'If-Match',
'ATOMIC_SINGLE_WINNER',
'canonical 设置实际变化',
'内部 LF',
'控制字符',
'exact local',
'单字段',
'nullable',
'真实 JSON 数组',
'允许非语义 tracing header',
'禁止 ETag',
'traceparent/tracestate/x-request-id/x-correlation-id',
'内联 Header Object',
'非 null 的 string schema',
'readOnly/writeOnly',
'根层',
'PUBLIC_APPLY→MEMBER_ONLY',
'fresh overview',
'不创建.*占位 client gate',
'OpenAPI 绿.*不.*直接接线'
)) {
$pattern = if ($settingsGateFact -in @('不创建.*占位 client gate', 'OpenAPI 绿.*不.*直接接线')) {
$settingsGateFact
} else {
[regex]::Escape($settingsGateFact)
}
if ($overviewDocument -notmatch $pattern -and
$designDocument -notmatch $pattern -and
$mappingDocument -notmatch $pattern -and
$planDocument -notmatch $pattern) {
throw "当前文档没有登记 G11 设置门禁事实:$settingsGateFact"
}
}
foreach ($generationPoemGateFact in @(
'API-POEM-001',
'API-POEM-006',
'g12-generation-poem-openapi-contract.ps1',
'g12-generation-poem-openapi-adversarial-contract.ps1',
'g12-generation-poem-unicode-contract-runtime-smoke.js',
'appGetGenerationPoemSet',
'appUpdateGenerationPoemSet',
'/genealogy/app/genealogies/{genealogyId}/generation-poems',
'AppGenerationPoemSetUpdateBody',
'GenerationPoemSetSnapshot',
'GenerationPoemSetVersion',
'disableMissing',
'VALIDATE_FINAL_ACTIVE_CAPACITY',
'ALLOCATE_UNIQUE_NEW_IDS',
'GENERATION_SLOT_CONFLICT',
'POEM_SET_VERSION_CONFLICT',
'FRESH_GET_THREE_WAY_NO_AUTO_PUT',
'CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION',
'APP_GATEWAY_PREFLIGHT',
'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED',
'Issues: 72',
'G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS',
'G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS',
'zero issues',
'ACTIVE-only',
'If-Match',
'不引入 preview token',
'不自动 PUT',
'本地预览',
'生产 normalizer/coordinator'
)) {
if ($overviewDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
$designDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
$mappingDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
$planDocument -notmatch [regex]::Escape($generationPoemGateFact)) {
throw "当前文档没有登记 G12 字辈集合门禁事实:$generationPoemGateFact"
}
}
foreach ($familyFeedReadGateFact in @(
'API-FEED-READ-001',
'API-FEED-READ-006',
'family-feed-read-openapi-contract.ps1',
'family-feed-read-openapi-adversarial-contract.ps1',
'appListFamilyFeeds',
'appGetFamilyFeed',
'appListFamilyFeedRootComments',
'/genealogy/app/genealogies/{genealogyId}/feeds',
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}',
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments',
'FamilyFeedId',
'FamilyFeedCommentId',
'AppFamilyFeedReadItem',
'AppFamilyFeedRootCommentReadItem',
'UPPER_BOUND_KEYSET_LATEST_VISIBLE',
'publishedAt:DESC',
'commentId:ASC_ORDINAL',
'hasMedia',
'FAMILY_FEED_NOT_AVAILABLE',
'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED',
'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58',
'Issues: 85',
'zero issues',
'手机号',
'审核字段',
'删除两个 `/page` GET',
'后端绿前',
'生产 read normalizer/coordinator'
)) {
if ($overviewDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
$designDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
$mappingDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
$planDocument -notmatch [regex]::Escape($familyFeedReadGateFact)) {
throw "当前文档没有登记 F01/F03 家族动态读取门禁事实:$familyFeedReadGateFact"
}
}
foreach ($liveOpenApiFact in @('https://backend-api.ddxcjp.cn/', '3.1.0', '722', '858', '507', '/captcha/challenge', 'validToken')) {
if ($overviewDocument -notmatch [regex]::Escape($liveOpenApiFact) -or $mappingDocument -notmatch [regex]::Escape($liveOpenApiFact)) {
throw "项目总览或接口映射缺少新线上 OpenAPI 事实:$liveOpenApiFact"
@@ -0,0 +1,990 @@
$ErrorActionPreference = 'Stop'
$contractPath = Join-Path $PSScriptRoot 'family-feed-read-openapi-contract.ps1'
$temporaryPath = [System.IO.Path]::GetTempFileName()
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
function New-Ref([string]$Ref) {
return [ordered]@{ '$ref' = $Ref }
}
function New-StringOwner(
[int]$MinLength,
[int]$MaxLength,
[string]$Pattern = ''
) {
$schema = [ordered]@{
type = 'string'
minLength = $MinLength
maxLength = $MaxLength
nullable = $false
}
if ($Pattern) { $schema.pattern = $Pattern }
return $schema
}
function New-ClosedObject(
[System.Collections.Specialized.OrderedDictionary]$Properties,
[string[]]$Required
) {
return [ordered]@{
type = 'object'
properties = $Properties
required = $Required
additionalProperties = $false
nullable = $false
}
}
function New-SuccessEnvelope([string]$DataRef) {
return New-ClosedObject ([ordered]@{
code = [ordered]@{
type = 'integer'
enum = @(200)
nullable = $false
}
data = New-Ref $DataRef
}) @('code', 'data')
}
function New-ErrorEnvelope([int]$Status, [string[]]$BusinessCodes) {
return New-ClosedObject ([ordered]@{
businessCode = [ordered]@{
type = 'string'
enum = $BusinessCodes
nullable = $false
}
code = [ordered]@{
type = 'integer'
enum = @($Status)
nullable = $false
}
message = [ordered]@{
type = 'string'
minLength = 1
maxLength = 200
nullable = $false
}
}) @('businessCode', 'code', 'message')
}
function New-Response([string]$SchemaRef, [bool]$RateLimited = $false) {
$headers = [ordered]@{
'Cache-Control' = New-Ref '#/components/headers/PrivateNoStore'
}
if ($RateLimited) {
$headers.'Retry-After' = New-Ref '#/components/headers/RetryAfter'
}
return [ordered]@{
description = 'typed response'
headers = $headers
content = [ordered]@{
'application/json' = [ordered]@{
schema = New-Ref $SchemaRef
}
}
}
}
function New-Responses([string]$SuccessRef) {
return [ordered]@{
'200' = New-Response $SuccessRef
'400' = New-Response '#/components/schemas/RFamilyFeedReadBadRequest'
'401' = New-Response '#/components/schemas/RFamilyFeedReadUnauthorized'
'404' = New-Response '#/components/schemas/RFamilyFeedReadNotFound'
'429' = New-Response '#/components/schemas/RFamilyFeedReadRateLimited' $true
'500' = New-Response '#/components/schemas/RFamilyFeedReadUnavailable'
}
}
function New-ClientIdParameter {
return [ordered]@{
name = 'clientid'
in = 'header'
required = $true
schema = [ordered]@{
type = 'string'
minLength = 1
maxLength = 128
nullable = $false
}
}
}
function New-IdParameter([string]$Name, [string]$SchemaRef) {
return [ordered]@{
name = $Name
in = 'path'
required = $true
schema = New-Ref $SchemaRef
}
}
function New-CursorParameter([string]$SchemaRef) {
return [ordered]@{
name = 'cursor'
in = 'query'
required = $false
schema = New-Ref $SchemaRef
}
}
function New-LimitParameter {
return [ordered]@{
name = 'limit'
in = 'query'
required = $false
schema = [ordered]@{
type = 'integer'
minimum = 1
maximum = 50
default = 20
nullable = $false
}
}
}
function New-ReadOperation(
[string]$OperationId,
[object[]]$Parameters,
[string]$SuccessRef,
[string[]]$AuthorizationScope
) {
return [ordered]@{
operationId = $OperationId
parameters = $Parameters
responses = New-Responses $SuccessRef
security = @([ordered]@{ SaToken = @() })
'x-read-only' = $true
'x-non-disclosing-not-found' = $true
'x-authorize-every-request' = $true
'x-cors-policy-owner' = 'APP_GATEWAY_PREFLIGHT'
'x-authorization-scope' = $AuthorizationScope
}
}
function Add-CursorContract(
[System.Collections.Specialized.OrderedDictionary]$Operation,
[string[]]$Scope,
[string[]]$Order
) {
$Operation.'x-cursor-scope' = $Scope
$Operation.'x-cursor-order' = $Order
$Operation.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
$Operation.'x-cursor-no-total' = $true
$Operation.'x-refresh-discards-cursor' = $true
$Operation.'x-authorize-every-page' = $true
$Operation.'x-invalid-or-expired-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
$Operation.'x-cross-scope-cursor' = '404_FAMILY_FEED_NOT_AVAILABLE'
}
function New-CursorPage(
[string]$ItemRef,
[string]$CursorRef,
[string[]]$Order
) {
$page = New-ClosedObject ([ordered]@{
items = [ordered]@{
type = 'array'
items = New-Ref $ItemRef
minItems = 0
maxItems = 50
nullable = $false
}
nextCursor = New-Ref $CursorRef
}) @('items')
$page.'x-no-total' = $true
$page.'x-next-cursor-absent-at-end' = $true
$page.'x-order' = $Order
$page.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
$page.'x-new-items-after-window' = 'EXCLUDED_UNTIL_REFRESH'
$page.'x-deletion-or-visibility-change' = 'OMIT_ON_LATER_PAGE'
$page.'x-edit-policy' = 'LATEST_VISIBLE_AT_PAGE_READ'
return $page
}
function New-ValidDocument {
$genealogyId = New-StringOwner 1 128 $identifierPattern
$feedId = New-StringOwner 1 128 $identifierPattern
$feedId.'x-opaque' = $true
$feedId.'x-client-semantics' = 'COMPARE_ONLY'
$commentId = New-StringOwner 1 128 $identifierPattern
$commentId.'x-opaque' = $true
$commentId.'x-client-semantics' = 'COMPARE_ONLY'
$feedCursor = New-StringOwner 1 512 $cursorPattern
$feedCursor.'x-opaque' = $true
$feedCursor.'x-purpose' = 'FAMILY_FEED_PAGE'
$commentCursor = New-StringOwner 1 512 $cursorPattern
$commentCursor.'x-opaque' = $true
$commentCursor.'x-purpose' = 'FAMILY_FEED_ROOT_COMMENT_PAGE'
$feedContent = New-StringOwner 1 300
$feedContent.'x-text-normalizer' = 'FAMILY_FEED_TEXT_V1'
$feedContent.'x-length-unit' = 'UNICODE_CODE_POINT'
$commentContent = New-StringOwner 1 1000
$commentContent.'x-text-normalizer' = 'FAMILY_FEED_COMMENT_TEXT_V1'
$commentContent.'x-length-unit' = 'UNICODE_CODE_POINT'
$displayName = New-StringOwner 1 100
$displayName.'x-projection' = 'AUTHORIZED_DISPLAY_NAME_ONLY'
$displayName.'x-missing-author-policy' = 'NON_EMPTY_SERVER_FALLBACK'
$publishedAt = [ordered]@{
type = 'string'
format = 'date-time'
nullable = $false
'x-server-generated' = $true
'x-immutable' = $true
}
$feedItem = New-ClosedObject ([ordered]@{
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
feedContent = New-Ref '#/components/schemas/FamilyFeedContent'
feedId = New-Ref '#/components/schemas/FamilyFeedId'
hasMedia = [ordered]@{
type = 'boolean'
nullable = $false
}
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
}) @('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt')
$feedItem.description = 'Annotations may mention phone or audit examples without becoming response fields.'
$feedItem.example = [ordered]@{ annotationOnly = 'appUserPhone is not a schema property' }
$feedItem.'x-projection' = 'VISIBLE_FEED_PRESENTATION_ONLY'
$feedItem.'x-media-policy' = 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT'
$commentItem = New-ClosedObject ([ordered]@{
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
commentContent = New-Ref '#/components/schemas/FamilyFeedCommentContent'
commentId = New-Ref '#/components/schemas/FamilyFeedCommentId'
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
}) @('authorDisplayName', 'commentContent', 'commentId', 'publishedAt')
$commentItem.'x-projection' = 'VISIBLE_COMMENT_PRESENTATION_ONLY'
$commentItem.'x-comment-level' = 'ROOT_ONLY'
$commentItem.'x-deleted-placeholder-policy' = 'EXCLUDE'
$feedOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
$commentOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
$feedList = New-ReadOperation 'appListFamilyFeeds' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-CursorParameter '#/components/schemas/FamilyFeedCursor'),
(New-LimitParameter)
) '#/components/schemas/RAppFamilyFeedCursorPage' @('tenant', 'genealogy', 'membership')
Add-CursorContract $feedList @(
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection',
'order', 'limit', 'windowUpperBound', 'lastTuple'
) $feedOrder
$feedDetail = New-ReadOperation 'appGetFamilyFeed' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId')
) '#/components/schemas/RAppFamilyFeedReadItem' @(
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
)
$comments = New-ReadOperation 'appListFamilyFeedRootComments' @(
(New-ClientIdParameter),
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId'),
(New-CursorParameter '#/components/schemas/FamilyFeedRootCommentCursor'),
(New-LimitParameter)
) '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' @(
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
)
Add-CursorContract $comments @(
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId',
'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple'
) $commentOrder
$schemas = [ordered]@{
GenealogyId = $genealogyId
FamilyFeedId = $feedId
FamilyFeedCommentId = $commentId
FamilyFeedCursor = $feedCursor
FamilyFeedRootCommentCursor = $commentCursor
FamilyFeedContent = $feedContent
FamilyFeedCommentContent = $commentContent
FamilyFeedAuthorDisplayName = $displayName
FamilyFeedPublishedAt = $publishedAt
AppFamilyFeedReadItem = $feedItem
AppFamilyFeedRootCommentReadItem = $commentItem
AppFamilyFeedCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' $feedOrder
AppFamilyFeedRootCommentCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' $commentOrder
RAppFamilyFeedCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedCursorPage'
RAppFamilyFeedReadItem = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedReadItem'
RAppFamilyFeedRootCommentCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
RFamilyFeedReadBadRequest = New-ErrorEnvelope 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
RFamilyFeedReadUnauthorized = New-ErrorEnvelope 401 @('AUTH_REQUIRED')
RFamilyFeedReadNotFound = New-ErrorEnvelope 404 @('FAMILY_FEED_NOT_AVAILABLE')
RFamilyFeedReadRateLimited = New-ErrorEnvelope 429 @('RATE_LIMITED')
RFamilyFeedReadUnavailable = New-ErrorEnvelope 500 @('FAMILY_FEED_READ_UNAVAILABLE')
}
$document = [ordered]@{
openapi = '3.0.1'
info = [ordered]@{
title = 'family feed read adversarial fixture'
version = '1'
}
paths = [ordered]@{
'/genealogy/app/genealogies/{genealogyId}/feeds' = [ordered]@{
get = $feedList
}
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}' = [ordered]@{
get = $feedDetail
}
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments' = [ordered]@{
get = $comments
}
}
components = [ordered]@{
schemas = $schemas
headers = [ordered]@{
PrivateNoStore = [ordered]@{
schema = [ordered]@{
type = 'string'
enum = @('private, no-store')
nullable = $false
}
}
RetryAfter = [ordered]@{
schema = [ordered]@{
type = 'integer'
minimum = 1
maximum = 300
nullable = $false
}
}
}
securitySchemes = [ordered]@{
SaToken = [ordered]@{
type = 'apiKey'
in = 'header'
name = 'Authorization'
}
}
}
}
return ($document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
}
function Copy-Document([object]$Document) {
return ($Document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
}
function Get-ContractIssues([object]$Document) {
$json = $Document | ConvertTo-Json -Depth 100
[System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false))
$output = @(
& $contractPath -SkipProtectedParity -ReturnIssues -DocumentPath $temporaryPath
)
return @($output | Where-Object { $_ -is [string] -and $_.Length -gt 0 })
}
function Rename-NoteProperty([object]$Owner, [string]$From, [string]$To) {
$property = @($Owner.PSObject.Properties | Where-Object { $_.Name -ceq $From })[0]
if (-not $property) { throw "mutation setup missing property: $From" }
$value = $property.Value
$Owner.PSObject.Properties.Remove($From)
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($To, $value))
}
function Add-NoteProperty([object]$Owner, [string]$Name, [object]$Value) {
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($Name, $Value))
}
function Assert-MutantRejected(
[object]$Seed,
[string]$Label,
[scriptblock]$Mutate,
[string]$ExpectedIssuePattern
) {
$mutant = Copy-Document $Seed
& $Mutate $mutant
$mutantIssues = @(Get-ContractIssues $mutant)
if ($mutantIssues.Count -eq 0) {
throw "adversarial mutant fake-greened: $Label"
}
if ($ExpectedIssuePattern -and -not (($mutantIssues -join "`n") -match $ExpectedIssuePattern)) {
throw "adversarial mutant rejected for the wrong reason: $Label`n$($mutantIssues -join "`n")"
}
}
try {
$seed = New-ValidDocument
$seedIssues = @(Get-ContractIssues $seed)
if ($seedIssues.Count -gt 0) {
throw "valid zero-issue seed was rejected:`n$($seedIssues -join "`n")"
}
$mutations = @(
@{
Label = 'legacy /feeds/page GET owner'
Pattern = 'legacy duplicate GET owner'
Apply = {
param($doc)
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/feeds/page' ([pscustomobject]@{
get = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
})
}
},
@{
Label = 'operationId drift'
Pattern = 'operationId must be appListFamilyFeeds'
Apply = { param($doc) $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.operationId = 'list_17' }
},
@{
Label = 'Path Item Get keyword casing'
Pattern = 'Path Item keyword casing is invalid'
Apply = {
param($doc)
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'get' 'Get'
}
},
@{
Label = 'security object instead of array'
Pattern = 'security must be a JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security = [pscustomobject]@{ SaToken = @() }
}
},
@{
Label = 'SaToken key casing'
Pattern = 'must require only exact SaToken'
Apply = {
param($doc)
$requirement = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0]
Rename-NoteProperty $requirement 'SaToken' 'satoken'
}
},
@{
Label = 'non-empty SaToken scopes'
Pattern = 'SaToken scopes must be an empty JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0].SaToken = @('feed:read')
}
},
@{
Label = 'int64 feed identity'
Pattern = 'sole exact local ref #/components/schemas/FamilyFeedId'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId = [pscustomobject]@{ type = 'integer'; format = 'int64' }
}
},
@{
Label = 'open feed projection'
Pattern = 'AppFamilyFeedReadItem must be closed'
Apply = { param($doc) $doc.components.schemas.AppFamilyFeedReadItem.additionalProperties = $true }
},
@{
Label = 'phone field leak'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedReadItem.properties 'appUserPhone' ([pscustomobject]@{
type = 'string'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedReadItem.required += 'appUserPhone'
}
},
@{
Label = 'moderation field leak'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'moderationReason' ([pscustomobject]@{
type = 'string'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'moderationReason'
}
},
@{
Label = 'offset total in cursor page'
Pattern = 'AppFamilyFeedCursorPage must be closed'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedCursorPage.properties 'total' ([pscustomobject]@{
type = 'integer'; nullable = $false
})
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'total'
}
},
@{
Label = 'pageNum query bypass'
Pattern = 'parameters must be exactly'
Apply = {
param($doc)
$operation = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$operation.parameters += [pscustomobject]@{
name = 'pageNum'; in = 'query'; required = $false
schema = [pscustomobject]@{ type = 'integer'; minimum = 1; nullable = $false }
}
}
},
@{
Label = 'cursor scope comma string'
Pattern = 'x-cursor-scope must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = 'tenant,account,authSession'
}
},
@{
Label = 'cross-scope cursor returns 400'
Pattern = 'cursor/refresh/per-page authorization semantics drifted'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cross-scope-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
}
},
@{
Label = '403 resource existence split'
Pattern = 'responses must be exactly'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'.get.responses '403' (
New-Response '#/components/schemas/RFamilyFeedReadNotFound'
)
}
},
@{
Label = 'wildcard response media type'
Pattern = 'must expose only application/json'
Apply = {
param($doc)
$response = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
Rename-NoteProperty $response.content 'application/json' '*/*'
}
},
@{
Label = 'missing private cache header'
Pattern = 'must define Cache-Control'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.PSObject.Properties.Remove('Cache-Control')
}
},
@{
Label = 'unbounded Retry-After'
Pattern = 'Retry-After must be a non-null integer in 1..300'
Apply = { param($doc) $doc.components.headers.RetryAfter.schema.maximum = 301 }
},
@{
Label = 'generic list response'
Pattern = '200 schema ref must be #/components/schemas/RAppFamilyFeedCursorPage'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.content.'application/json'.schema.'$ref' = '#/components/schemas/RObject'
}
},
@{
Label = 'external feedId ref'
Pattern = 'must be the sole exact local ref'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId.'$ref' = 'https://example.invalid/schemas.json#/FamilyFeedId'
}
},
@{
Label = 'hasMedia removed'
Pattern = 'AppFamilyFeedReadItem must be closed'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedReadItem.properties.PSObject.Properties.Remove('hasMedia')
$doc.components.schemas.AppFamilyFeedReadItem.required = @(
$doc.components.schemas.AppFamilyFeedReadItem.required | Where-Object { $_ -cne 'hasMedia' }
)
}
},
@{
Label = 'feed cursor order drift'
Pattern = 'x-cursor-order must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-order' = @('feedId:DESC_ORDINAL', 'publishedAt:DESC')
}
},
@{
Label = 'mutable publishedAt'
Pattern = 'must be a non-null immutable server-generated RFC3339'
Apply = { param($doc) $doc.components.schemas.FamilyFeedPublishedAt.'x-immutable' = $false }
},
@{
Label = 'feed content length drift'
Pattern = 'FamilyFeedContent must be a non-null string length 1..300'
Apply = { param($doc) $doc.components.schemas.FamilyFeedContent.maxLength = 301 }
},
@{
Label = 'comment level internal field'
Pattern = 'success graph leaks forbidden/internal field'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'commentLevel' ([pscustomobject]@{
type = 'string'; enum = @('root'); nullable = $false
})
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'commentLevel'
}
},
@{
Label = 'duplicate operationId'
Pattern = 'operationId must be appListFamilyFeedRootComments|must have exactly one global operation owner'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'.get.operationId = 'appListFamilyFeeds'
}
},
@{
Label = 'explicit HEAD read bypass'
Pattern = 'must not expose an explicit HEAD read bypass'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'head' ([pscustomobject]@{
responses = [pscustomobject]@{}
})
}
},
@{
Label = 'callback side channel'
Pattern = 'must not define callbacks'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'callbacks' ([pscustomobject]@{
leak = [pscustomobject]@{}
})
}
},
@{
Label = 'schema Type keyword casing'
Pattern = 'contains an unowned schema keyword: Type'
Apply = { param($doc) Rename-NoteProperty $doc.components.schemas.FamilyFeedContent 'type' 'Type' }
},
@{
Label = 'nextCursor made required'
Pattern = 'AppFamilyFeedCursorPage must be closed'
Apply = {
param($doc)
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'nextCursor'
}
},
@{
Label = 'alternate APP GET reuses feed read projection'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowFamilyFeedRead'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'Response Object links side channel'
Pattern = 'contains an unowned Response Object keyword: links'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' 'links' ([pscustomobject]@{
next = [pscustomobject]@{ operationId = 'shadowFamilyFeedRead' }
})
}
},
@{
Label = 'Header Object content side channel'
Pattern = 'contains an unowned Header Object keyword: content'
Apply = {
param($doc)
Add-NoteProperty $doc.components.headers.PrivateNoStore 'content' ([pscustomobject]@{
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ type = 'string' } }
})
}
},
@{
Label = 'OpenAPI 3.1 dialect drift'
Pattern = 'OpenAPI version must be exact 3.0.1'
Apply = { param($doc) $doc.openapi = '3.1.0' }
},
@{
Label = 'OpenAPI 3.1 webhooks keyword'
Pattern = 'OpenAPI root contains an unowned keyword: webhooks'
Apply = { param($doc) Add-NoteProperty $doc 'webhooks' ([pscustomobject]@{}) }
},
@{
Label = 'OpenAPI Paths keyword casing'
Pattern = 'OpenAPI root keyword casing is invalid: Paths'
Apply = { param($doc) Rename-NoteProperty $doc 'paths' 'Paths' }
},
@{
Label = 'OpenAPI Components keyword casing'
Pattern = 'OpenAPI root keyword casing is invalid: Components'
Apply = { param($doc) Rename-NoteProperty $doc 'components' 'Components' }
},
@{
Label = 'cursor extension keyword casing'
Pattern = 'operation keyword casing is invalid: X-Cursor-Scope'
Apply = {
param($doc)
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-cursor-scope' 'X-Cursor-Scope'
}
},
@{
Label = 'cursor scope comma join collision'
Pattern = 'x-cursor-scope must be the exact JSON array'
Apply = {
param($doc)
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = @(
'tenant,account', 'authSession', 'client', 'genealogyId', 'projection',
'order', 'limit', 'windowUpperBound', 'lastTuple'
)
}
},
@{
Label = 'Parameter schema keyword casing'
Pattern = 'Parameter Object keyword casing is invalid: Schema'
Apply = {
param($doc)
$parameter = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1]
Rename-NoteProperty $parameter 'schema' 'Schema'
}
},
@{
Label = 'path parameter case-shadow'
Pattern = 'parameters must be exactly'
Apply = {
param($doc)
$pathItem = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'
Add-NoteProperty $pathItem 'parameters' @(
[pscustomobject]@{
name = 'GenealogyId'
in = 'path'
required = $true
schema = New-Ref '#/components/schemas/GenealogyId'
}
)
}
},
@{
Label = 'Parameter Reference Object sibling'
Pattern = 'parameter ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'parameters' ([pscustomobject]@{
GenealogyIdParameter = [pscustomobject]@{
name = 'genealogyId'
in = 'path'
required = $true
schema = New-Ref '#/components/schemas/GenealogyId'
}
})
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] = [pscustomobject]@{
'$ref' = '#/components/parameters/GenealogyIdParameter'
description = 'forbidden sibling'
}
}
},
@{
Label = 'Schema Reference Object sibling'
Pattern = 'must be the sole exact local ref'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1].schema 'description' 'forbidden sibling'
}
},
@{
Label = 'SaToken security scheme drift'
Pattern = 'SaToken must be the exact apiKey/header/Authorization security owner'
Apply = {
param($doc)
$doc.components.securitySchemes.SaToken = [pscustomobject]@{
type = 'oauth2'
flows = [pscustomobject]@{}
}
}
},
@{
Label = 'alternate 206 owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowFamilyFeed206'
$shadow.responses = [pscustomobject]@{
'206' = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-206' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'replies path reuses canonical projection'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowRepliesFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/replies-shadow' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'alternate HEAD projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowHeadFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-head' ([pscustomobject]@{ head = $shadow })
}
},
@{
Label = 'alternate OPTIONS projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowOptionsFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-options' ([pscustomobject]@{ options = $shadow })
}
},
@{
Label = 'callback GET projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowCallbackFamilyFeed'
Add-NoteProperty $doc.paths '/genealogy/app/callback-carrier' ([pscustomobject]@{
post = [pscustomobject]@{
operationId = 'callbackCarrier'
responses = [pscustomobject]@{
'204' = [pscustomobject]@{ description = 'accepted' }
}
callbacks = [pscustomobject]@{
leak = [pscustomobject]@{
'{$request.body#/callbackUrl}' = [pscustomobject]@{ get = $shadow }
}
}
}
})
}
},
@{
Label = 'inline alternate projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowInlineFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = Copy-Document $doc.components.schemas.RAppFamilyFeedCursorPage
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-inline' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'two-hop schema alias owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasTwo' (New-Ref '#/components/schemas/RAppFamilyFeedCursorPage')
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasOne' (New-Ref '#/components/schemas/FamilyFeedAliasTwo')
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowSchemaAliasFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = New-Ref '#/components/schemas/FamilyFeedAliasOne'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-schema-alias' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'two-hop response alias owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
FamilyFeedAliasOne = New-Ref '#/components/responses/FamilyFeedAliasTwo'
FamilyFeedAliasTwo = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
})
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowResponseAliasFamilyFeed'
$shadow.responses = [pscustomobject]@{
'200' = New-Ref '#/components/responses/FamilyFeedAliasOne'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-response-alias' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'external alternate response schema'
Pattern = 'schema ref is not an inspectable exact local component ref'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowExternalFamilyFeed'
$shadow.responses.'200'.content.'application/json'.schema = New-Ref 'https://example.invalid/feed.json#/FeedPage'
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-external' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'default response projection owner'
Pattern = 'alternate APP operation exposes the family-feed read projection'
Apply = {
param($doc)
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
$shadow.operationId = 'shadowDefaultFamilyFeed'
$shadow.responses = [pscustomobject]@{
default = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
}
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-default' ([pscustomobject]@{ get = $shadow })
}
},
@{
Label = 'Response Reference Object sibling'
Pattern = 'response ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
FeedListSuccess = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
})
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' = [pscustomobject]@{
'$ref' = '#/components/responses/FeedListSuccess'
description = 'forbidden sibling'
}
}
},
@{
Label = 'Header Reference Object sibling'
Pattern = 'header ref must contain only its exact local'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.'Cache-Control' 'description' 'forbidden sibling'
}
},
@{
Label = 'Parameter content side channel'
Pattern = 'Parameter Object contains an unowned keyword: content'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] 'content' ([pscustomobject]@{
'application/json' = [pscustomobject]@{
schema = New-Ref '#/components/schemas/GenealogyId'
}
})
}
},
@{
Label = 'unknown operation extension'
Pattern = 'operation contains an unowned keyword: x-shadow-owner'
Apply = {
param($doc)
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-shadow-owner' 'legacy'
}
}
)
foreach ($mutation in $mutations) {
Assert-MutantRejected $seed $mutation.Label $mutation.Apply $mutation.Pattern
}
Write-Output "FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=$($mutations.Count)"
} finally {
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
File diff suppressed because it is too large Load Diff
+12 -291
View File
@@ -5,303 +5,24 @@ $jsonPath = Join-Path $root 'APP.openapi.json'
$yamlPath = Join-Path $root 'APP.openapi.yaml'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
$helper = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/generation-poem.js')
function Get-YamlBlock {
param(
[string]$Header,
[string]$NextHeaderPattern
)
$lines = @($yaml -split "`r?`n")
$start = [Array]::IndexOf($lines, $Header)
if ($start -lt 0) { throw "YAML block missing: $Header" }
$end = $lines.Count
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
if ($lines[$index] -match $NextHeaderPattern) {
$end = $index
break
}
}
return ($lines[$start..($end - 1)] -join "`n")
}
function Get-YamlNestedBlock {
param(
[string]$Content,
[string]$Header,
[string]$NextHeaderPattern
)
$pattern = '(?ms)^' + [regex]::Escape($Header) + "`n(?<body>.*?)(?=$NextHeaderPattern|\z)"
$match = [regex]::Match($Content, $pattern)
if (-not $match.Success) { throw "YAML nested block missing: $Header" }
return $match.Value
}
foreach ($path in @(
'/genealogy/app/genealogies',
'/genealogy/app/genealogies/{genealogyId}',
'/genealogy/app/genealogies/{genealogyId}/join-applies',
'/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit',
'/genealogy/app/genealogies/{genealogyId}/generation-poems',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'
'/genealogy/app/genealogies/{genealogyId}'
)) {
if (-not $document.paths.PSObject.Properties[$path]) { throw "G-series OpenAPI path missing: $path" }
if (-not $yaml.Contains(" $path`:")) { throw "G-series YAML path missing: $path" }
}
# 创建、设置与访问预设从本轮起只由 g03-bootstrap-openapi-contract.ps1 管理.
# 旧测试不得继续锁定 GenealogyCreateBody 或 visibility/joinMode,否则会把后端的新单一合同误判为回归.
$joinApply = $document.components.schemas.GenealogyJoinApplyBody
foreach ($field in @('applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
if (-not $joinApply.properties.PSObject.Properties[$field]) {
throw "GenealogyJoinApplyBody field missing: $field"
if (-not $document.paths.PSObject.Properties[$path]) {
throw "G-series OpenAPI path missing: $path"
}
if (-not $yaml.Contains(" $path`:")) {
throw "G-series YAML path missing: $path"
}
}
$audit = $document.components.schemas.GenealogyJoinAuditBody
if ('status' -notin @($audit.required)) { throw 'GenealogyJoinAuditBody.status must remain required' }
foreach ($field in @('status', 'auditRemark')) {
if (-not $audit.properties.PSObject.Properties[$field]) {
throw "GenealogyJoinAuditBody field missing: $field"
}
}
$batch = $document.components.schemas.GenerationPoemBatchBody
$batchProperties = @($batch.properties.PSObject.Properties.Name | Sort-Object)
if (($batchProperties -join ',') -ne 'disableMissing,poemText') {
throw "GenerationPoemBatchBody fields drifted: $($batchProperties -join ',')"
}
$batchRequired = @($batch.required | Sort-Object)
if (($batchRequired -join ',') -ne 'poemText') {
throw "GenerationPoemBatchBody required fields drifted: $($batchRequired -join ',')"
}
if ($batch.properties.poemText.type -ne 'string') { throw 'GenerationPoemBatchBody.poemText must remain a string' }
if ($batch.properties.disableMissing.type -ne 'boolean') { throw 'GenerationPoemBatchBody.disableMissing must remain a boolean' }
if ($batch.properties.poemText.maxLength -ne 26000) { throw 'GenerationPoemBatchBody.poemText maxLength must be 26000' }
$poemDescription = [string]$batch.properties.poemText.description
foreach ($fact in @(
'\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26',
'\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3',
'\u7a7a\u683c\u3001\u9017\u53f7\u3001\u5206\u53f7\u3001\u987f\u53f7\u3001\u659c\u6760\u6216\u7ad6\u7ebf'
)) {
if ($poemDescription -notmatch $fact) { throw "GenerationPoemBatchBody description missing: $fact" }
}
if ([string]$batch.properties.disableMissing.description -notmatch '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55') {
throw 'GenerationPoemBatchBody.disableMissing no longer guarantees history retention'
}
foreach ($path in @(
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save'
)) {
$bodyRef = $document.paths.$path.post.requestBody.content.'application/json'.schema.'$ref'
if ($bodyRef -ne '#/components/schemas/GenerationPoemBatchBody') {
throw "Batch endpoint request body drifted: $path -> $bodyRef"
}
}
$poemView = $document.components.schemas.GenerationPoemView
$poemViewFields = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
generationText = @{ Type = 'string'; Format = $null }
status = @{ Type = 'string'; Format = $null }
}
foreach ($field in $poemViewFields.Keys) {
$property = $poemView.properties.PSObject.Properties[$field].Value
if (-not $property) { throw "GenerationPoemView field missing: $field" }
if ($property.type -ne $poemViewFields[$field].Type -or $property.format -ne $poemViewFields[$field].Format) {
throw "GenerationPoemView.$field type/format drifted"
}
}
if ([string]$poemView.properties.status.description -notmatch '0\u6b63\u5e38\uff0c1\u505c\u7528') {
throw 'GenerationPoemView status 0/1 contract drifted'
}
$normalList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
if ([string]$normalList.description -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or [string]$normalList.description -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001') {
throw 'Normal generation-poem list capability or active-only projection drifted'
}
$managementList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'.get
if ([string]$managementList.description -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or [string]$managementList.description -notmatch '\u6b63\u5e38\u548c\u505c\u7528') {
throw 'Management generation-poem list capability or status projection drifted'
}
foreach ($operation in @($normalList, $managementList)) {
if ($operation.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemListResult') {
throw 'Generation-poem list operation no longer returns GenerationPoemListResult'
}
}
if ($document.components.responses.GenerationPoemListResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemList') {
throw 'GenerationPoemListResult wrapper drifted'
}
$poemListData = $document.components.schemas.RGenerationPoemList.properties.data
if ($poemListData.type -ne 'array' -or $poemListData.items.'$ref' -ne '#/components/schemas/GenerationPoemView') {
throw 'RGenerationPoemList.data no longer exposes GenerationPoemView items'
}
$previewPath = '/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview'
if ($document.paths.$previewPath.post.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemBatchPreviewResult') {
throw 'Generation-poem preview response no longer uses GenerationPoemBatchPreviewResult'
}
if ($document.components.responses.GenerationPoemBatchPreviewResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemBatchPreview') {
throw 'GenerationPoemBatchPreviewResult wrapper drifted'
}
if ($document.components.schemas.RGenerationPoemBatchPreview.properties.data.'$ref' -ne '#/components/schemas/GenerationPoemBatchPreviewView') {
throw 'RGenerationPoemBatchPreview.data no longer exposes the preview view'
}
$previewView = $document.components.schemas.GenerationPoemBatchPreviewView
if ($previewView.properties.items.type -ne 'array' -or $previewView.properties.items.items.'$ref' -ne '#/components/schemas/GenerationPoemBatchItemView') {
throw 'GenerationPoemBatchPreviewView.items contract drifted'
}
$previewItem = $document.components.schemas.GenerationPoemBatchItemView
$previewItemExpected = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
oldGenerationText = @{ Type = 'string'; Format = $null }
newGenerationText = @{ Type = 'string'; Format = $null }
oldStatus = @{ Type = 'string'; Format = $null }
newStatus = @{ Type = 'string'; Format = $null }
action = @{ Type = 'string'; Format = $null }
warning = @{ Type = 'string'; Format = $null }
}
$previewItemFields = @($previewItem.properties.PSObject.Properties.Name | Sort-Object)
if (($previewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
throw "GenerationPoemBatchItemView fields drifted: $($previewItemFields -join ',')"
}
foreach ($field in $previewItemExpected.Keys) {
$property = $previewItem.properties.PSObject.Properties[$field].Value
if ($property.type -ne $previewItemExpected[$field].Type -or $property.format -ne $previewItemExpected[$field].Format) {
throw "GenerationPoemBatchItemView.$field type/format drifted"
}
}
if ([string]$previewItem.properties.action.description -notmatch 'create.*update.*keep.*disable') {
throw 'GenerationPoemBatchItemView.action dictionary drifted'
}
$yamlBatch = Get-YamlBlock ' GenerationPoemBatchBody:' '^ [A-Za-z0-9_]+:$'
if ($yamlBatch -notmatch '(?m)^ required:\n - poemText\n properties:$') {
throw 'YAML GenerationPoemBatchBody required fields drifted'
}
$yamlBatchFields = @([regex]::Matches($yamlBatch, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
if (($yamlBatchFields -join ',') -ne 'disableMissing,poemText') {
throw "YAML GenerationPoemBatchBody fields drifted: $($yamlBatchFields -join ',')"
}
$yamlPoemText = Get-YamlNestedBlock $yamlBatch ' poemText:' '^ [A-Za-z0-9_]+:$'
foreach ($pattern in @('(?m)^ type: string$', '(?m)^ maxLength: 26000$', '\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26', '\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3')) {
if ($yamlPoemText -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.poemText drifted: $pattern" }
}
$yamlDisableMissing = Get-YamlNestedBlock $yamlBatch ' disableMissing:' '^ [A-Za-z0-9_]+:$'
foreach ($pattern in @('(?m)^ type: boolean$', '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55')) {
if ($yamlDisableMissing -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.disableMissing drifted: $pattern" }
}
$yamlPoemView = Get-YamlBlock ' GenerationPoemView:' '^ [A-Za-z0-9_]+:$'
$yamlPoemViewExpected = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
generationText = @{ Type = 'string'; Format = $null }
status = @{ Type = 'string'; Format = $null }
}
foreach ($field in $yamlPoemViewExpected.Keys) {
$fieldBlock = Get-YamlNestedBlock $yamlPoemView " $field`:" '^ [A-Za-z0-9_]+:$'
if ($fieldBlock -notmatch "(?m)^ type: $($yamlPoemViewExpected[$field].Type)$") {
throw "YAML GenerationPoemView.$field type drifted"
}
$format = $yamlPoemViewExpected[$field].Format
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
throw "YAML GenerationPoemView.$field format drifted"
}
}
$yamlStatus = Get-YamlNestedBlock $yamlPoemView ' status:' '^ [A-Za-z0-9_]+:$'
if ($yamlStatus -notmatch 'description:.*0\u6b63\u5e38\uff0c1\u505c\u7528' -or
$yamlStatus -notmatch '(?m)^ type: string$') {
throw 'YAML GenerationPoemView.status contract drifted'
}
$yamlPreviewPath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview:' '^ /.*:$'
if ($yamlPreviewPath -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemBatchPreviewResult'")) {
throw 'YAML generation-poem preview response drifted'
}
$yamlPreviewResponse = Get-YamlBlock ' GenerationPoemBatchPreviewResult:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemBatchPreview'")) {
throw 'YAML GenerationPoemBatchPreviewResult wrapper drifted'
}
$yamlPreviewWrapper = Get-YamlBlock ' RGenerationPoemBatchPreview:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewWrapper -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchPreviewView'")) {
throw 'YAML RGenerationPoemBatchPreview.data drifted'
}
$yamlPreviewView = Get-YamlBlock ' GenerationPoemBatchPreviewView:' '^ [A-Za-z0-9_]+:$'
$yamlPreviewItems = Get-YamlNestedBlock $yamlPreviewView ' items:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewItems -notmatch '(?m)^ type: array$' -or $yamlPreviewItems -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchItemView'")) {
throw 'YAML GenerationPoemBatchPreviewView.items drifted'
}
$yamlPreviewItem = Get-YamlBlock ' GenerationPoemBatchItemView:' '^ [A-Za-z0-9_]+:$'
$yamlPreviewItemFields = @([regex]::Matches($yamlPreviewItem, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
if (($yamlPreviewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
throw "YAML GenerationPoemBatchItemView fields drifted: $($yamlPreviewItemFields -join ',')"
}
foreach ($field in $previewItemExpected.Keys) {
$fieldBlock = Get-YamlNestedBlock $yamlPreviewItem " $field`:" '^ [A-Za-z0-9_]+:$'
if ($fieldBlock -notmatch "(?m)^ type: $($previewItemExpected[$field].Type)$") {
throw "YAML GenerationPoemBatchItemView.$field type drifted"
}
$format = $previewItemExpected[$field].Format
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
throw "YAML GenerationPoemBatchItemView.$field format drifted"
}
}
$yamlPreviewAction = Get-YamlNestedBlock $yamlPreviewItem ' action:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewAction -notmatch 'create.*update.*keep.*disable') {
throw 'YAML GenerationPoemBatchItemView.action dictionary drifted'
}
$yamlNormalList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems:' '^ /.*:$'
$yamlNormalGet = Get-YamlNestedBlock $yamlNormalList ' get:' '^ [a-z]+:$'
if ($yamlNormalGet -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or $yamlNormalGet -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001' -or
$yamlNormalGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
throw 'YAML normal generation-poem capability or projection drifted'
}
$yamlManagementList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/management:' '^ /.*:$'
$yamlManagementGet = Get-YamlNestedBlock $yamlManagementList ' get:' '^ [a-z]+:$'
if ($yamlManagementGet -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or $yamlManagementGet -notmatch '\u6b63\u5e38\u548c\u505c\u7528' -or
$yamlManagementGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
throw 'YAML management generation-poem capability or projection drifted'
}
$yamlPoemListResponse = Get-YamlBlock ' GenerationPoemListResult:' '^ [A-Za-z0-9_]+:$'
if ($yamlPoemListResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemList'")) {
throw 'YAML GenerationPoemListResult wrapper drifted'
}
$yamlPoemListWrapper = Get-YamlBlock ' RGenerationPoemList:' '^ [A-Za-z0-9_]+:$'
$yamlPoemListData = Get-YamlNestedBlock $yamlPoemListWrapper ' data:' '^ [A-Za-z0-9_]+:$'
if ($yamlPoemListData -notmatch '(?m)^ type: array$' -or
$yamlPoemListData -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemView'")) {
throw 'YAML RGenerationPoemList.data drifted'
}
$yamlPreviewPost = Get-YamlNestedBlock $yamlPreviewPath ' post:' '^ [a-z]+:$'
if ($yamlPreviewPost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
throw 'YAML generation-poem preview request body drifted'
}
$yamlSavePath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save:' '^ /.*:$'
$yamlSavePost = Get-YamlNestedBlock $yamlSavePath ' post:' '^ [a-z]+:$'
if ($yamlSavePost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
throw 'YAML generation-poem save request body drifted'
}
foreach ($constant in @(
'MAX_GENERATION_COUNT = 500',
'MAX_GENERATION_TEXT_LENGTH = 50',
'MAX_GENERATION_POEM_INPUT_LENGTH = 26000',
"ACTIVE: '0'",
"DISABLED: '1'"
)) {
if (-not $helper.Contains($constant)) { throw "Generation poem helper is not synchronized with OpenAPI: $constant" }
}
foreach ($schemaName in @('GenealogyJoinApplyBody', 'GenealogyJoinAuditBody', 'GenerationPoemBatchBody', 'GenerationPoemView')) {
if (-not $yaml.Contains(" $schemaName`:")) { throw "G-series YAML schema missing: $schemaName" }
}
# G03 原子创建、共享访问预设与旧 DTO 删除只由 g03-bootstrap-openapi-contract.ps1 管理。
# G11 设置 PUT、dirty-only body、版本 CAS、响应与错误只由 g11-settings-openapi-contract.ps1 管理。
# 普通加入申请只由 join-application-openapi-contract.ps1 管理;邀请码直入只由 invite-ticket-openapi-contract.ps1 管理。
# G12 字辈聚合读写、词法行身份、版本 CAS、候选集合与旧批量入口删除只由
# g12-generation-poem-openapi-contract.ps1 管理。本通用门禁不得重新锁定 poemText、0/1 状态、
# integer/int64 poemId、preview、batch/save、management 或逐行写入口,避免形成相反合同 owner。
Write-Output 'G-SERIES-OPENAPI-CONTRACT PASS'
+3 -2
View File
@@ -4,7 +4,7 @@ $page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-geneal
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
$context = Get-Content -LiteralPath (Join-Path $root 'utils/genealogy-context.js') -Raw -Encoding UTF8
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'getGenealogyFixtureAccess(item.id).accessRole === "owner"', 'getGenealogyFixtureAccess(item.id).accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=')) {
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'item.accessRole === "owner"', 'item.accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=', 'appApi.getMyGenealogies', 'createRequestController', 'listRequestController.abort()', 'onUnload')) {
if (-not $page.Contains($token)) { throw "G01 context contract missing: $token" }
}
foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId', 'getCurrentGenealogyId: readCurrentGenealogyId', 'invalidateCurrentGenealogyId:', 'isCurrentGenealogyInvalidated:', 'new Set(normalizedIds).size !== normalizedIds.length', 'normalizedIds.includes(normalizedPreferredId)', 'normalizedIds.includes(storedId)', 'if (normalizedPreferredId)', 'if (storedId)')) {
@@ -12,7 +12,8 @@ foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId'
}
if ($page.Contains('Number(query?.genealogyId)')) { throw 'G01 must not coerce a genealogy ID to Number' }
if ($page -match 'currentGenealogy\s*=\s*computed\([\s\S]*?\|\|\s*availableGenealogies\.value\[0\]') { throw 'G01 must not silently fall back to the first genealogy after permission loss' }
if ($page -notmatch '(?s)const retryLoad = \(\) => \{.*?reconcilePageGenealogyContext\(\)') { throw 'G01 context failure retry must rerun reconciliation instead of exposing a dead button' }
if ($page -notmatch '(?s)const retryLoad = \(\) => \{\s*loadGenealogies\(\)') { throw 'G01 retry must rerun the remote list owner instead of exposing a dead button' }
if ($page -match '@/data/mock\.js|getGenealogyFixtureAccess|listNotificationFixtures') { throw 'G01 remote page must not retain fixture data owners' }
foreach ($membership in @("membership: 'created'", "membership: 'joined'")) {
if (-not $mock.Contains($membership)) { throw "Genealogy mock ownership missing: $membership" }
}
+206
View File
@@ -0,0 +1,206 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = {
getToken: () => "session-1",
saveToken() {},
};
`;
const requests = [];
let nextResponse;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = {
statusCode: 200,
data: {
code: 200,
msg: "操作成功",
data: [
{
genealogyId: 900001001,
genealogyName: "汤氏家谱",
surname: "汤",
ancestralHall: "敦睦堂",
regionFullName: "河南省洛阳市",
memberCount: 158,
roleType: "OWNER",
canManage: true,
canEditContent: true,
},
{
genealogyId: 900001002,
genealogyName: "汤氏宗谱",
regionName: "山东省济宁市",
memberCount: 286,
roleType: "MEMBER",
canManage: false,
canEditContent: false,
},
],
},
};
const result = await appApi.getMyGenealogies();
assert.deepStrictEqual(result, [
{
id: "900001001",
name: "汤氏家谱",
surname: "汤",
hall: "敦睦堂",
location: "河南省洛阳市",
memberCount: 158,
accessRole: "owner",
canManage: true,
canEditContent: true,
},
{
id: "900001002",
name: "汤氏宗谱",
surname: "",
hall: "",
location: "山东省济宁市",
memberCount: 286,
accessRole: "member",
canManage: false,
canEditContent: false,
},
]);
assert.strictEqual(requests.length, 1);
assert.strictEqual(requests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/mine");
assert.strictEqual(requests[0].method, "GET");
assert.strictEqual(requests[0].header.clientid, "client-1");
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{
genealogyId: 900001003,
genealogyName: "只读家谱",
memberCount: 1,
roleType: "OWNER",
canManage: false,
canEditContent: false,
}],
},
};
const explicitCapabilities = await appApi.getMyGenealogies();
assert.strictEqual(explicitCapabilities[0].accessRole, "member");
assert.strictEqual(explicitCapabilities[0].canEditContent, false);
for (const invalidItem of [
{
genealogyId: 900001004,
genealogyName: "缺少权限",
memberCount: 1,
canEditContent: false,
},
{
genealogyId: 900001005,
genealogyName: "错用人物数",
personCount: 9,
canManage: false,
canEditContent: false,
},
]) {
nextResponse = {
statusCode: 200,
data: { code: 200, data: [invalidItem] },
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
}
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [
{
genealogyId: 900001006,
genealogyName: "重复一",
memberCount: 1,
canManage: false,
canEditContent: false,
},
{
genealogyId: 900001006,
genealogyName: "重复二",
memberCount: 2,
canManage: false,
canEditContent: false,
},
],
},
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{ genealogyId: 9007199254740992, genealogyName: "失真家谱" }],
},
};
await assert.rejects(
appApi.getMyGenealogies(),
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+1 -1
View File
@@ -7,7 +7,7 @@ function Assert-Contains([string]$Content, [string]$Pattern, [string]$Message) {
Assert-Contains $page 'v-for="item in visibleShortcuts"' 'G01 must render the membership-filtered shortcut list'
Assert-Contains $page '{{ currentRoleLabel }}' 'G01 must render the membership-derived role label'
Assert-Contains $page 'getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole' 'G01 must consume the shared fixture access resolver'
Assert-Contains $page 'currentGenealogy.value?.accessRole === "owner"' 'G01 must consume the normalized remote role'
Assert-Contains $page 'item.key !== "applications"' 'G01 must remove application review for joined members'
$selectedBinding = ':selected="item.id === currentGenealogy.id"'
if (([regex]::Matches($page, [regex]::Escape($selectedBinding))).Count -ne 2) {
+51 -24
View File
@@ -7,7 +7,6 @@ $issues = New-Object System.Collections.Generic.List[string]
$createPath = '/genealogy/app/genealogies'
$statusPath = '/genealogy/app/genealogy-bootstrap-operations/{operationKey}'
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
$regionSearchPath = '/genealogy/app/region/search'
$personPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons'
$personDetailPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}'
@@ -30,6 +29,35 @@ function Test-IsJsonBoolean {
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsNonNullable {
param([object]$Schema)
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-AllowedSchemaKeywords {
param([object]$Schema, [string]$Label, [string[]]$Allowed)
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureSchemaRef {
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
if (-not $Schema) { return $false }
$properties = @($Schema.PSObject.Properties.Name)
$actualRef = [string]$Schema.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
return $false
}
return $true
}
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
@@ -180,8 +208,8 @@ function Assert-RequestBodyRef {
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
if (-not (Test-IsJsonBoolean $Operation.requestBody.required $true) -or -not $media) {
Add-Issue "JSON $Label must require an application/json body"
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
Add-Issue "JSON $Label request body must use $ExpectedRef"
} else {
[void](Test-IsPureSchemaRef $media.Value.schema $ExpectedRef "$Label request body")
}
}
@@ -205,7 +233,8 @@ function Assert-ExactObject {
} elseif ($requiredProperty) {
Test-IsJsonArray $Schema.required
} else { $true }
if ($Schema.type -ne 'object' -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
-not $requiredShapeValid -or
($actualFields -join ',') -ne ($expectedFields -join ',') -or
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
@@ -217,16 +246,18 @@ function Assert-ExactObject {
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
Add-Issue "JSON $Label maxProperties must be $MaxProperties"
}
$allowedKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
if ($MinProperties -ge 0) { $allowedKeywords += 'minProperties' }
if ($MaxProperties -ge 0) { $allowedKeywords += 'maxProperties' }
Assert-AllowedSchemaKeywords $Schema $Label $allowedKeywords
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
if (-not $property) { Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef"; return }
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Assert-ErrorSchema {
@@ -364,7 +395,6 @@ function Assert-AllDeclaredResponsesPrivateJson {
$createOperation = Get-Operation $createPath 'post'
$statusOperation = Get-Operation $statusPath 'get'
$settingsOperation = Get-Operation $settingsPath 'put'
$regionOperation = Get-Operation $regionSearchPath 'get'
$personOperation = Get-Operation $personPath 'post'
$personUpdateOperation = Get-Operation $personDetailPath 'put'
@@ -379,7 +409,6 @@ if ($document.paths.PSObject.Properties['/genealogy/region/search']) {
foreach ($entry in @(
[pscustomobject]@{ Operation = $createOperation; Label = "POST $createPath"; Path = $createPath },
[pscustomobject]@{ Operation = $statusOperation; Label = "GET $statusPath"; Path = $statusPath },
[pscustomobject]@{ Operation = $settingsOperation; Label = "PUT $settingsPath"; Path = $settingsPath },
[pscustomobject]@{ Operation = $regionOperation; Label = "GET $regionSearchPath"; Path = $regionSearchPath },
[pscustomobject]@{ Operation = $personOperation; Label = "POST $personPath"; Path = $personPath },
[pscustomobject]@{ Operation = $personUpdateOperation; Label = "PUT $personDetailPath"; Path = $personDetailPath },
@@ -391,7 +420,6 @@ foreach ($entry in @(
}
Assert-RequestBodyRef $createOperation "POST $createPath" '#/components/schemas/AppGenealogyBootstrapBody'
Assert-RequestBodyRef $settingsOperation "PUT $settingsPath" '#/components/schemas/AppGenealogySettingsUpdateBody'
$idempotencyHeader = Get-Parameter $createOperation 'Idempotency-Key' 'header' $createPath
if ($idempotencyHeader) {
@@ -556,7 +584,6 @@ $rootPersonBody = Get-Schema 'AppGenealogyRootPersonBody'
$operationKey = Get-Schema 'GenealogyBootstrapOperationKey'
$regionCode = Get-Schema 'GenealogyRegionCode'
$accessPreset = Get-Schema 'GenealogyAccessPreset'
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
$bootstrapResult = Get-Schema 'GenealogyBootstrapResult'
$operationStatus = Get-Schema 'GenealogyBootstrapOperationStatus'
$pendingStatus = Get-Schema 'GenealogyBootstrapPendingStatus'
@@ -573,7 +600,7 @@ Assert-ExactObject $bootstrapBody 'AppGenealogyBootstrapBody' @(
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'rootPerson' '#/components/schemas/AppGenealogyRootPersonBody'
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'regionCode' '#/components/schemas/GenealogyRegionCode'
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' 1 24
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' '#/components/schemas/GenealogyName'
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'surname' 1 4
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'ancestralHall' 1 12
@@ -631,17 +658,20 @@ if ($regionCode) {
if ($accessPreset) {
$values = @($accessPreset.enum | Sort-Object)
$expected = @('MEMBER_ONLY', 'PUBLIC_APPLY') | Sort-Object
$nullable = $accessPreset.PSObject.Properties['nullable']
if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or
($values -join ',') -ne ($expected -join ',')) {
Add-Issue 'JSON GenealogyAccessPreset must contain only MEMBER_ONLY/PUBLIC_APPLY'
($values -join ',') -ne ($expected -join ',') -or
($nullable -and -not (Test-IsJsonBoolean $nullable.Value $false))) {
Add-Issue 'JSON GenealogyAccessPreset must be non-null and contain only MEMBER_ONLY/PUBLIC_APPLY'
}
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const')) {
if ($accessPreset.PSObject.Properties[$keyword]) {
Add-Issue "JSON GenealogyAccessPreset must not define conflicting schema keyword: $keyword"
}
}
Assert-AllowedSchemaKeywords $accessPreset 'GenealogyAccessPreset' @('type', 'enum', 'nullable')
}
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
foreach ($legacySchema in @('GenealogyCreateBody', 'AppGenealogyCreateBody', 'GenealogyUpdateBody', 'AppGenealogyUpdateBody')) {
if ($document.components.schemas.PSObject.Properties[$legacySchema]) {
Add-Issue "JSON legacy schema must be removed in the same migration: $legacySchema"
@@ -673,9 +703,7 @@ foreach ($schemaProperty in $document.components.schemas.PSObject.Properties) {
}
}
foreach ($presetDefinition in @(Get-ComposedPropertyDefinitions $schemaProperty.Value 'accessPreset' @{})) {
if ([string]$presetDefinition.'$ref' -ne '#/components/schemas/GenealogyAccessPreset') {
Add-Issue "JSON $($schemaProperty.Name).accessPreset must reference GenealogyAccessPreset"
}
[void](Test-IsPureSchemaRef $presetDefinition '#/components/schemas/GenealogyAccessPreset' "$($schemaProperty.Name).accessPreset")
}
}
@@ -967,7 +995,6 @@ foreach ($status in $statusErrorRefs.Keys) {
Assert-RetryAfter $statusResponses['404'] "GET $statusPath" '404'
Assert-RetryAfter $statusResponses['429'] "GET $statusPath" '429'
Assert-AllDeclaredResponsesPrivateJson $settingsOperation "PUT $settingsPath"
Assert-AllDeclaredResponsesPrivateJson $personOperation "POST $personPath"
Assert-AllDeclaredResponsesPrivateJson $personUpdateOperation "PUT $personDetailPath"
Assert-AllDeclaredResponsesPrivateJson $personDeleteOperation "DELETE $personDetailPath"
+2 -4
View File
@@ -2,12 +2,9 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding UTF8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
foreach ($token in @('findGenealogyFixture', 'getGenealogyFixtureAccess', 'getGenealogyAccessPresetLabel', 'access.canView', 'ancestorName', 'updatedAt')) {
foreach ($token in @('appApi.getOverview', 'createRequestController', 'isRequestCancelled', 'getGenealogyAccessPresetLabel', 'result.accessRole', 'overviewRequestController.abort()', 'onUnload')) {
if (-not $page.Contains($token)) { throw "G05 data ownership contract missing: $token" }
}
if ($page -notmatch '(?s)const access = getGenealogyFixtureAccess\(genealogyId\.value\);\s*if \(!access\.canView\) \{\s*overviewState\.value = "no-permission";\s*return;\s*\}') {
throw 'G05 must fail closed before exposing a fixture that the current role cannot view'
}
foreach ($token in @('export const publicGenealogies', 'export const createLocalGenealogyPreview', 'export const updateLocalGenealogyPreviewAncestor', 'export const findGenealogyFixture', 'export const getGenealogyFixtureAccess', 'fixture?.localPreview', "memberFixture?.membership === 'created'", "memberFixture?.membership === 'joined'")) {
if (-not $mock.Contains($token)) { throw "Shared genealogy fixture ownership missing: $token" }
}
@@ -15,6 +12,7 @@ foreach ($forbidden in @('overviewFixtures', 'query.genealogyName', 'query.role'
if ($page.Contains($forbidden)) { throw "G05 retains duplicate or untrusted data owner: $forbidden" }
}
if ($page.Contains('getGenealogyVisibilityLabel') -or $page.Contains('genealogy.visibility')) { throw 'G05 retains the deleted visibility-only contract' }
if ($page -match '@/data/mock\.js|findGenealogyFixture|getGenealogyFixtureAccess') { throw 'G05 remote overview must not retain fixture owners' }
if ($mock -match "(?s)const localCreatedGenealogy = \{.*?membership:\s*'created'") { throw 'Local-created URL fixture must never grant owner membership' }
if ($page.Contains('class="overview-action-lock"')) { throw 'G05 ordinary members must not see inert management lock cards' }
Write-Output 'G05-DATA-OWNERSHIP-CONTRACT PASS'
+120
View File
@@ -0,0 +1,120 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({
MEMBER_ONLY: "MEMBER_ONLY",
PUBLIC_APPLY: "PUBLIC_APPLY",
});
const fromApiGenealogyAccess = ({ visibility, joinMode } = {}) =>
visibility === "1" && joinMode === "1"
? "PUBLIC_APPLY"
: visibility === "2" && joinMode === "0"
? "MEMBER_ONLY"
: null;
const session = {
getToken: () => "session-1",
saveToken() {},
};
`;
const requests = [];
let nextResponse;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: {
genealogyId: 900001001,
genealogyName: "汤氏家谱",
surname: "汤",
ancestralHall: "敦睦堂",
regionFullName: "河南省洛阳市",
intro: "敦亲睦族,敬祖传家。",
visibility: "1",
joinMode: "1",
memberCount: 158,
personCount: 146,
roleType: "OWNER",
canManage: true,
canEditContent: true,
joinTime: "2026-07-23T08:00:00+08:00",
},
},
};
const result = await appApi.getOverview("900001001");
assert.deepStrictEqual(result, {
id: "900001001",
name: "汤氏家谱",
surname: "汤",
hall: "敦睦堂",
location: "河南省洛阳市",
memberCount: 158,
personCount: 146,
accessPreset: "PUBLIC_APPLY",
accessRole: "owner",
canManage: true,
canEditContent: true,
intro: "敦亲睦族,敬祖传家。",
joinTime: "2026-07-23T08:00:00+08:00",
});
assert.strictEqual(
requests[0].url,
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/900001001/overview",
);
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000);
await assert.rejects(
appApi.getOverview("../other"),
(error) => error?.code === "GENEALOGY_ID_INVALID",
);
process.stdout.write("G05-OVERVIEW-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+4 -2
View File
@@ -25,7 +25,9 @@ foreach ($required in @(
"query.genealogyId",
'const viewMode = ref("member")',
'const accessRole = ref("guest")',
'findGenealogyFixture',
'appApi.getOverview',
'createRequestController',
'isRequestCancelled',
'overview-ready',
'overview-public',
'overview-state--empty',
@@ -36,7 +38,7 @@ foreach ($required in @(
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
}
Assert-Contains $profiles 'g05-overview-surface.png' 'Adaptive G05 surface profile must own the overview asset'
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
foreach ($forbidden in @("@/data/mock.js", 'findGenealogyFixture', 'getGenealogyFixtureAccess', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($g05 -match [regex]::Escape($forbidden)) { throw "G05 retains forbidden implementation: $forbidden" }
}
+874
View File
@@ -0,0 +1,874 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$genealogyNamePattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\n\u0000-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
$genealogyIntroPattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Test-IsJsonArray {
param([object]$Value)
return $null -ne $Value -and $Value.GetType().IsArray
}
function Test-IsJsonBoolean {
param([object]$Value, [bool]$Expected)
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsNonNullable {
param([object]$Schema)
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords {
param(
[object]$Schema,
[string]$Label,
[string[]]$Allowed = @()
)
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords {
param([object]$Schema, [string]$Label, [string[]]$Allowed)
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureSchemaRef {
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
if (-not $Schema) { return $false }
$properties = @($Schema.PSObject.Properties.Name)
$actualRef = [string]$Schema.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
return $true
}
function Get-LocalComponentName {
param([string]$Ref, [string]$Section, [string]$Label)
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
return $null
}
return $operationProperty.Value
}
function Resolve-Parameter {
param([object]$Parameter, [string]$Label)
if (-not $Parameter) { return $null }
if (-not $Parameter.'$ref') { return $Parameter }
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
if (-not $name) { return $null }
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing parameter owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Response {
param([object]$Response, [string]$Label)
if (-not $Response) { return $null }
if (-not $Response.'$ref') { return $Response }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($responseRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing response owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Header {
param([object]$Header, [string]$Label)
if (-not $Header) { return $null }
if (-not $Header.'$ref') { return $Header }
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($headerRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
if (-not $name) { return $null }
$owner = $document.components.headers.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing header owner: $name"
return $null
}
return $owner.Value
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing response: $Status"
return $null
}
return Resolve-Response $property.Value "$Label $Status"
}
function Get-JsonResponseRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = @($Response.content.PSObject.Properties)
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
Add-Issue "JSON $Label $Status must expose only application/json"
return ''
}
$schema = $media[0].Value.schema
$ref = [string]$schema.'$ref'
if (-not $ref) {
Add-Issue "JSON $Label $Status must use a component schema ref"
} else {
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
$schemaKeywords = @($schema.PSObject.Properties.Name)
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
}
}
return $ref
}
function Assert-ExactResponseSet {
param([object]$Operation, [string]$Label, [string[]]$Expected)
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -ne ($wanted -join ',')) {
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
if (-not $header) { return }
$values = @($header.schema.enum)
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
[string]$values[0] -cne 'private, no-store') {
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
}
function Assert-RequiredResponseHeaders {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
if ('Cache-Control' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
}
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Retry-After"
}
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
Add-Issue "JSON $Label $Status must not publish ETag; settings concurrency has one If-Match/settingsVersion owner"
}
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
if ($unexpectedHeaders.Count -gt 0) {
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
}
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
if (-not $property) { continue }
$header = Resolve-Header $property.Value "$Label $Status $traceName"
if (-not $header) { continue }
foreach ($headerProperty in @($header.PSObject.Properties)) {
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
}
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
continue
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
}
}
function Assert-RetryAfter {
param([object]$Response, [string]$Label)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label must document Retry-After"
return
}
$header = Resolve-Header $property.Value "$Label Retry-After"
if (-not $header) { return }
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
[int]$header.schema.minimum -ne 1 -or
[int]$header.schema.maximum -lt 1 -or [int]$header.schema.maximum -gt 300) {
Add-Issue "JSON $Label Retry-After must be an integer in a bounded 1..300 second range"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
}
function Assert-SaToken {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = @($Operation.security)
if ($requirements.Count -ne 1) {
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
return
}
$names = @($requirements[0].PSObject.Properties.Name)
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
Add-Issue "JSON $Label must require only SaToken"
}
}
function Get-OperationParameters {
param([string]$Path, [object]$Operation, [string]$Label)
$parameters = @()
$pathProperty = $document.paths.PSObject.Properties[$Path]
if ($pathProperty -and $pathProperty.Value.parameters) {
foreach ($parameter in @($pathProperty.Value.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label path parameter"
if ($resolved) { $parameters += $resolved }
}
}
if ($Operation -and $Operation.parameters) {
foreach ($parameter in @($Operation.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
if ($resolved) { $parameters += $resolved }
}
}
return $parameters
}
function Get-Parameter {
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
if ($matches.Count -ne 1) {
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
return $null
}
return $matches[0]
}
function Assert-ExactParameters {
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -ne ($wanted -join ',')) {
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Assert-ExactObject {
param(
[object]$Schema,
[string]$Name,
[string[]]$Properties,
[string[]]$Required,
[int]$MinProperties = -1,
[int]$MaxProperties = -1
)
if (-not $Schema) { return }
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$expectedProperties = @($Properties | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
($actualProperties -join ',') -ne ($expectedProperties -join ',') -or
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
Add-Issue "JSON $Name must be a closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
$allowedObjectKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
if ($MinProperties -ge 0) { $allowedObjectKeywords += 'minProperties' }
if ($MaxProperties -ge 0) { $allowedObjectKeywords += 'maxProperties' }
Assert-AllowedSchemaKeywords $Schema $Name $allowedObjectKeywords
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
Add-Issue "JSON $Name.minProperties must be $MinProperties"
}
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
Add-Issue "JSON $Name.maxProperties must be $MaxProperties"
}
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return
}
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Assert-FixedError {
param(
[object]$Schema,
[string]$Name,
[int]$Status,
[string]$BusinessCode,
[switch]$Current
)
$properties = @('code', 'businessCode')
if ($Current) { $properties += 'current' }
Assert-ExactObject $Schema $Name $properties $properties
if (-not $Schema) { return }
if ($Schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $Schema.properties.code) -or
-not (Test-IsJsonArray $Schema.properties.code.enum) -or
@($Schema.properties.code.enum).Count -ne 1 -or
@($Schema.properties.code.enum)[0] -ne $Status -or
$Schema.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $Schema.properties.businessCode) -or
-not (Test-IsJsonArray $Schema.properties.businessCode.enum) -or
@($Schema.properties.businessCode.enum).Count -ne 1 -or
@($Schema.properties.businessCode.enum)[0] -ne $BusinessCode) {
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
}
Assert-NoConflictingSchemaKeywords $Schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $Schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
if ($Current) { [void](Test-IsPureSchemaRef $Schema.properties.current '#/components/schemas/AppGenealogyVo' "$Name.current") }
}
function Get-RequestBodySchemas {
param([object]$Operation, [string]$Label)
if (-not $Operation -or -not $Operation.requestBody) { return @() }
$requestBody = $Operation.requestBody
if ($requestBody.'$ref') {
$name = Get-LocalComponentName ([string]$requestBody.'$ref') 'requestBodies' "$Label request body"
if (-not $name) { return @() }
$owner = $document.components.requestBodies.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing request body owner: $name"
return @()
}
$requestBody = $owner.Value
}
if (-not $requestBody.content) { return @() }
return @($requestBody.content.PSObject.Properties | ForEach-Object { $_.Value.schema } | Where-Object { $_ })
}
function Test-SchemaContainsSettingsContract {
param(
[object]$Schema,
[hashtable]$Seen,
[string]$Label,
[bool]$AllowInlineIntro,
[bool]$AtRequestRoot = $true
)
if (-not $Schema) { return $false }
$ref = [string]$Schema.'$ref'
if ($ref) {
$leaf = @($ref -split '/')[-1]
if ($leaf -ceq 'AppGenealogySettingsUpdateBody') {
if ($ref -cne '#/components/schemas/AppGenealogySettingsUpdateBody') {
[void](Get-LocalComponentName $ref 'schemas' $Label)
}
return $true
}
$name = Get-LocalComponentName $ref 'schemas' $Label
if (-not $name) { return $false }
if ($Seen.ContainsKey($name)) { return $false }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return $false
}
return Test-SchemaContainsSettingsContract $owner.Value $Seen "$Label -> $name" $AllowInlineIntro $AtRequestRoot
}
if ($Schema.properties) {
$sharedOwners = @{
genealogyName = '#/components/schemas/GenealogyName'
intro = '#/components/schemas/GenealogyIntro'
accessPreset = '#/components/schemas/GenealogyAccessPreset'
}
foreach ($field in $sharedOwners.Keys) {
$property = $Schema.properties.PSObject.Properties[$field]
if (-not $property) { continue }
$propertyKeywords = @($property.Value.PSObject.Properties.Name)
if ($propertyKeywords.Count -eq 1 -and $propertyKeywords[0] -ceq '$ref' -and
[string]$property.Value.'$ref' -ceq $sharedOwners[$field]) {
return $true
}
if ($AtRequestRoot -and ($field -ne 'intro' -or $AllowInlineIntro)) { return $true }
}
}
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
foreach ($branch in @($Schema.$keyword)) {
if (Test-SchemaContainsSettingsContract $branch $Seen "$Label $keyword" $AllowInlineIntro $AtRequestRoot) { return $true }
}
}
if ($Schema.items -and (Test-SchemaContainsSettingsContract $Schema.items $Seen "$Label items" $AllowInlineIntro $false)) { return $true }
if ($Schema.properties) {
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if (Test-SchemaContainsSettingsContract $property.Value $Seen "$Label.$($property.Name)" $AllowInlineIntro $false) { return $true }
}
}
return $false
}
function Assert-GlobalSettingsWriteOwner {
$methods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
$writeMethods = @('post', 'put', 'patch', 'delete')
$settingsOwners = New-Object System.Collections.Generic.List[string]
$operationIdOwners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $methods })) {
$method = [string]$methodProperty.Name
$operation = $methodProperty.Value
$label = "$($method.ToUpperInvariant()) $($pathProperty.Name)"
if ([string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
$operationIdOwners.Add($label)
}
if ($method -notin $writeMethods) { continue }
$isSettingsOwner = $method -eq 'put' -and $pathProperty.Name -ceq $settingsPath
$settingsSemanticPath = $pathProperty.Name -match '(?i)/genealogies(?:/\{[^}]+\})?/settings(?:[-_/]|$)'
if ($settingsSemanticPath -or
[string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
$isSettingsOwner = $true
}
$isGenealogyCreate = $method -eq 'post' -and
$pathProperty.Name -ceq '/genealogy/app/genealogies'
if (-not $isGenealogyCreate) {
if ($operation.requestBody -and $operation.requestBody.'$ref') {
$requestBodyLeaf = @(([string]$operation.requestBody.'$ref') -split '/')[-1]
if ($requestBodyLeaf -ceq 'AppGenealogySettingsUpdateBody') {
$isSettingsOwner = $true
}
}
$allowInlineIntro = $settingsSemanticPath -or
$pathProperty.Name -match '^/genealogy/app/genealogies(?:/|$)'
foreach ($schema in @(Get-RequestBodySchemas $operation $label)) {
if (Test-SchemaContainsSettingsContract $schema @{} "$label request schema" $allowInlineIntro $true) {
$isSettingsOwner = $true
break
}
}
}
if ($isSettingsOwner) { $settingsOwners.Add("$method $($pathProperty.Name)") }
}
}
$uniqueSettingsOwners = @($settingsOwners | Sort-Object -Unique)
$expected = "put $settingsPath"
if ($uniqueSettingsOwners.Count -ne 1 -or $uniqueSettingsOwners[0] -cne $expected) {
Add-Issue "JSON settings write contract must have exactly one global owner ($expected); actual: $($uniqueSettingsOwners -join ',')"
}
if ($operationIdOwners.Count -ne 1 -or $operationIdOwners[0] -cne "PUT $settingsPath") {
Add-Issue "JSON operationId appUpdateGenealogySettings must be globally unique on PUT $settingsPath; actual: $($operationIdOwners -join ',')"
}
}
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
}
$workspaceScript = Join-Path $PSScriptRoot 'genealogy-workspace-openapi-contract.ps1'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$workspaceOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $workspaceScript 2>&1)
$workspaceExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
$workspaceLines = @($workspaceOutput | ForEach-Object { [string]$_ })
if ($workspaceExitCode -ne 0 -or 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS' -notin $workspaceLines) {
Add-Issue 'canonical /mine and /overview workspace prerequisite must pass its sole owner gate before G11 can pass'
}
Assert-GlobalSettingsWriteOwner
$settingsPathProperty = $document.paths.PSObject.Properties[$settingsPath]
$settingsPathItem = if ($settingsPathProperty) { $settingsPathProperty.Value } else { $null }
$settingsOperation = Get-Operation $settingsPath 'put'
if ($settingsPathItem -and $settingsPathItem.patch) {
Add-Issue "JSON $settingsPath must not publish a second PATCH settings owner"
}
if ($settingsOperation) {
if ($settingsOperation.operationId -ne 'appUpdateGenealogySettings') {
Add-Issue "JSON PUT $settingsPath operationId must be appUpdateGenealogySettings"
}
Assert-SaToken $settingsOperation "PUT $settingsPath"
$settingsParameters = @(Get-OperationParameters $settingsPath $settingsOperation "PUT $settingsPath")
Assert-ExactParameters $settingsParameters "PUT $settingsPath" @(
'path:genealogyId', 'header:clientid', 'header:If-Match'
)
$genealogyId = Get-Parameter $settingsParameters 'genealogyId' 'path' "PUT $settingsPath"
if ($genealogyId) {
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
Add-Issue "JSON PUT $settingsPath genealogyId must be required"
}
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "PUT $settingsPath genealogyId")
}
$clientid = Get-Parameter $settingsParameters 'clientid' 'header' "PUT $settingsPath"
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
[int]$clientid.schema.minLength -ne 1 -or
[int]$clientid.schema.maxLength -ne 128)) {
Add-Issue "JSON PUT $settingsPath clientid must be a required non-null bounded non-empty string"
}
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "PUT $settingsPath clientid" }
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "PUT $settingsPath clientid" @('type', 'minLength', 'maxLength', 'nullable') }
$ifMatch = Get-Parameter $settingsParameters 'If-Match' 'header' "PUT $settingsPath"
if ($ifMatch) {
if (-not (Test-IsJsonBoolean $ifMatch.required $true)) {
Add-Issue "JSON PUT $settingsPath If-Match must be required"
}
[void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenealogySettingsVersion' "PUT $settingsPath If-Match")
}
$requestContent = if ($settingsOperation.requestBody) { @($settingsOperation.requestBody.content.PSObject.Properties) } else { @() }
if (-not $settingsOperation.requestBody -or -not (Test-IsJsonBoolean $settingsOperation.requestBody.required $true) -or
$requestContent.Count -ne 1 -or $requestContent[0].Name -ne 'application/json') {
Add-Issue "JSON PUT $settingsPath must require only application/json AppGenealogySettingsUpdateBody"
} elseif ($requestContent.Count -eq 1) {
[void](Test-IsPureSchemaRef $requestContent[0].Value.schema '#/components/schemas/AppGenealogySettingsUpdateBody' "PUT $settingsPath request schema")
}
if ([string]$settingsOperation.'x-update-semantics' -ne 'ATOMIC_DIRTY_ONLY_MERGE' -or
[string]$settingsOperation.'x-omitted-fields' -ne 'UNCHANGED' -or
[string]$settingsOperation.'x-version-precondition' -ne 'IF_MATCH_SETTINGS_VERSION_CAS' -or
[string]$settingsOperation.'x-canonical-noop-policy' -ne 'RETURN_200_KEEP_VERSION_NO_DOMAIN_SIDE_EFFECTS') {
Add-Issue "JSON PUT $settingsPath must machine-bind dirty-only merge, omission, version CAS, and canonical no-op semantics"
}
$precedence = @($settingsOperation.'x-conflict-precedence')
$expectedPrecedence = @(
'GENEALOGY_SETTINGS_VERSION_CHANGED',
'GENEALOGY_NOT_READY',
'ACTIVE_PENDING_APPLICATIONS'
)
if (-not (Test-IsJsonArray $settingsOperation.'x-conflict-precedence') -or
($precedence -join ',') -ne ($expectedPrecedence -join ',')) {
Add-Issue "JSON PUT $settingsPath must order version, READY, then active-pending conflicts"
}
if ([string]$settingsOperation.'x-active-pending-policy' -ne 'BLOCK_ONLY_PUBLIC_APPLY_TO_MEMBER_ONLY' -or
[string]$settingsOperation.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
-not (Test-IsJsonBoolean $settingsOperation.'x-authorization-revalidated-in-transaction' $true) -or
-not (Test-IsJsonBoolean $settingsOperation.'x-conflict-disclosure-requires-current-permission' $true)) {
Add-Issue "JSON PUT $settingsPath must bind permission revalidation and atomic single-winner PUBLIC_APPLY-to-MEMBER_ONLY pending protection"
}
}
$statuses = @('200', '400', '401', '403', '404', '409', '422', '429', '500')
Assert-ExactResponseSet $settingsOperation "PUT $settingsPath" $statuses
$responseRefs = @{
'200' = '#/components/schemas/RAppGenealogyVo'
'400' = '#/components/schemas/RGenealogySettingsBadRequest'
'401' = '#/components/schemas/RGenealogySettingsUnauthorized'
'403' = '#/components/schemas/RGenealogySettingsForbidden'
'404' = '#/components/schemas/RGenealogySettingsNotFound'
'409' = '#/components/schemas/RGenealogySettingsConflict'
'422' = '#/components/schemas/RGenealogySettingsValidationError'
'429' = '#/components/schemas/RGenealogySettingsRateLimited'
'500' = '#/components/schemas/RGenealogySettingsOutcomeUnknown'
}
$settingsResponses = @{}
foreach ($status in $statuses) {
$response = Get-Response $settingsOperation "PUT $settingsPath" $status
$settingsResponses[$status] = $response
$actualRef = Get-JsonResponseRef $response "PUT $settingsPath" $status
if ($actualRef -ne $responseRefs[$status]) {
Add-Issue "JSON PUT $settingsPath $status must return $($responseRefs[$status]); actual: $actualRef"
}
Assert-RequiredResponseHeaders $response "PUT $settingsPath" $status
Assert-PrivateNoStore $response "PUT $settingsPath" $status
}
Assert-RetryAfter $settingsResponses['429'] "PUT $settingsPath 429"
$nameOwner = Get-Schema 'GenealogyName'
if ($nameOwner) {
if ($nameOwner.type -ne 'string' -or [int]$nameOwner.minLength -ne 1 -or
[int]$nameOwner.maxLength -ne 24 -or [string]$nameOwner.pattern -cne $genealogyNamePattern -or
-not (Test-IsNonNullable $nameOwner) -or
[string]$nameOwner.'x-unicode-normalization' -ne 'NFC' -or
[string]$nameOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT') {
Add-Issue 'JSON GenealogyName must be non-null NFC, 1..24 Unicode code points, without boundary whitespace, line breaks, or control characters'
}
Assert-NoConflictingSchemaKeywords $nameOwner 'GenealogyName'
Assert-AllowedSchemaKeywords $nameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
$introOwner = Get-Schema 'GenealogyIntro'
if ($introOwner) {
$branches = @($introOwner.oneOf)
$clear = @($branches | Where-Object {
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and (Test-IsJsonArray $_.enum) -and
@($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq ''
})
$value = @($branches | Where-Object {
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and [int]$_.minLength -eq 1
})
if (-not (Test-IsJsonArray $introOwner.oneOf) -or $branches.Count -ne 2 -or
$clear.Count -ne 1 -or $value.Count -ne 1 -or
-not (Test-IsNonNullable $introOwner) -or
[int]$value[0].maxLength -ne 80 -or [string]$value[0].pattern -cne $genealogyIntroPattern -or
[string]$introOwner.'x-unicode-normalization' -ne 'NFC' -or
[string]$introOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT' -or
[string]$introOwner.'x-line-ending-normalization' -ne 'LF') {
Add-Issue 'JSON GenealogyIntro must be non-null, allow exact empty clear or boundary-trimmed NFC 1..80 code points, permit internal LF only, and reject other controls'
}
Assert-NoConflictingSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf')
Assert-AllowedSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf', 'nullable')
foreach ($branch in $clear) {
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro empty branch' @('enum')
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro empty branch' @('type', 'enum', 'nullable')
}
foreach ($branch in $value) {
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro value branch'
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro value branch' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
}
$versionOwner = Get-Schema 'GenealogySettingsVersion'
if ($versionOwner) {
if ($versionOwner.type -ne 'string' -or [int]$versionOwner.minLength -ne 1 -or
[int]$versionOwner.maxLength -ne 128 -or [string]$versionOwner.pattern -ne $identifierPattern -or
-not (Test-IsNonNullable $versionOwner) -or
[string]$versionOwner.'x-semantics' -ne 'OPAQUE_SETTINGS_CAS_VERSION' -or
-not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or
(@($versionOwner.'x-version-scope-fields') -join ',') -cne 'genealogyName,intro,accessPreset' -or
[string]$versionOwner.'x-version-change-policy' -ne 'CANONICAL_SETTINGS_CHANGE_ONLY') {
Add-Issue 'JSON GenealogySettingsVersion must be a non-null 1..128 URL-safe opaque CAS token that changes only when canonical genealogyName/intro/accessPreset changes'
}
Assert-NoConflictingSchemaKeywords $versionOwner 'GenealogySettingsVersion'
Assert-AllowedSchemaKeywords $versionOwner 'GenealogySettingsVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'genealogyName' '#/components/schemas/GenealogyName'
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'intro' '#/components/schemas/GenealogyIntro'
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
$appGenealogy = Get-Schema 'AppGenealogyVo'
if ($appGenealogy) {
if (-not (Test-IsNonNullable $appGenealogy)) {
Add-Issue 'JSON AppGenealogyVo must reject nullable=true'
}
foreach ($field in @('genealogyId', 'genealogyName', 'intro', 'accessPreset', 'settingsVersion', 'canManage')) {
if ($field -notin @($appGenealogy.required)) {
Add-Issue "JSON AppGenealogyVo.required missing settings baseline field: $field"
}
}
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'intro' '#/components/schemas/GenealogyIntro'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'settingsVersion' '#/components/schemas/GenealogySettingsVersion'
if ($appGenealogy.properties.canManage.type -ne 'boolean' -or
-not (Test-IsNonNullable $appGenealogy.properties.canManage)) {
Add-Issue 'JSON AppGenealogyVo.canManage must be a non-null boolean and is only an entry capability, not write authorization proof'
}
Assert-NoConflictingSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage'
Assert-AllowedSchemaKeywords $appGenealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
Assert-AllowedSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' @('type', 'nullable')
}
$successEnvelope = Get-Schema 'RAppGenealogyVo'
if ($successEnvelope) {
Assert-ExactObject $successEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
if ($successEnvelope.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $successEnvelope.properties.code) -or
-not (Test-IsJsonArray $successEnvelope.properties.code.enum) -or
@($successEnvelope.properties.code.enum).Count -ne 1 -or
@($successEnvelope.properties.code.enum)[0] -ne 200) {
Add-Issue 'JSON RAppGenealogyVo must expose fixed code=200 and canonical AppGenealogyVo data'
}
Assert-NoConflictingSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('enum')
Assert-AllowedSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('type', 'enum', 'nullable')
[void](Test-IsPureSchemaRef $successEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
}
$badRequest = Get-Schema 'RGenealogySettingsBadRequest'
$unauthorized = Get-Schema 'RGenealogySettingsUnauthorized'
$forbidden = Get-Schema 'RGenealogySettingsForbidden'
$notFound = Get-Schema 'RGenealogySettingsNotFound'
$versionChanged = Get-Schema 'RGenealogySettingsVersionChanged'
$notReady = Get-Schema 'RGenealogySettingsNotReady'
$pendingApplications = Get-Schema 'RGenealogySettingsPendingApplications'
$conflict = Get-Schema 'RGenealogySettingsConflict'
$validation = Get-Schema 'RGenealogySettingsValidationError'
$rateLimited = Get-Schema 'RGenealogySettingsRateLimited'
$outcomeUnknown = Get-Schema 'RGenealogySettingsOutcomeUnknown'
Assert-FixedError $badRequest 'RGenealogySettingsBadRequest' 400 'GENEALOGY_SETTINGS_REQUEST_INVALID'
Assert-FixedError $unauthorized 'RGenealogySettingsUnauthorized' 401 'AUTH_REQUIRED'
Assert-FixedError $forbidden 'RGenealogySettingsForbidden' 403 'GENEALOGY_SETTINGS_FORBIDDEN'
Assert-FixedError $notFound 'RGenealogySettingsNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
Assert-FixedError $versionChanged 'RGenealogySettingsVersionChanged' 409 'GENEALOGY_SETTINGS_VERSION_CHANGED' -Current
Assert-FixedError $notReady 'RGenealogySettingsNotReady' 409 'GENEALOGY_NOT_READY'
Assert-FixedError $pendingApplications 'RGenealogySettingsPendingApplications' 409 'ACTIVE_PENDING_APPLICATIONS'
Assert-FixedError $rateLimited 'RGenealogySettingsRateLimited' 429 'RATE_LIMITED'
Assert-FixedError $outcomeUnknown 'RGenealogySettingsOutcomeUnknown' 500 'GENEALOGY_SETTINGS_OUTCOME_UNKNOWN'
if ($conflict) {
$actualRefs = @($conflict.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$expectedRefs = @(
'#/components/schemas/RGenealogySettingsVersionChanged',
'#/components/schemas/RGenealogySettingsNotReady',
'#/components/schemas/RGenealogySettingsPendingApplications'
) | Sort-Object
$mapping = $conflict.discriminator.mapping
$actualMapping = if ($mapping) {
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
} else { @() }
$expectedMapping = @(
'GENEALOGY_SETTINGS_VERSION_CHANGED=#/components/schemas/RGenealogySettingsVersionChanged',
'GENEALOGY_NOT_READY=#/components/schemas/RGenealogySettingsNotReady',
'ACTIVE_PENDING_APPLICATIONS=#/components/schemas/RGenealogySettingsPendingApplications'
) | Sort-Object
$conflictKeywords = @($conflict.PSObject.Properties.Name | Sort-Object)
$discriminatorKeywords = if ($conflict.discriminator) {
@($conflict.discriminator.PSObject.Properties.Name | Sort-Object)
} else { @() }
$pureBranches = @($conflict.oneOf | Where-Object {
(@($_.PSObject.Properties.Name) -join ',') -ceq '$ref' -and
[string]$_.'$ref' -match '^#/components/schemas/[^/]+$'
})
if (($conflictKeywords -join ',') -cne 'discriminator,oneOf' -or
($discriminatorKeywords -join ',') -cne 'mapping,propertyName' -or
-not (Test-IsJsonArray $conflict.oneOf) -or $pureBranches.Count -ne 3 -or
($actualRefs -join ',') -ne ($expectedRefs -join ',') -or
$conflict.discriminator.propertyName -ne 'businessCode' -or
($actualMapping -join ',') -ne ($expectedMapping -join ',')) {
Add-Issue 'JSON RGenealogySettingsConflict must contain only an exact businessCode discriminator and three local-ref oneOf branches for version, READY, and active-pending conflicts'
}
}
Assert-ExactObject $validation 'RGenealogySettingsValidationError' @(
'code', 'businessCode', 'fieldErrors'
) @('code', 'businessCode', 'fieldErrors')
if ($validation) {
if ($validation.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $validation.properties.code) -or
-not (Test-IsJsonArray $validation.properties.code.enum) -or
@($validation.properties.code.enum).Count -ne 1 -or @($validation.properties.code.enum)[0] -ne 422 -or
$validation.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $validation.properties.businessCode) -or
-not (Test-IsJsonArray $validation.properties.businessCode.enum) -or
@($validation.properties.businessCode.enum).Count -ne 1 -or
@($validation.properties.businessCode.enum)[0] -ne 'GENEALOGY_SETTINGS_INVALID') {
Add-Issue 'JSON RGenealogySettingsValidationError must fix code=422 and businessCode=GENEALOGY_SETTINGS_INVALID'
}
Assert-NoConflictingSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('enum')
Assert-NoConflictingSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('enum')
Assert-AllowedSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('type', 'enum', 'nullable')
$fieldErrors = $validation.properties.fieldErrors
Assert-ExactObject $fieldErrors 'RGenealogySettingsValidationError.fieldErrors' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
if ($fieldErrors) {
foreach ($field in @('genealogyName', 'intro', 'accessPreset')) {
$property = $fieldErrors.properties.PSObject.Properties[$field]
if (-not $property -or $property.Value.type -ne 'string' -or
-not (Test-IsNonNullable $property.Value) -or
[int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 200) {
Add-Issue "JSON RGenealogySettingsValidationError.fieldErrors.$field must be an optional non-null bounded non-empty string"
}
if ($property) {
Assert-NoConflictingSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field"
Assert-AllowedSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" @('type', 'minLength', 'maxLength', 'nullable')
}
}
}
}
if ($issues.Count -gt 0) {
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output '- Keep PUT as the only settings owner and /overview as the only single-genealogy read owner; do not add PATCH or revive the generic GET.'
Write-Output '- Use one atomic dirty-only merge with GenealogySettingsVersion in AppGenealogyVo, required If-Match, typed 409 conflicts, and no version field in the body.'
Write-Output '- Block only PUBLIC_APPLY-to-MEMBER_ONLY while active PENDING applications exist, and serialize that transition with new application admission.'
Write-Output '- Timeout, cancellation, 5xx, or malformed success is outcome-unknown; reconcile with a fresh /overview and never auto-repeat PUT or claim this request succeeded.'
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
exit 1
}
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT PASS'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
const assert = require("node:assert/strict");
// This is an adversarial executable check for the OpenAPI text contract only.
// It is not the production G12 normalizer or coordinator.
const boundaryAndControlPattern = /^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$/u;
function hasWellFormedUtf16(value) {
for (let index = 0; index < value.length; index += 1) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
index += 1;
continue;
}
if (unit >= 0xdc00 && unit <= 0xdfff) return false;
}
return true;
}
function validatesDeclaredGenerationText(value) {
return (
typeof value === "string" &&
hasWellFormedUtf16(value) &&
value === value.normalize("NFC") &&
[...value].length >= 1 &&
[...value].length <= 50 &&
boundaryAndControlPattern.test(value)
);
}
const hanDe = "\u5FB7";
const hanCheng = "\u627F";
const supplementary = String.fromCodePoint(0x20000);
for (const valid of [
`${hanDe}${hanCheng}`,
`${hanDe}${hanDe}`,
`${supplementary}${hanCheng}`,
supplementary.repeat(50),
]) {
assert.equal(validatesDeclaredGenerationText(valid), true, `valid sample rejected: ${JSON.stringify(valid)}`);
}
for (const invalid of [
"",
supplementary.repeat(51),
"e\u0301",
"\ud800",
"\udc00",
` ${hanDe}`,
`${hanDe} `,
`\u00A0${hanDe}`,
`${hanDe}\u00A0`,
`${hanDe}\t${hanCheng}`,
`${hanDe}\r${hanCheng}`,
`${hanDe}\n${hanCheng}`,
`${hanDe}\u0085${hanCheng}`,
`${hanDe}\u061C${hanCheng}`,
`${hanDe}\u200B${hanCheng}`,
`${hanDe}\u200E${hanCheng}`,
`${hanDe}\u200F${hanCheng}`,
`${hanDe}\u2028${hanCheng}`,
`${hanDe}\u2029${hanCheng}`,
`${hanDe}\u202E${hanCheng}`,
`${hanDe}\u2060${hanCheng}`,
`${hanDe}\u2066${hanCheng}`,
`${hanDe}\uFEFF${hanCheng}`,
]) {
assert.equal(validatesDeclaredGenerationText(invalid), false, `invalid sample accepted: ${JSON.stringify(invalid)}`);
}
console.log("G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS");
+654 -210
View File
@@ -2,58 +2,85 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$yamlPath = Join-Path $root 'APP.openapi.yaml'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
$issues = New-Object System.Collections.Generic.List[string]
$minePath = '/genealogy/app/genealogies/mine'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-JsonOperation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
return $null
}
return $operationProperty.Value
function Test-IsJsonArray {
param([object]$Value)
return $null -ne $Value -and $Value.GetType().IsArray
}
function Get-JsonResponseSchemaRef {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return '' }
$responseProperty = $Operation.responses.PSObject.Properties['200']
if (-not $responseProperty) {
Add-Issue "JSON $Label missing 200 response"
return ''
}
$response = $responseProperty.Value
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$responseOwner = $document.components.responses.PSObject.Properties[$responseName]
if (-not $responseOwner) {
Add-Issue "JSON $Label references missing response owner: $responseName"
return ''
function Test-IsJsonBoolean {
param([object]$Value, [bool]$Expected)
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsNonNullable {
param([object]$Schema)
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords {
param(
[object]$Schema,
[string]$Label,
[string[]]$Allowed = @()
)
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
}
$response = $responseOwner.Value
}
$media = @($response.content.PSObject.Properties)
if ($media.Count -eq 0) {
Add-Issue "JSON $Label 200 response has no content schema"
return ''
}
return [string]$media[0].Value.schema.'$ref'
}
function Get-JsonSchema {
function Assert-AllowedSchemaKeywords {
param([object]$Schema, [string]$Label, [string[]]$Allowed)
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureSchemaRef {
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
if (-not $Schema) { return $false }
$properties = @($Schema.PSObject.Properties.Name)
$actualRef = [string]$Schema.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
return $true
}
function Get-LocalComponentName {
param([string]$Ref, [string]$Section, [string]$Label)
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
@@ -63,213 +90,630 @@ function Get-JsonSchema {
return $property.Value
}
function Assert-JsonRequired {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
$required = @($Schema.required)
foreach ($field in $Fields) {
if ($field -notin $required) {
Add-Issue "JSON $SchemaName.required missing: $field"
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpperInvariant()) $Path"
return $null
}
return $operationProperty.Value
}
function Assert-OnlyMethod {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) { return }
$actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { $_ -in $httpMethods } | Sort-Object)
if ($actual.Count -ne 1 -or $actual[0] -ne $Method) {
Add-Issue "JSON $Path must expose only $($Method.ToUpperInvariant()); actual: $($actual -join ',')"
}
}
function Assert-JsonProperty {
param(
[object]$Schema,
[string]$SchemaName,
[string]$Field,
[string]$Type,
[switch]$NonEmpty
)
if (-not $Schema) { return $null }
function Resolve-Parameter {
param([object]$Parameter, [string]$Label)
if (-not $Parameter) { return $null }
if (-not $Parameter.'$ref') { return $Parameter }
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
if (-not $name) { return $null }
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing parameter owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Response {
param([object]$Response, [string]$Label, [bool]$StrictRefObject = $true)
if (-not $Response) { return $null }
if (-not $Response.'$ref') { return $Response }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($StrictRefObject -and $responseRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing response owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Header {
param([object]$Header, [string]$Label)
if (-not $Header) { return $null }
if (-not $Header.'$ref') { return $Header }
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($headerRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
if (-not $name) { return $null }
$owner = $document.components.headers.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing header owner: $name"
return $null
}
return $owner.Value
}
function Get-OperationParameters {
param([string]$Path, [object]$Operation, [string]$Label)
$parameters = @()
$pathProperty = $document.paths.PSObject.Properties[$Path]
if ($pathProperty -and $pathProperty.Value.parameters) {
foreach ($parameter in @($pathProperty.Value.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label path parameter"
if ($resolved) { $parameters += $resolved }
}
}
if ($Operation -and $Operation.parameters) {
foreach ($parameter in @($Operation.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
if ($resolved) { $parameters += $resolved }
}
}
return $parameters
}
function Assert-ExactParameters {
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -cne ($wanted -join ',')) {
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Get-Parameter {
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
if ($matches.Count -ne 1) {
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
return $null
}
return $matches[0]
}
function Assert-SaToken {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = @($Operation.security)
if ($requirements.Count -ne 1) {
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
return
}
$names = @($requirements[0].PSObject.Properties.Name)
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
Add-Issue "JSON $Label must require only SaToken"
}
}
function Assert-GlobalOperationId {
param([string]$OperationId, [string]$ExpectedLabel)
$owners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $httpMethods })) {
if ([string]$methodProperty.Value.operationId -ceq $OperationId) {
$owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)")
}
}
}
if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedLabel) {
Add-Issue "JSON operationId $OperationId must be globally unique on $ExpectedLabel; actual: $($owners -join ',')"
}
}
function Assert-ExactResponseSet {
param([object]$Operation, [string]$Label, [string[]]$Expected)
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -cne ($wanted -join ',')) {
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing response: $Status"
return $null
}
return Resolve-Response $property.Value "$Label $Status"
}
function Get-ResponseSchemaRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = @($Response.content.PSObject.Properties)
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
Add-Issue "JSON $Label $Status must expose only application/json"
return ''
}
$schema = $media[0].Value.schema
$ref = [string]$schema.'$ref'
if (-not $ref) {
Add-Issue "JSON $Label $Status must use a component schema ref"
return ''
}
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
$schemaKeywords = @($schema.PSObject.Properties.Name)
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
}
return $ref
}
function Assert-RequiredResponseHeaders {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
if ('Cache-Control' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
}
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Retry-After"
}
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
Add-Issue "JSON $Label $Status must not publish ETag; workspace reads use the shared settingsVersion owner instead"
}
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
if ($unexpectedHeaders.Count -gt 0) {
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
}
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
if (-not $property) { continue }
$header = Resolve-Header $property.Value "$Label $Status $traceName"
if (-not $header) { continue }
foreach ($headerProperty in @($header.PSObject.Properties)) {
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
}
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
continue
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
}
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
if (-not $header) { return }
$values = @($header.schema.enum)
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
[string]$values[0] -cne 'private, no-store') {
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
}
function Assert-RetryAfter {
param([object]$Response, [string]$Label)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label must document Retry-After"
return
}
$header = Resolve-Header $property.Value "$Label Retry-After"
if (-not $header) { return }
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
[int]$header.schema.minimum -ne 1 -or [int]$header.schema.maximum -lt 1 -or
[int]$header.schema.maximum -gt 300) {
Add-Issue "JSON $Label Retry-After must be a non-null integer in a bounded 1..300 second range"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
}
function Assert-ExactObject {
param([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required)
if (-not $Schema) { return }
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$expectedProperties = @($Properties | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
($actualProperties -join ',') -cne ($expectedProperties -join ',') -or
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
Add-Issue "JSON $Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return $null
return
}
if ($property.Value.type -ne $Type) {
Add-Issue "JSON $SchemaName.$Field must be $Type"
}
if ($NonEmpty -and [int]$property.Value.minLength -lt 1) {
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
}
return $property.Value
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Get-YamlBlock {
param([string]$Header, [string]$NextHeaderPattern)
$lines = @($yaml -split "`r?`n")
$start = [Array]::IndexOf($lines, $Header)
if ($start -lt 0) { return '' }
$end = $lines.Count
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
if ($lines[$index] -match $NextHeaderPattern) {
$end = $index
break
function Assert-FixedError {
param([string]$Name, [int]$Status, [string]$BusinessCode)
$schema = Get-Schema $Name
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
if (-not $schema) { return }
$codes = @($schema.properties.code.enum)
$businessCodes = @($schema.properties.businessCode.enum)
if ($schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $schema.properties.code) -or
-not (Test-IsJsonArray $schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[0] -ne $Status -or
$schema.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $schema.properties.businessCode) -or
-not (Test-IsJsonArray $schema.properties.businessCode.enum) -or
$businessCodes.Count -ne 1 -or [string]$businessCodes[0] -cne $BusinessCode) {
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
}
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
}
function Get-AllOfPropertyNames {
param([object]$Schema, [hashtable]$Seen, [string]$Label)
if (-not $Schema -or $Schema.type -eq 'array') { return @() }
if ($Schema.'$ref') {
$name = Get-LocalComponentName ([string]$Schema.'$ref') 'schemas' $Label
if (-not $name -or $Seen.ContainsKey($name)) { return @() }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return @()
}
return @(Get-AllOfPropertyNames $owner.Value $Seen "$Label -> $name")
}
$names = New-Object System.Collections.Generic.List[string]
if ($Schema.properties) {
foreach ($name in @($Schema.properties.PSObject.Properties.Name)) { $names.Add($name) }
}
foreach ($branch in @($Schema.allOf)) {
foreach ($name in @(Get-AllOfPropertyNames $branch $Seen "$Label allOf")) { $names.Add($name) }
}
return @($names | Sort-Object -Unique)
}
function Test-SchemaClosureExposesSingleGenealogy {
param([object]$Schema, [hashtable]$Seen, [string]$Label)
if (-not $Schema) { return $false }
$ref = [string]$Schema.'$ref'
if ($ref) {
$leaf = @($ref -split '/')[-1]
if ($leaf -in @('RAppGenealogyVo', 'AppGenealogyVo')) {
$expectedRef = "#/components/schemas/$leaf"
if ($ref -cne $expectedRef) {
[void](Get-LocalComponentName $ref 'schemas' $Label)
}
return $true
}
$name = Get-LocalComponentName $ref 'schemas' $Label
if (-not $name -or $Seen.ContainsKey($name)) { return $false }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return $false
}
return Test-SchemaClosureExposesSingleGenealogy $owner.Value $Seen "$Label -> $name"
}
if ($Schema.type -eq 'array') { return $false }
$propertyNames = @(Get-AllOfPropertyNames $Schema @{} "$Label structural detail")
$detailFields = @(@('genealogyId', 'genealogyName', 'canView') | Where-Object { $_ -in $propertyNames })
if ($detailFields.Count -eq 3) {
return $true
}
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
foreach ($branch in @($Schema.$keyword)) {
if (Test-SchemaClosureExposesSingleGenealogy $branch $Seen "$Label $keyword") { return $true }
}
}
return ($lines[$start..($end - 1)] -join "`n")
}
function Get-YamlRequiredFields {
param([string]$Block)
$match = [regex]::Match(
$Block,
'(?ms)^ required:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
)
if (-not $match.Success) { return @() }
return @([regex]::Matches($match.Groups['rows'].Value, '(?m)^ - (?<name>[^\s]+)\s*$') |
ForEach-Object { $_.Groups['name'].Value })
}
function Get-YamlFieldBlock {
param([string]$SchemaBlock, [string]$Field)
$pattern = '(?ms)^ ' + [regex]::Escape($Field) + ':\s*\n(?<body>.*?)(?=^ [A-Za-z0-9_]+:\s*$|\z)'
$match = [regex]::Match($SchemaBlock, $pattern)
if (-not $match.Success) { return '' }
return $match.Value
}
function Assert-YamlRequired {
param([string]$Block, [string]$SchemaName, [string[]]$Fields)
if (-not $Block) { return }
$required = @(Get-YamlRequiredFields $Block)
foreach ($field in $Fields) {
if ($field -notin $required) {
Add-Issue "YAML $SchemaName.required missing: $field"
if ($Schema.properties) {
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if ($property.Value.type -eq 'array') { continue }
if (Test-SchemaClosureExposesSingleGenealogy $property.Value $Seen "$Label.$($property.Name)") { return $true }
}
}
return $false
}
function Assert-YamlField {
param(
[string]$Block,
[string]$SchemaName,
[string]$Field,
[string]$Type,
[switch]$NonEmpty
)
if (-not $Block) { return '' }
$fieldBlock = Get-YamlFieldBlock $Block $Field
if (-not $fieldBlock) {
Add-Issue "YAML $SchemaName missing property: $Field"
return ''
function Test-OperationReturnsSingleGenealogy {
param([object]$Operation, [string]$Label)
if (-not $Operation -or -not $Operation.responses) { return $false }
$responseProperty = $Operation.responses.PSObject.Properties['200']
if (-not $responseProperty) { return $false }
$response = $responseProperty.Value
if ($response.'$ref') {
$leaf = @(([string]$response.'$ref') -split '/')[-1]
if ($leaf -eq 'RAppGenealogyVo') {
if ([string]$response.'$ref' -cne '#/components/responses/RAppGenealogyVo') {
[void](Get-LocalComponentName ([string]$response.'$ref') 'responses' "$Label 200 response")
}
return $true
}
$response = Resolve-Response $response "$Label 200 response" $true
if (-not $response) { return $false }
}
if ($fieldBlock -notmatch "(?m)^ type: $([regex]::Escape($Type))\s*$") {
Add-Issue "YAML $SchemaName.$Field must be $Type"
if (-not $response.content) { return $false }
foreach ($media in @($response.content.PSObject.Properties)) {
if (Test-SchemaClosureExposesSingleGenealogy $media.Value.schema @{} "$Label 200 $($media.Name)") {
return $true
}
}
if ($NonEmpty -and $fieldBlock -notmatch '(?m)^ minLength: [1-9][0-9]*\s*$') {
Add-Issue "YAML $SchemaName.$Field must declare minLength >= 1"
return $false
}
function Assert-SoleSingleGenealogyReadOwner {
$owners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties | Where-Object { $_.Name -match '^/genealogy/app(?:/|$)' })) {
$getProperty = $pathProperty.Value.PSObject.Properties['get']
if (-not $getProperty -or $pathProperty.Name -ceq $minePath) { continue }
$label = "GET $($pathProperty.Name)"
if (Test-OperationReturnsSingleGenealogy $getProperty.Value $label) {
$owners.Add($label)
}
}
$expected = "GET $overviewPath"
$uniqueOwners = @($owners | Sort-Object -Unique)
if ($uniqueOwners.Count -ne 1 -or $uniqueOwners[0] -cne $expected) {
Add-Issue "JSON single-genealogy APP read must have exactly one global owner ($expected); actual: $($uniqueOwners -join ',')"
}
return $fieldBlock
}
$minePath = '/genealogy/app/genealogies/mine'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$mine = Get-JsonOperation $minePath 'get'
$overview = Get-JsonOperation $overviewPath 'get'
$mineResponseRef = Get-JsonResponseSchemaRef $mine 'GET /mine'
if ($mineResponseRef -ne '#/components/schemas/RListAppGenealogyVo') {
Add-Issue "JSON GET /mine must return RListAppGenealogyVo; actual: $mineResponseRef"
}
$overviewResponseRef = Get-JsonResponseSchemaRef $overview 'GET /overview'
if ($overviewResponseRef -ne '#/components/schemas/RAppGenealogyVo') {
Add-Issue "JSON GET /overview must return RAppGenealogyVo; actual: $overviewResponseRef"
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
}
$listEnvelope = Get-JsonSchema 'RListAppGenealogyVo'
$objectEnvelope = Get-JsonSchema 'RAppGenealogyVo'
$genealogy = Get-JsonSchema 'AppGenealogyVo'
$mine = Get-Operation $minePath 'get'
$overview = Get-Operation $overviewPath 'get'
Assert-OnlyMethod $minePath 'get'
Assert-OnlyMethod $overviewPath 'get'
Assert-SoleSingleGenealogyReadOwner
Assert-JsonRequired $listEnvelope 'RListAppGenealogyVo' @('code', 'data')
Assert-JsonRequired $objectEnvelope 'RAppGenealogyVo' @('code', 'data')
Assert-JsonProperty $listEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-JsonProperty $objectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
$operationContracts = @(
[pscustomobject]@{
Path = $minePath
Operation = $mine
Label = "GET $minePath"
OperationId = 'appListMyGenealogies'
Parameters = @('header:clientid')
Responses = [ordered]@{
'200' = '#/components/schemas/RListAppGenealogyVo'
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
}
},
[pscustomobject]@{
Path = $overviewPath
Operation = $overview
Label = "GET $overviewPath"
OperationId = 'appGetGenealogyOverview'
Parameters = @('header:clientid', 'path:genealogyId')
Responses = [ordered]@{
'200' = '#/components/schemas/RAppGenealogyVo'
'400' = '#/components/schemas/RGenealogyWorkspaceBadRequest'
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
'404' = '#/components/schemas/RGenealogyWorkspaceNotFound'
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
}
}
)
foreach ($contract in $operationContracts) {
$operation = $contract.Operation
if (-not $operation) { continue }
if ([string]$operation.operationId -cne $contract.OperationId) {
Add-Issue "JSON $($contract.Label) operationId must be $($contract.OperationId)"
}
Assert-GlobalOperationId $contract.OperationId $contract.Label
Assert-SaToken $operation $contract.Label
if ($operation.PSObject.Properties['requestBody']) {
Add-Issue "JSON $($contract.Label) must not define a request body"
}
$parameters = @(Get-OperationParameters $contract.Path $operation $contract.Label)
Assert-ExactParameters $parameters $contract.Label $contract.Parameters
$clientid = Get-Parameter $parameters 'clientid' 'header' $contract.Label
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
[int]$clientid.schema.minLength -ne 1 -or [int]$clientid.schema.maxLength -ne 128)) {
Add-Issue "JSON $($contract.Label) clientid must be a required non-null string bounded to 1..128"
}
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "$($contract.Label) clientid" }
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "$($contract.Label) clientid" @('type', 'minLength', 'maxLength', 'nullable') }
if ($contract.Path -eq $overviewPath) {
$genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $contract.Label
if ($genealogyId) {
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
Add-Issue "JSON $($contract.Label) genealogyId must be required"
}
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$($contract.Label) genealogyId")
}
}
$statuses = @($contract.Responses.Keys)
Assert-ExactResponseSet $operation $contract.Label $statuses
foreach ($status in $statuses) {
$response = Get-Response $operation $contract.Label $status
$actualRef = Get-ResponseSchemaRef $response $contract.Label $status
if ($actualRef -cne $contract.Responses[$status]) {
Add-Issue "JSON $($contract.Label) $status must return $($contract.Responses[$status]); actual: $actualRef"
}
Assert-RequiredResponseHeaders $response $contract.Label $status
Assert-PrivateNoStore $response $contract.Label $status
if ($status -eq '429') { Assert-RetryAfter $response "$($contract.Label) 429" }
}
}
if ($mine -and (-not (Test-IsJsonBoolean $mine.'x-current-account-viewable-only' $true) -or
[string]$mine.'x-revocation-policy' -cne 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS')) {
Add-Issue 'JSON GET /mine must machine-bind current-account viewable-only scope and confirmed-revocation omission'
}
if ($overview -and [string]$overview.'x-object-authorization-failure' -cne 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE') {
Add-Issue 'JSON GET /overview must machine-bind non-disclosing object authorization failure'
}
$genealogyIdOwner = Get-Schema 'GenealogyId'
if ($genealogyIdOwner -and ($genealogyIdOwner.type -ne 'string' -or
-not (Test-IsNonNullable $genealogyIdOwner) -or [int]$genealogyIdOwner.minLength -ne 1 -or
[int]$genealogyIdOwner.maxLength -ne 128 -or [string]$genealogyIdOwner.pattern -cne $identifierPattern)) {
Add-Issue 'JSON GenealogyId must be a non-null 1..128 URL-safe lexical identifier'
}
if ($genealogyIdOwner) { Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId' }
if ($genealogyIdOwner) { Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
$genealogyNameOwner = Get-Schema 'GenealogyName'
if ($genealogyNameOwner -and ($genealogyNameOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyNameOwner))) {
Add-Issue 'JSON GenealogyName shared owner must be a non-null string; G11 owns its normalization and length semantics'
}
if ($genealogyNameOwner) { Assert-NoConflictingSchemaKeywords $genealogyNameOwner 'GenealogyName' }
if ($genealogyNameOwner) { Assert-AllowedSchemaKeywords $genealogyNameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
$listEnvelope = Get-Schema 'RListAppGenealogyVo'
$objectEnvelope = Get-Schema 'RAppGenealogyVo'
$genealogy = Get-Schema 'AppGenealogyVo'
Assert-ExactObject $listEnvelope 'RListAppGenealogyVo' @('code', 'data') @('code', 'data')
Assert-ExactObject $objectEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
foreach ($envelope in @(
[pscustomobject]@{ Name = 'RListAppGenealogyVo'; Schema = $listEnvelope },
[pscustomobject]@{ Name = 'RAppGenealogyVo'; Schema = $objectEnvelope }
)) {
if (-not $envelope.Schema) { continue }
$codes = @($envelope.Schema.properties.code.enum)
if ($envelope.Schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $envelope.Schema.properties.code) -or
-not (Test-IsJsonArray $envelope.Schema.properties.code.enum) -or
$codes.Count -ne 1 -or $codes[0] -ne 200) {
Add-Issue "JSON $($envelope.Name).code must be fixed to 200"
}
Assert-NoConflictingSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('enum')
Assert-AllowedSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('type', 'enum', 'nullable')
}
if ($listEnvelope) {
$listData = $listEnvelope.properties.PSObject.Properties['data'].Value
if (-not $listData -or $listData.type -ne 'array' -or $listData.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RListAppGenealogyVo.data must be AppGenealogyVo[]'
$listData = $listEnvelope.properties.data
if ($listData.type -ne 'array' -or -not (Test-IsNonNullable $listData)) {
Add-Issue 'JSON RListAppGenealogyVo.data must be a non-null AppGenealogyVo array'
}
Assert-NoConflictingSchemaKeywords $listData 'RListAppGenealogyVo.data'
Assert-AllowedSchemaKeywords $listData 'RListAppGenealogyVo.data' @('type', 'items', 'nullable')
[void](Test-IsPureSchemaRef $listData.items '#/components/schemas/AppGenealogyVo' 'RListAppGenealogyVo.data.items')
}
if ($objectEnvelope) {
$objectData = $objectEnvelope.properties.PSObject.Properties['data'].Value
if (-not $objectData -or $objectData.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
}
[void](Test-IsPureSchemaRef $objectEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
}
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
Assert-JsonRequired $genealogy 'AppGenealogyVo' $consumedFields
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
Assert-JsonProperty $genealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
}
$roleType = Assert-JsonProperty $genealogy 'AppGenealogyVo' 'roleType' 'string'
if ($roleType) {
$roleValues = @($roleType.enum | Where-Object { $_ -is [string] -and $_.Trim() }) | Select-Object -Unique
if ($roleValues.Count -lt 2) {
Add-Issue 'JSON AppGenealogyVo.roleType must declare at least two non-empty enum values'
if ($genealogy) {
if ($genealogy.type -ne 'object' -or -not (Test-IsNonNullable $genealogy)) {
Add-Issue 'JSON AppGenealogyVo must be a non-null object'
}
Assert-AllowedSchemaKeywords $genealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
foreach ($field in $consumedFields) {
if ($field -notin @($genealogy.required)) {
Add-Issue "JSON AppGenealogyVo.required missing workspace field: $field"
}
}
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
$property = $genealogy.properties.PSObject.Properties[$field]
if (-not $property -or $property.Value.type -ne 'boolean' -or -not (Test-IsNonNullable $property.Value)) {
Add-Issue "JSON AppGenealogyVo.$field must be a non-null boolean"
}
if ($property) {
Assert-NoConflictingSchemaKeywords $property.Value "AppGenealogyVo.$field"
Assert-AllowedSchemaKeywords $property.Value "AppGenealogyVo.$field" @('type', 'nullable')
}
}
$roleType = $genealogy.properties.PSObject.Properties['roleType']
$rawRoleValues = if ($roleType) { @($roleType.Value.enum) } else { @() }
$roleValues = @($rawRoleValues | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
$uniqueRoleValues = @($roleValues | Sort-Object -CaseSensitive -Unique)
if (-not $roleType -or $roleType.Value.type -ne 'string' -or -not (Test-IsNonNullable $roleType.Value) -or
-not (Test-IsJsonArray $roleType.Value.enum) -or $roleValues.Count -ne $rawRoleValues.Count -or
$uniqueRoleValues.Count -ne $rawRoleValues.Count -or $rawRoleValues.Count -lt 2) {
Add-Issue 'JSON AppGenealogyVo.roleType must be a non-null string with at least two stable non-empty enum values'
}
if ($roleType) {
Assert-NoConflictingSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('enum')
Assert-AllowedSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('type', 'enum', 'nullable')
}
}
$yamlMine = Get-YamlBlock " $minePath`:" '^ /.*:$'
$yamlOverview = Get-YamlBlock " $overviewPath`:" '^ /.*:$'
if (-not $yamlMine) {
Add-Issue "YAML missing path: $minePath"
} elseif ($yamlMine -notmatch [regex]::Escape('#/components/schemas/RListAppGenealogyVo')) {
Add-Issue 'YAML GET /mine must return RListAppGenealogyVo'
}
if (-not $yamlOverview) {
Add-Issue "YAML missing path: $overviewPath"
} elseif ($yamlOverview -notmatch [regex]::Escape('#/components/schemas/RAppGenealogyVo')) {
Add-Issue 'YAML GET /overview must return RAppGenealogyVo'
}
$yamlListEnvelope = Get-YamlBlock ' RListAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
$yamlObjectEnvelope = Get-YamlBlock ' RAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
$yamlGenealogy = Get-YamlBlock ' AppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
foreach ($schema in @(
@{ Name = 'RListAppGenealogyVo'; Block = $yamlListEnvelope },
@{ Name = 'RAppGenealogyVo'; Block = $yamlObjectEnvelope },
@{ Name = 'AppGenealogyVo'; Block = $yamlGenealogy }
)) {
if (-not $schema.Block) { Add-Issue "YAML missing schema owner: $($schema.Name)" }
}
Assert-YamlRequired $yamlListEnvelope 'RListAppGenealogyVo' @('code', 'data')
Assert-YamlRequired $yamlObjectEnvelope 'RAppGenealogyVo' @('code', 'data')
Assert-YamlRequired $yamlGenealogy 'AppGenealogyVo' $consumedFields
Assert-YamlField $yamlListEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-YamlField $yamlObjectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
}
$yamlRoleType = Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'roleType' 'string'
if ($yamlRoleType) {
$enumMatch = [regex]::Match(
$yamlRoleType,
'(?ms)^ enum:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
)
$yamlRoleValues = if ($enumMatch.Success) {
@([regex]::Matches($enumMatch.Groups['rows'].Value, '(?m)^ - (?<value>\S.*?)\s*$') |
ForEach-Object { $_.Groups['value'].Value } | Select-Object -Unique)
} else { @() }
if ($yamlRoleValues.Count -lt 2) {
Add-Issue 'YAML AppGenealogyVo.roleType must declare at least two enum values'
}
}
Assert-FixedError 'RGenealogyWorkspaceBadRequest' 400 'GENEALOGY_ID_INVALID'
Assert-FixedError 'RGenealogyWorkspaceUnauthorized' 401 'AUTH_REQUIRED'
Assert-FixedError 'RGenealogyWorkspaceNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
Assert-FixedError 'RGenealogyWorkspaceRateLimited' 429 'RATE_LIMITED'
Assert-FixedError 'RGenealogyWorkspaceUnavailable' 500 'GENEALOGY_WORKSPACE_UNAVAILABLE'
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Release behavior still requires authenticated tests for current-account filtering, revocation, cross-account access, 401/403/404/5xx, malformed JSON, and cancellation.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output '- /mine and /overview are authenticated private reads; do not publish a generic duplicate detail read or HTTP-200-wrapped errors.'
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
Write-Output '- Release still requires two-account deployment tests for revocation, cross-account isolation, deletion, rate limits, malformed identifiers, and service failure.'
exit 1
}
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'
+61 -7
View File
@@ -82,7 +82,7 @@ for ($index = 0; $index -lt $registeredRoutes.Count; $index += 1) {
# 第五步:A04、G 系列第一轮、F 系列当前写边界和 T01 允许登记已有专项证据;
# 其余页面不能提前冒充完成 OpenAPI 审查。F 系列每页必须同时写出已核对事实和仍未启用的边界。
$authSeriesReviewPatterns = @{
A01 = '`APP_SMS_LOGIN` 客户端链已落地.*`API-AUTH-TAC-001` 硬关闭.*见 2\.10'
A01 = '密码登录线上 wire.*默认 mock.*`validToken` 未由登录接口消费.*`API-AUTH-TAC-001`.*见 2\.10'
A04 = '`APP_REGISTER`、4 位码、`RAppLoginVo → AppLoginVo\.access_token` 客户端链已落地.*真实 challenge.*红灯.*见 2\.10'
A05 = '`APP_FORGOT_PASSWORD` 客户端链已落地.*真实后端、Android 可访问替代与 MuMu 仍红灯.*见 2\.10'
}
@@ -90,12 +90,19 @@ $gSeriesWorkspacePatterns = @{
G01 = 'Task26 工作区红灯已建立.*`/mine`.*尚未接远端.*Task29 unread-count 红灯.*M01.*见 2\.13/5\.6'
G03 = 'Task35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、APP 可信地区与词法 ID 的后端/客户端双红灯.*见 2\.8/5\.12'
G05 = 'Task26 选定 `/overview` 为唯一 owner.*对象级授权和错误语义未关闭.*见 2\.8/5\.3'
G11 = 'Task38 已建立唯一 merge PUT、settingsVersion/If-Match、409 three-way、待审串行化与 unknown 红灯.*见 2\.16/5\.15'
G12 = 'Task39 已建立唯一 GET/PUT、poemSetVersion/If-Match、完整候选集合、unknown 与 CORS owner 红灯.*页面尚未接远端.*见 2\.17/5\.16'
}
$gSeriesJoinPatterns = @{
G06 = 'Task36 搜索.*见 2\.14/5\.13.*Task37 六 operation 邀请红灯.*见 2\.15/5\.14'
G08 = 'Task36 后端红灯.*见 2\.14/5\.13.*Task37 明确邀请不得进入 G08'
G09 = 'mine 四状态、拒绝原因和撤回 CAS.*消息中心承诺待接线时删除'
G10 = 'Task36 固定 APPROVE/REJECT、PENDING CAS.*手机号节点待接线时原子删除'
}
$gSeriesFirstReviewCodes = @('G06', 'G08', 'G09', 'G10', 'G11', 'G12')
$fSeriesReviewPatterns = @{
F01 = '任务 7 已核对.*线上有动态列表/写入能力.*当前静态批次只读'
F01 = 'Task40 已建立唯一 cursor GET、无 PII 投影、逐页鉴权与非泄露 404 红灯.*后端绿前保持 fixture.*见 2\.18/5\.17'
F02 = '线上写请求要求 `feedContent`.*minLength=1.*`feedType/mediaOssIds/sortOrder/status`.*`WRITE_UNAVAILABLE`'
F03 = '线上评论写请求要求 `commentContent`.*minLength=0/maxLength=1000.*不宣称发送成功'
F03 = 'Task40 已拆分详情与一级评论 cursor owner.*评论写仍只做未提交预览.*见 2\.18/5\.17'
F04 = '任务 7 已核对线上列表及写接口.*唯一只读 owner'
F05 = '线上未证明收藏合同.*`articleTitle/articleContent`.*minLength=1'
F06 = '线上 POST/PUT 已存在.*`categoryId/coverOssId` 为 int64.*真实调用未'
@@ -127,7 +134,7 @@ $nmSeriesReviewPatterns = @{
M05 = 'Task34 已建立专用 SaToken 发码、全活动六位码、ALL 会话撤销、outbox 与 credential marker 红灯.*依赖 M04 raw wire.*见 2\.13/5\.11'
M06 = 'Task27 选定列表唯一 owner 并建立红灯.*专用 required、纯文本、仅发布、顺序和认证语义待后端关闭.*见 2\.13/5\.4'
M07 = 'Task25 已接 `POST /genealogy/app/feedback` 严格客户端.*remote 实测与 MuMu 待认证门禁关闭.*见 2\.13'
M08 = '线上无邀请码签发/校验/直入合同.*删除码值、复制和海报伪能力.*见 2\.13'
M08 = 'Task37 六 operation 邀请红灯.*见 2\.15/5\.14.*通过前保持不可用且不展示假码'
M09 = '套餐/订单端点存在但支付闭环未定义.*删除查询参数演示订单.*见 2\.13'
M10 = 'Task9 已完成本机清理基线.*Task32 已建立当前凭证族、幂等 200、logoutCoordinator、required RVoid 与部署复用红灯.*见 2\.13/5\.9'
}
@@ -136,7 +143,7 @@ $prematureRows = @(
if ($authSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $authSeriesReviewPatterns[$_.Code] }
if ($_.Code -eq 'T01') { return $_.Line -notmatch '专项已核对:现有递归 `LineagePersonTreeView` 不满足' }
if ($gSeriesWorkspacePatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $gSeriesWorkspacePatterns[$_.Code] }
if ($_.Code -in $gSeriesFirstReviewCodes) { return $_.Line -notmatch '第一轮已核对' -or $_.Line -notmatch '见 2\.8' }
if ($gSeriesJoinPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $gSeriesJoinPatterns[$_.Code] }
if ($fSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $fSeriesReviewPatterns[$_.Code] }
if ($rSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $rSeriesReviewPatterns[$_.Code] }
if ($nmSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $nmSeriesReviewPatterns[$_.Code] }
@@ -181,11 +188,58 @@ foreach ($requiredFact in @(
'receipt→mine cache→context→G05',
'API-G03-001',
'API-G03-005',
'AppGenealogySettingsUpdateBody',
'GenealogySettingsVersion',
'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED',
'API-SETTINGS-001',
'API-SETTINGS-006',
'ACTIVE_PENDING_APPLICATIONS',
'ATOMIC_SINGLE_WINNER',
'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE',
'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS',
'canonical 设置实际变化',
'内部 LF',
'控制字符',
'exact local',
'旁路详情',
'nullable',
'真实 JSON 数组',
'允许非语义 tracing header',
'禁止 ETag',
'traceparent/tracestate/x-request-id/x-correlation-id',
'内联 Header Object',
'非 null 的 string schema',
'readOnly/writeOnly',
'allOf',
'根层',
'PUBLIC_APPLY→MEMBER_ONLY',
'AppGenerationPoemSetUpdateBody',
'GenerationPoemSetSnapshot',
'GenerationPoemSetVersion',
'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED',
'API-POEM-001',
'API-POEM-006',
'APP_GATEWAY_PREFLIGHT',
'VALIDATE_FINAL_ACTIVE_CAPACITY',
'FRESH_GET_THREE_WAY_NO_AUTO_PUT',
'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED',
'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58',
'API-FEED-READ-001',
'API-FEED-READ-006',
'appListFamilyFeeds',
'appGetFamilyFeed',
'appListFamilyFeedRootComments',
'AppFamilyFeedReadItem',
'AppFamilyFeedRootCommentReadItem',
'UPPER_BOUND_KEYSET_LATEST_VISIBLE',
'FAMILY_FEED_NOT_AVAILABLE',
'hasMedia',
'A01、A04、A05 已接入同一个 `TacVerification`',
'`APP_SMS_LOGIN`',
'`APP_REGISTER`',
'`APP_FORGOT_PASSWORD`',
'密码登录入口保持不可用',
'开发/联调 wire 已按线上',
'`validToken` 未进入或被服务端消费',
'不能把本地滑动成功冒充服务端验证',
'聚焦密码框',
'审核中记录只展示进度和“撤回申请”',
+625
View File
@@ -0,0 +1,625 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$operations = @(
[pscustomobject]@{ Path='/genealogy/app/genealogies/{genealogyId}/invite-tickets/mine'; Method='get'; Id='appListMyGenealogyInviteTickets'; Responses=@('200','400','401','403','404','429','500'); Success='#/components/schemas/RGenealogyInviteTicketList'; Parameters=@('header:clientid','path:genealogyId') },
[pscustomobject]@{ Path='/genealogy/app/genealogies/{genealogyId}/invite-tickets'; Method='post'; Id='appIssueGenealogyInviteTicket'; Responses=@('200','400','401','403','404','409','422','429','500'); Success='#/components/schemas/RIssuedGenealogyInviteTicket'; Parameters=@('header:clientid','header:Idempotency-Key','path:genealogyId') },
[pscustomobject]@{ Path='/genealogy/app/genealogies/{genealogyId}/invite-tickets/{inviteTicketId}'; Method='delete'; Id='appRevokeGenealogyInviteTicket'; Responses=@('200','400','401','403','404','409','429','500'); Success='#/components/schemas/RRevokedGenealogyInviteTicket'; Parameters=@('header:clientid','path:genealogyId','path:inviteTicketId') },
[pscustomobject]@{ Path='/genealogy/app/genealogy-invite-tickets/resolve'; Method='post'; Id='appResolveGenealogyInviteTicket'; Responses=@('200','400','401','404','409','429','500'); Success='#/components/schemas/RResolvedGenealogyInviteTicket'; Parameters=@('header:clientid') },
[pscustomobject]@{ Path='/genealogy/app/genealogy-invite-redemptions'; Method='post'; Id='appRedeemGenealogyInviteTicket'; Responses=@('200','400','401','404','409','422','429','500'); Success='#/components/schemas/RGenealogyInviteRedemptionReceipt'; Parameters=@('header:clientid','header:Genealogy-Redemption-Token','header:Idempotency-Key') },
[pscustomobject]@{ Path='/genealogy/app/genealogy-invite-redemption-requests/{requestKey}'; Method='get'; Id='appGetGenealogyInviteRedemptionRequest'; Responses=@('200','400','401','404','429','500'); Success='#/components/schemas/RGenealogyInviteRedemptionRequestStatus'; Parameters=@('header:clientid','path:requestKey') }
)
function Add-Issue([string]$Message) { $script:issues.Add($Message) }
function Is-True([object]$Value) { return $Value -is [System.Boolean] -and $Value }
function Get-Schema([string]$Name) {
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) { Add-Issue "missing schema owner: $Name"; return $null }
return $property.Value
}
function Get-Operation([string]$Path, [string]$Method) {
$pathProperty = $document.paths.PSObject.Properties[$Path]
$operationProperty = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
if (-not $operationProperty) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path"; return $null }
return $operationProperty.Value
}
function Get-Parameters([string]$Path, [object]$Operation) {
if (-not $Operation) { return @() }
$pathItem = $document.paths.PSObject.Properties[$Path].Value
$parameters = [ordered]@{}
foreach ($scope in @(@($pathItem.parameters), @($Operation.parameters))) {
$scopeKeys = @{}
foreach ($parameter in $scope) {
if (-not $parameter) { continue }
$resolved = $parameter
if ($parameter.'$ref') {
$ownerName = ([string]$parameter.'$ref').Split('/')[-1]
$owner = $document.components.parameters.PSObject.Properties[$ownerName]
if (-not $owner) { Add-Issue "missing parameter owner: $ownerName"; continue }
$resolved = $owner.Value
}
$key = "$($resolved.in):$($resolved.name)".ToLowerInvariant()
if ($scopeKeys.ContainsKey($key)) { Add-Issue "$Path duplicates parameter in one scope: $key"; continue }
$scopeKeys[$key] = $true
$parameters[$key] = $resolved
}
}
return @($parameters.Values)
}
function Get-Parameter([string]$Path, [object]$Operation, [string]$In, [string]$Name) {
$matches = @(Get-Parameters $Path $Operation | Where-Object { $_.in -eq $In -and $_.name -eq $Name })
if ($matches.Count -ne 1) { Add-Issue "$($Operation.operationId) must define exactly one ${In}:${Name}"; return $null }
return $matches[0]
}
function Assert-ClosedObject([object]$Schema, [string]$Name, [string[]]$Fields, [string[]]$Required) {
if (-not $Schema) { return }
$actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
if ($Schema.type -ne 'object' -or -not ($Schema.additionalProperties -is [System.Boolean] -and -not $Schema.additionalProperties) -or
($actualFields -join ',') -ne ((@($Fields | Sort-Object)) -join ',') -or
($actualRequired -join ',') -ne ((@($Required | Sort-Object)) -join ',')) {
Add-Issue "$Name must be closed; fields=$($Fields -join ','); required=$($Required -join ',')"
}
}
function Assert-StringOwner([string]$Name, [int]$Min, [int]$Max, [string]$Pattern) {
$schema = Get-Schema $Name
if (-not $schema) { return }
if ($schema.type -ne 'string' -or [int]$schema.minLength -ne $Min -or [int]$schema.maxLength -ne $Max -or [string]$schema.pattern -ne $Pattern) {
Add-Issue "$Name lexical contract drifted"
}
}
function Assert-Ref([object]$Schema, [string]$Name, [string]$Field, [string]$Ref) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property -or [string]$property.Value.'$ref' -ne $Ref) { Add-Issue "$Name.$Field must use $Ref" }
}
function Assert-SingleEnum([object]$Schema, [string]$Name, [string]$Field, [string]$Value) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property -or $property.Value.type -ne 'string' -or @($property.Value.enum).Count -ne 1 -or $property.Value.enum[0] -ne $Value) {
Add-Issue "$Name.$Field must be the single value $Value"
}
}
function Assert-Union([object]$Schema, [string]$Name, [string]$Discriminator, [hashtable]$Mapping) {
if (-not $Schema) { return }
$expectedRefs = @($Mapping.Values | Sort-Object -Unique)
$actualRefs = @($Schema.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$expectedKeys = @($Mapping.Keys | Sort-Object)
$actualKeys = @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object)
if ($Schema.discriminator.propertyName -ne $Discriminator -or ($actualRefs -join ',') -ne ($expectedRefs -join ',') -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')) {
Add-Issue "$Name discriminator/oneOf contract drifted"
}
foreach ($key in $expectedKeys) {
if ([string]$Schema.discriminator.mapping.$key -ne [string]$Mapping[$key]) { Add-Issue "$Name mapping $key drifted" }
}
foreach ($forbidden in @('properties','required','allOf','anyOf','additionalProperties')) {
if ($Schema.PSObject.Properties[$forbidden]) { Add-Issue "$Name union wrapper must not define $forbidden" }
}
}
function Assert-Envelope([string]$Name, [string]$DataRef) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('code','data') @('code','data')
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne 200) { Add-Issue "$Name.code must be integer enum [200]" }
Assert-Ref $schema $Name 'data' $DataRef
}
function Assert-ErrorEnvelope([string]$Name, [int]$Status, [string[]]$BusinessCodes, [string[]]$ExtraFields = @(), [hashtable]$ExtraRefs = @{}) {
$schema = Get-Schema $Name
$fields = @('code','businessCode') + @($ExtraFields)
Assert-ClosedObject $schema $Name $fields $fields
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne $Status) { Add-Issue "$Name.code must be integer enum [$Status]" }
$actualCodes = @($schema.properties.businessCode.enum | Sort-Object)
if ($schema.properties.businessCode.type -ne 'string' -or ($actualCodes -join ',') -ne ((@($BusinessCodes | Sort-Object)) -join ',')) { Add-Issue "$Name.businessCode enum drifted" }
foreach ($field in $ExtraRefs.Keys) { Assert-Ref $schema $Name $field $ExtraRefs[$field] }
}
function Assert-DateTime([object]$Schema, [string]$Name, [string]$Field) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property -or $property.Value.type -ne 'string' -or $property.Value.format -ne 'date-time') { Add-Issue "$Name.$Field must be RFC3339 date-time" }
}
function Assert-PathRef([string]$Path, [object]$Operation, [string]$Name, [string]$Ref) {
if (-not $Operation) { return }
$parameter = Get-Parameter $Path $Operation 'path' $Name
if ($parameter -and (-not (Is-True $parameter.required) -or [string]$parameter.schema.'$ref' -ne $Ref)) { Add-Issue "$($Operation.operationId) $Name must be required and use $Ref" }
}
function Resolve-Response([object]$Response) {
if (-not $Response) { return $null }
if ($Response.'$ref') {
$name = ([string]$Response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "missing response owner: $name"; return $null }
return $owner.Value
}
return $Response
}
function Expected-ErrorRef([string]$OperationId, [string]$Status) {
if ($OperationId -eq 'appGetGenealogyInviteRedemptionRequest' -and $Status -eq '404') { return '#/components/schemas/RGenealogyInviteRedemptionRequestNotAvailable' }
if ($Status -eq '409') {
if ($OperationId -eq 'appIssueGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteIssueConflict' }
if ($OperationId -eq 'appRevokeGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteStateConflict' }
if ($OperationId -eq 'appResolveGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteViewerConflict' }
if ($OperationId -eq 'appRedeemGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteRedemptionConflict' }
}
$operationSpecific = @{
appListMyGenealogyInviteTickets = @{ '400'='RGenealogyInviteListBadRequest'; '404'='RGenealogyInviteListNotFound' }
appIssueGenealogyInviteTicket = @{ '400'='RGenealogyInviteIssueBadRequest'; '404'='RGenealogyInviteIssueNotFound'; '422'='RGenealogyInviteIssueUnprocessable' }
appRevokeGenealogyInviteTicket = @{ '400'='RGenealogyInviteRevokeBadRequest'; '404'='RGenealogyInviteRevokeNotFound' }
appResolveGenealogyInviteTicket = @{ '400'='RGenealogyInviteResolveBadRequest'; '404'='RGenealogyInviteTicketNotAvailable' }
appRedeemGenealogyInviteTicket = @{ '400'='RGenealogyInviteRedemptionBadRequest'; '404'='RGenealogyInviteGrantNotAvailable'; '422'='RGenealogyInviteRedemptionUnprocessable' }
appGetGenealogyInviteRedemptionRequest = @{ '400'='RGenealogyInviteRedemptionStatusBadRequest' }
}
$specific = $operationSpecific[$OperationId][$Status]
if ($specific) { return "#/components/schemas/$specific" }
return @{
'401'='#/components/schemas/RGenealogyInviteUnauthorized'; '403'='#/components/schemas/RGenealogyInviteForbidden';
'429'='#/components/schemas/RGenealogyInviteRateLimited'; '500'='#/components/schemas/RGenealogyInviteServerError'
}[$Status]
}
function Assert-OperationResponses([object]$Operation, [object]$Entry) {
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
if (($actual -join ',') -ne ((@($Entry.Responses | Sort-Object)) -join ',')) { Add-Issue "$($Entry.Id) response set drifted" }
foreach ($status in $actual) {
if ($status -eq 'default' -or $status -match '^3') { Add-Issue "$($Entry.Id) must not use default or 3xx" }
$response = Resolve-Response $Operation.responses.PSObject.Properties[$status].Value
if (-not $response) { continue }
$media = if ($response.content) { $response.content.PSObject.Properties['application/json'] } else { $null }
if (-not $media -or $response.content.PSObject.Properties.Count -ne 1) { Add-Issue "$($Entry.Id) $status must use only application/json" }
$expected = if ($status -eq '200') { $Entry.Success } else { Expected-ErrorRef $Entry.Id $status }
if ($media -and $expected -and [string]$media.Value.schema.'$ref' -ne $expected) { Add-Issue "$($Entry.Id) $status must use $expected" }
$cache = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $cache -or [string]$cache.Value.'$ref' -ne '#/components/headers/PrivateNoStore') { Add-Issue "$($Entry.Id) $status must use PrivateNoStore" }
if ($status -eq '429' -or ($Entry.Id -eq 'appGetGenealogyInviteRedemptionRequest' -and $status -eq '404')) {
$retry = if ($response.headers) { $response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $retry -or [string]$retry.Value.'$ref' -ne '#/components/headers/RetryAfter') { Add-Issue "$($Entry.Id) $status must use RetryAfter" }
}
}
}
function Assert-RequestBody([object]$Operation, [string]$OperationId, [string]$Ref) {
if (-not $Operation) { return }
$content = $Operation.requestBody.content
$media = if ($content) { $content.PSObject.Properties['application/json'] } else { $null }
if (-not (Is-True $Operation.requestBody.required) -or -not $media -or $content.PSObject.Properties.Count -ne 1 -or [string]$media.Value.schema.'$ref' -ne $Ref) {
Add-Issue "$OperationId must require only application/json with $Ref"
}
}
$parity = @(& node (Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js') 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parity) { Add-Issue "protected JSON/YAML parity failed: $($parity -join ' | ')" }
$resolved = @{}
foreach ($entry in $operations) {
$operation = Get-Operation $entry.Path $entry.Method
$resolved[$entry.Id] = $operation
if ($operation -and $operation.operationId -ne $entry.Id) { Add-Issue "$($entry.Method.ToUpperInvariant()) $($entry.Path) operationId must be $($entry.Id)" }
if ($operation) {
$security = @($operation.security)
if ($security.Count -ne 1 -or $security[0].PSObject.Properties.Count -ne 1 -or $security[0].PSObject.Properties.Name -notcontains 'SaToken') { Add-Issue "$($entry.Id) must require SaToken" }
$actualParameters = @(Get-Parameters $entry.Path $operation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
if (($actualParameters -join ',') -ne ((@($entry.Parameters | Sort-Object)) -join ',')) { Add-Issue "$($entry.Id) parameter set drifted: $($actualParameters -join ',')" }
$client = Get-Parameter $entry.Path $operation 'header' 'clientid'
if ($client -and (-not (Is-True $client.required) -or $client.schema.type -ne 'string' -or [int]$client.schema.minLength -lt 1)) { Add-Issue "$($entry.Id) clientid must be required and non-empty" }
}
Assert-OperationResponses $operation $entry
}
foreach ($entry in $operations) {
$operation = $resolved[$entry.Id]
if ($entry.Path -match '\{genealogyId\}') { Assert-PathRef $entry.Path $operation 'genealogyId' '#/components/schemas/GenealogyId' }
if ($entry.Path -match '\{inviteTicketId\}') { Assert-PathRef $entry.Path $operation 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' }
}
foreach ($entry in $operations) {
$duplicates = @($document.paths.PSObject.Properties | ForEach-Object { $_.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') -and $_.Value.operationId -eq $entry.Id } })
if ($duplicates.Count -ne 1) { Add-Issue "operationId must be globally unique: $($entry.Id)" }
}
$headerOwners = if ($document.components.PSObject.Properties['headers']) { $document.components.headers } else { $null }
$privateHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['PrivateNoStore'] } else { $null }
$privateHeader = if ($privateHeaderProperty) { $privateHeaderProperty.Value } else { $null }
if (-not $privateHeader -or $privateHeader.schema.type -ne 'string' -or @($privateHeader.schema.enum).Count -ne 1 -or $privateHeader.schema.enum[0] -ne 'private, no-store') { Add-Issue 'PrivateNoStore header must be fixed private, no-store' }
$retryHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['RetryAfter'] } else { $null }
$retryHeader = if ($retryHeaderProperty) { $retryHeaderProperty.Value } else { $null }
if (-not $retryHeader -or $retryHeader.schema.type -ne 'integer' -or [int]$retryHeader.schema.minimum -ne 1 -or [int]$retryHeader.schema.maximum -ne 120) { Add-Issue 'RetryAfter header must be integer 1..120 seconds' }
Assert-StringOwner 'GenealogyInviteTicketId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'GenealogyMembershipId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'GenealogyId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'GenealogyInviteIssueRequestKey' 40 61 '^gii\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$'
Assert-StringOwner 'GenealogyInviteRedemptionRequestKey' 40 61 '^gir\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$'
Assert-StringOwner 'GenealogyInviteCode' 37 37 '^JPI1(?:-[0-9A-HJKMNP-TV-Z]{4}){6}-[0-9A-HJKMNP-TV-Z]{2}$'
Assert-StringOwner 'GenealogyRedemptionToken' 43 512 '^[A-Za-z0-9_-]+$'
foreach ($keyName in @('GenealogyInviteIssueRequestKey','GenealogyInviteRedemptionRequestKey')) {
$keyOwner = Get-Schema $keyName
if ($keyOwner -and ($keyOwner.'x-issued-at-source' -ne 'KEY_EPOCH_MILLISECONDS' -or [int]$keyOwner.'x-accept-window-seconds' -ne 600 -or [int]$keyOwner.'x-max-future-skew-seconds' -ne 300 -or [int]$keyOwner.'x-random-min-bits' -ne 128)) { Add-Issue "$keyName timing/random extensions drifted" }
}
$codeOwner = Get-Schema 'GenealogyInviteCode'
if ($codeOwner -and (-not (Is-True $codeOwner.'x-sensitive') -or [int]$codeOwner.'x-random-min-bits' -ne 128 -or [int]$codeOwner.'x-valid-for-seconds' -ne 86400 -or [int]$codeOwner.'x-secret-replay-window-seconds' -ne 600 -or $codeOwner.'x-storage' -ne 'HMAC_LOOKUP_KMS_ENCRYPTED_REPLAY_WINDOW')) { Add-Issue 'GenealogyInviteCode security/lifetime extensions drifted' }
$tokenOwner = Get-Schema 'GenealogyRedemptionToken'
if ($tokenOwner -and (-not (Is-True $tokenOwner.'x-sensitive') -or [int]$tokenOwner.'x-random-min-bits' -ne 128 -or $tokenOwner.'x-storage' -ne 'HMAC_ONLY' -or [int]$tokenOwner.'x-max-ttl-seconds' -ne 300 -or $tokenOwner.'x-binding' -ne 'tenant,account,client,inviteTicketId,ticketVersion,genealogyId,authorizationEpoch')) { Add-Issue 'GenealogyRedemptionToken sensitivity/binding drifted' }
$issue = $resolved['appIssueGenealogyInviteTicket']
if ($issue) {
if ($issue.PSObject.Properties['requestBody']) { Add-Issue 'issue POST must not accept configurable body in the single-use 24-hour first release' }
$key = Get-Parameter $operations[1].Path $issue 'header' 'Idempotency-Key'
if ($key -and (-not (Is-True $key.required) -or [string]$key.schema.'$ref' -ne '#/components/schemas/GenealogyInviteIssueRequestKey')) { Add-Issue 'issue POST must use GenealogyInviteIssueRequestKey' }
if ((@($issue.'x-idempotency-scope') -join ',') -ne 'method,path,genealogyId,tenant,account,client' -or
$issue.'x-secret-replay-policy' -ne 'SAME_SECRET_WITHIN_600_SECONDS_THEN_METADATA' -or $issue.'x-secret-replay-storage' -ne 'KMS_ENVELOPE_600_SECONDS' -or
$issue.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED' -or [int]$issue.'x-ticket-usage-limit' -ne 1 -or [int]$issue.'x-ticket-ttl-seconds' -ne 86400) {
Add-Issue 'issue POST idempotency/single-use/lifetime extensions drifted'
}
if ($issue.'x-required-capability' -ne 'INVITE_MEMBER' -or $issue.'x-active-ticket-constraint' -ne 'DATABASE_MAX_20_PER_TENANT_GENEALOGY_ISSUER' -or
(@($issue.'x-revalidates') -join ',') -ne 'GENEALOGY_READY,INVITATIONS_ENABLED,ISSUER_MEMBERSHIP,ISSUER_CAPABILITY' -or
$issue.'x-secret-replay-storage' -ne 'KMS_ENVELOPE_600_SECONDS' -or $issue.'x-secret-after-window' -ne 'ISSUED_SECRET_UNAVAILABLE_NO_NEW_TICKET') {
Add-Issue 'issue capability/constraint/secret-recovery extensions drifted'
}
if ($issue.'x-existing-key-precedence' -ne 'DIFFERENT_DIGEST_THEN_SECRET_WINDOW_THEN_METADATA' -or
$issue.'x-first-claim-after-accept-window' -ne 'OPERATION_KEY_EXPIRED_ONLY_IF_ABSENT' -or [int]$issue.'x-control-record-retention-days' -lt 30 -or
$issue.'x-atomic-commit' -ne 'KMS_CIPHERTEXT,TICKET,HMAC_LOOKUP,REQUEST_RECEIPT' -or $issue.'x-concurrent-same-key' -ne 'LOSER_READS_WINNER_RECEIPT' -or
$issue.'x-fault-injection-effects' -ne 'ALL_OR_ZERO_TICKET' -or $issue.'x-client-marker' -ne 'sessionEpoch,genealogyId,requestKey,startedAt') { Add-Issue 'issue precedence/atomic control-record extensions drifted' }
}
$list = $resolved['appListMyGenealogyInviteTickets']
if ($list -and $list.PSObject.Properties['requestBody']) { Add-Issue 'ticket list GET must not define a request body' }
if ($list -and ($list.'x-required-capability' -ne 'INVITE_MEMBER' -or $list.'x-issuer-scope' -ne 'TENANT_GENEALOGY_ISSUER_ACCOUNT_EQUALS_ACTOR' -or (@($list.'x-visible-ticket-states') -join ',') -ne 'ACTIVE' -or -not (Is-True $list.'x-never-returns-secret'))) { Add-Issue 'ticket list capability/visibility extensions drifted' }
$revoke = $resolved['appRevokeGenealogyInviteTicket']
if ($revoke) {
if ($revoke.PSObject.Properties['requestBody']) { Add-Issue 'revoke DELETE must not define a request body' }
if ($revoke.'x-required-capability' -ne 'INVITE_MEMBER' -or $revoke.'x-issuer-scope' -ne 'TENANT_GENEALOGY_ISSUER_ACCOUNT_EQUALS_ACTOR' -or $revoke.'x-cross-issuer-error' -ne 'NON_DISCLOSING_404' -or $revoke.'x-ticket-cas' -ne 'ACTIVE_TO_REVOKED' -or
-not (Is-True $revoke.'x-same-revoke-replays-receipt') -or $revoke.'x-race-winner' -ne 'REVOKE_OR_REDEEM_EXACTLY_ONE' -or
(@($revoke.'x-revalidates') -join ',') -ne 'ISSUER_MEMBERSHIP,ISSUER_CAPABILITY,AUTHORIZATION_EPOCH') {
Add-Issue 'revoke capability/CAS/replay/race extensions drifted'
}
}
$resolve = $resolved['appResolveGenealogyInviteTicket']
if ($resolve) { Assert-RequestBody $resolve 'appResolveGenealogyInviteTicket' '#/components/schemas/GenealogyInviteResolveBody' }
$resolveBody = Get-Schema 'GenealogyInviteResolveBody'
Assert-ClosedObject $resolveBody 'GenealogyInviteResolveBody' @('inviteCode') @('inviteCode')
Assert-Ref $resolveBody 'GenealogyInviteResolveBody' 'inviteCode' '#/components/schemas/GenealogyInviteCode'
if ($resolve -and ((@($resolve.'x-domain-effects') -join ',') -ne 'NONE' -or $resolve.'x-resolve-effects' -ne 'REDEMPTION_GRANT_ONLY' -or -not (Is-True $resolve.'x-ticket-not-consumed') -or
$resolve.'x-grant-write-policy' -ne 'ROTATE_ONE_ACTIVE_PER_BOUND_SUBJECT' -or [int]$resolve.'x-grant-ttl-seconds' -ne 300 -or $resolve.'x-grant-expiry-cleanup' -ne 'DELETE_OR_IRREVERSIBLE_EXPIRE' -or
$resolve.'x-not-available-error' -ne 'INVITE_TICKET_NOT_AVAILABLE' -or
(@($resolve.'x-rate-limit-dimensions') -join ',') -ne 'account,device,ip,codePrefix,global' -or $resolve.'x-timing-side-channel-policy' -ne 'UNIFORM_NOT_AVAILABLE' -or
$resolve.'x-grant-invalidated-by' -ne 'INPUT_EDIT,SESSION_EPOCH,REVOKE,EXPIRE,TICKET_VERSION,AUTHORIZATION_EPOCH,INVITATIONS_DISABLED')) { Add-Issue 'resolve must have no ticket consumption/member write and only bounded grant security writes' }
$redeem = $resolved['appRedeemGenealogyInviteTicket']
if ($redeem) {
if ($redeem.PSObject.Properties['requestBody']) { Add-Issue 'redeem must not accept applicantName, relationDesc, applyReason, inviteCode, or another body' }
$redemptionToken = Get-Parameter $operations[4].Path $redeem 'header' 'Genealogy-Redemption-Token'
if ($redemptionToken -and (-not (Is-True $redemptionToken.required) -or [string]$redemptionToken.schema.'$ref' -ne '#/components/schemas/GenealogyRedemptionToken' -or -not (Is-True $redemptionToken.'x-sensitive'))) { Add-Issue 'redeem must require sensitive Genealogy-Redemption-Token' }
$requestKey = Get-Parameter $operations[4].Path $redeem 'header' 'Idempotency-Key'
if ($requestKey -and (-not (Is-True $requestKey.required) -or [string]$requestKey.schema.'$ref' -ne '#/components/schemas/GenealogyInviteRedemptionRequestKey')) { Add-Issue 'redeem must use GenealogyInviteRedemptionRequestKey' }
if ((@($redeem.'x-domain-transaction-effects') -join ',') -ne 'INVITE_TICKET_CONSUMED,MEMBER_RELATION,SUCCEEDED_RECEIPT' -or
(@($redeem.'x-member-unique-scope') -join ',') -ne 'tenant,genealogyId,account' -or $redeem.'x-ticket-cas' -ne 'ACTIVE_TO_CONSUMED' -or
-not (Is-True $redeem.'x-no-join-application') -or -not (Is-True $redeem.'x-same-request-replays-receipt') -or
$redeem.'x-active-pending-policy' -ne 'REJECT_ACTIVE_PENDING_APPLICATION') { Add-Issue 'redeem atomic membership/CAS/application-isolation extensions drifted' }
if ((@($redeem.'x-idempotency-scope') -join ',') -ne 'method,path,tenant,account,client,redemptionTokenIdentity,inviteTicketId,genealogyId' -or
$redeem.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED' -or
(@($redeem.'x-transaction-revalidates') -join ',') -ne 'TOKEN_BINDING,TOKEN_EXPIRY,TICKET_VERSION,TICKET_ACTIVE,TICKET_NOT_EXPIRED,TICKET_UNUSED,GENEALOGY_READY,INVITATIONS_ENABLED,ISSUER_CAPABILITY,ACCOUNT_ELIGIBLE,CAPACITY' -or
(@($redeem.'x-no-consume-errors') -join ',') -ne 'ALREADY_MEMBER,ACTIVE_PENDING_APPLICATION,ACCOUNT_NOT_ELIGIBLE,INVITE_TICKET_NOT_AVAILABLE' -or
$redeem.'x-concurrency-policy' -ne 'TICKET_AND_MEMBER_CONSTRAINTS_BEFORE_CONSUME' -or $redeem.'x-grant-invalidation' -ne 'REVOKE,EXPIRE,TICKET_VERSION,AUTHORIZATION_EPOCH,INVITATIONS_DISABLED') {
Add-Issue 'redeem canonical identity/revalidation/no-consume extensions drifted'
}
}
$status = $resolved['appGetGenealogyInviteRedemptionRequest']
if ($status) {
if ($status.PSObject.Properties['requestBody']) { Add-Issue 'redemption status GET must not define a request body' }
$requestKey = Get-Parameter $operations[5].Path $status 'path' 'requestKey'
if ($requestKey -and (-not (Is-True $requestKey.required) -or [string]$requestKey.schema.'$ref' -ne '#/components/schemas/GenealogyInviteRedemptionRequestKey')) { Add-Issue 'redemption status must use the redemption request-key owner' }
if ((@($status.'x-state-transitions') -join ',') -ne 'ABSENT->PENDING,PENDING->SUCCEEDED,PENDING->FAILED_NO_COMMIT' -or -not (Is-True $status.'x-terminal-immutable') -or -not (Is-True $status.'x-read-only-no-write')) { Add-Issue 'redemption status transition/read-only extensions drifted' }
if ([int]$status.'x-accept-window-seconds' -ne 600 -or [int]$status.'x-max-future-skew-seconds' -ne 300 -or [int]$status.'x-resolve-sla-seconds' -ne 120 -or
$status.'x-absent-before-deadline' -ne '404_WITH_ACCEPT_UNTIL_AND_RETRY_AFTER' -or $status.'x-absent-after-deadline' -ne 'COMPUTED_FAILED_NO_COMMIT_NO_WRITE' -or
$status.'x-subject-isolation' -ne 'TENANT_ACCOUNT_CLIENT_404' -or $status.'x-fencing-policy' -ne 'WATCHDOG_AND_WORKER_TERMINAL_CAS') {
Add-Issue 'redemption status timing/isolation/fencing extensions drifted'
}
if ($status.'x-existing-terminal-precedence' -ne 'RETURN_STORED_TERMINAL_BEFORE_ABSENT_COMPUTATION' -or [int]$status.'x-terminal-receipt-retention-days' -lt 30 -or
[int]$status.'x-client-marker-ttl-days' -ne 30 -or $status.'x-cleanup-order' -ne 'CLIENT_MARKER_EXPIRES_BEFORE_TERMINAL_RECEIPT' -or
$status.'x-post-cleanup-safety' -ne 'NEVER_INFER_FAILED_AGAINST_COMMITTED_DOMAIN_EFFECTS') { Add-Issue 'redemption terminal retention/cleanup safety extensions drifted' }
}
$listItem = Get-Schema 'GenealogyInviteTicketMetadata'
Assert-ClosedObject $listItem 'GenealogyInviteTicketMetadata' @('inviteTicketId','state','issuedAt','expiresAt','usageLimit') @('inviteTicketId','state','issuedAt','expiresAt','usageLimit')
Assert-Ref $listItem 'GenealogyInviteTicketMetadata' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId'
Assert-SingleEnum $listItem 'GenealogyInviteTicketMetadata' 'state' 'ACTIVE'
Assert-DateTime $listItem 'GenealogyInviteTicketMetadata' 'issuedAt'
Assert-DateTime $listItem 'GenealogyInviteTicketMetadata' 'expiresAt'
if ($listItem -and ($listItem.properties.usageLimit.type -ne 'integer' -or @($listItem.properties.usageLimit.enum).Count -ne 1 -or $listItem.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'GenealogyInviteTicketMetadata.usageLimit must be integer enum [1]' }
if ($listItem -and ($listItem.properties.PSObject.Properties['inviteCode'] -or $listItem.properties.PSObject.Properties['inviteeUserId'])) { Add-Issue 'ticket metadata must not expose inviteCode or invitee identity' }
$listModel = Get-Schema 'GenealogyInviteTicketList'
Assert-ClosedObject $listModel 'GenealogyInviteTicketList' @('items') @('items')
if ($listModel -and ($listModel.properties.items.type -ne 'array' -or [string]$listModel.properties.items.items.'$ref' -ne '#/components/schemas/GenealogyInviteTicketMetadata' -or [int]$listModel.properties.items.maxItems -ne 20)) { Add-Issue 'GenealogyInviteTicketList must contain at most 20 active metadata items' }
$issued = Get-Schema 'IssuedGenealogyInviteTicket'
Assert-ClosedObject $issued 'IssuedGenealogyInviteTicket' @('inviteTicketId','inviteCode','state','issuedAt','expiresAt','usageLimit') @('inviteTicketId','inviteCode','state','issuedAt','expiresAt','usageLimit')
Assert-Ref $issued 'IssuedGenealogyInviteTicket' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId'
Assert-Ref $issued 'IssuedGenealogyInviteTicket' 'inviteCode' '#/components/schemas/GenealogyInviteCode'
Assert-SingleEnum $issued 'IssuedGenealogyInviteTicket' 'state' 'ACTIVE'
Assert-DateTime $issued 'IssuedGenealogyInviteTicket' 'issuedAt'
Assert-DateTime $issued 'IssuedGenealogyInviteTicket' 'expiresAt'
if ($issued -and ($issued.properties.usageLimit.type -ne 'integer' -or @($issued.properties.usageLimit.enum).Count -ne 1 -or $issued.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'IssuedGenealogyInviteTicket.usageLimit must be integer enum [1]' }
if ($issued -and ($issued.'x-expires-at-formula' -ne 'issuedAt+86400s' -or [int]$issued.'x-secret-replay-window-seconds' -ne 600)) { Add-Issue 'IssuedGenealogyInviteTicket expiry/replay-window formula drifted' }
$issuedSnapshot = Get-Schema 'GenealogyInviteIssuedTicketSnapshot'
Assert-ClosedObject $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' @('inviteTicketId','issuedAt','expiresAt','usageLimit') @('inviteTicketId','issuedAt','expiresAt','usageLimit')
Assert-Ref $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId'
Assert-DateTime $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'issuedAt'
Assert-DateTime $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'expiresAt'
if ($issuedSnapshot -and ($issuedSnapshot.properties.usageLimit.type -ne 'integer' -or @($issuedSnapshot.properties.usageLimit.enum).Count -ne 1 -or $issuedSnapshot.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'GenealogyInviteIssuedTicketSnapshot.usageLimit must be integer enum [1]' }
$revoked = Get-Schema 'RevokedGenealogyInviteTicket'
Assert-ClosedObject $revoked 'RevokedGenealogyInviteTicket' @('inviteTicketId','state','revokedAt') @('inviteTicketId','state','revokedAt')
Assert-Ref $revoked 'RevokedGenealogyInviteTicket' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId'
Assert-SingleEnum $revoked 'RevokedGenealogyInviteTicket' 'state' 'REVOKED'
Assert-DateTime $revoked 'RevokedGenealogyInviteTicket' 'revokedAt'
$target = Get-Schema 'GenealogyInviteTarget'
Assert-ClosedObject $target 'GenealogyInviteTarget' @('genealogyId','genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel') @('genealogyId','genealogyName','surname','regionName')
Assert-Ref $target 'GenealogyInviteTarget' 'genealogyId' '#/components/schemas/GenealogyId'
if ($target) {
foreach ($field in @('genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel')) {
$property = $target.properties.PSObject.Properties[$field]
if (-not $property -or $property.Value.type -ne 'string' -or [int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 100 -or $property.Value.'x-text-normalizer' -ne 'INVITE_TARGET_TEXT_V1') { Add-Issue "GenealogyInviteTarget.$field must use INVITE_TARGET_TEXT_V1 and length 1..100" }
}
}
$resolvedModel = Get-Schema 'ResolvedGenealogyInviteTicket'
Assert-ClosedObject $resolvedModel 'ResolvedGenealogyInviteTicket' @('redemptionToken','redemptionExpiresAt','target') @('redemptionToken','redemptionExpiresAt','target')
Assert-Ref $resolvedModel 'ResolvedGenealogyInviteTicket' 'redemptionToken' '#/components/schemas/GenealogyRedemptionToken'
Assert-Ref $resolvedModel 'ResolvedGenealogyInviteTicket' 'target' '#/components/schemas/GenealogyInviteTarget'
Assert-DateTime $resolvedModel 'ResolvedGenealogyInviteTicket' 'redemptionExpiresAt'
$receipt = Get-Schema 'GenealogyInviteRedemptionReceipt'
Assert-ClosedObject $receipt 'GenealogyInviteRedemptionReceipt' @('genealogyId','membershipId','role','status','joinedAt') @('genealogyId','membershipId','role','status','joinedAt')
Assert-Ref $receipt 'GenealogyInviteRedemptionReceipt' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-Ref $receipt 'GenealogyInviteRedemptionReceipt' 'membershipId' '#/components/schemas/GenealogyMembershipId'
Assert-SingleEnum $receipt 'GenealogyInviteRedemptionReceipt' 'role' 'MEMBER'
Assert-SingleEnum $receipt 'GenealogyInviteRedemptionReceipt' 'status' 'JOINED'
Assert-DateTime $receipt 'GenealogyInviteRedemptionReceipt' 'joinedAt'
$requestStatus = Get-Schema 'GenealogyInviteRedemptionRequestStatus'
Assert-Union $requestStatus 'GenealogyInviteRedemptionRequestStatus' 'status' @{
PENDING='#/components/schemas/PendingGenealogyInviteRedemptionRequest'; SUCCEEDED='#/components/schemas/SucceededGenealogyInviteRedemptionRequest'; FAILED_NO_COMMIT='#/components/schemas/FailedGenealogyInviteRedemptionRequest'
}
foreach ($branch in @(
@{ Name='PendingGenealogyInviteRedemptionRequest'; Status='PENDING'; Fields=@('status','resolveBy','retryAfterSeconds'); Required=@('status','resolveBy','retryAfterSeconds') },
@{ Name='SucceededGenealogyInviteRedemptionRequest'; Status='SUCCEEDED'; Fields=@('status','result'); Required=@('status','result') },
@{ Name='FailedGenealogyInviteRedemptionRequest'; Status='FAILED_NO_COMMIT'; Fields=@('status'); Required=@('status') }
)) {
$schema = Get-Schema $branch.Name
Assert-ClosedObject $schema $branch.Name $branch.Fields $branch.Required
Assert-SingleEnum $schema $branch.Name 'status' $branch.Status
}
$pendingStatus = Get-Schema 'PendingGenealogyInviteRedemptionRequest'
Assert-DateTime $pendingStatus 'PendingGenealogyInviteRedemptionRequest' 'resolveBy'
if ($pendingStatus -and ($pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30)) { Add-Issue 'PendingGenealogyInviteRedemptionRequest.retryAfterSeconds must be integer 1..30' }
$succeeded = Get-Schema 'SucceededGenealogyInviteRedemptionRequest'
Assert-Ref $succeeded 'SucceededGenealogyInviteRedemptionRequest' 'result' '#/components/schemas/GenealogyInviteRedemptionReceipt'
$failed = Get-Schema 'FailedGenealogyInviteRedemptionRequest'
if ($failed -and (($failed.'x-domain-effects' -join ',') -ne 'NONE' -or -not ($failed.'x-ticket-consumed' -is [System.Boolean] -and -not $failed.'x-ticket-consumed'))) { Add-Issue 'FAILED_NO_COMMIT must guarantee no ticket consumption or member write' }
$currentTicket = Get-Schema 'GenealogyInviteTicketCurrentState'
Assert-ClosedObject $currentTicket 'GenealogyInviteTicketCurrentState' @('inviteTicketId','state','changedAt') @('inviteTicketId','state','changedAt')
Assert-Ref $currentTicket 'GenealogyInviteTicketCurrentState' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId'
Assert-DateTime $currentTicket 'GenealogyInviteTicketCurrentState' 'changedAt'
if ($currentTicket -and ($currentTicket.properties.state.type -ne 'string' -or (@($currentTicket.properties.state.enum | Sort-Object) -join ',') -ne 'CONSUMED,EXPIRED,REVOKED')) { Add-Issue 'GenealogyInviteTicketCurrentState.state enum drifted' }
Assert-ErrorEnvelope 'RGenealogyInviteListBadRequest' 400 @('INVALID_REQUEST')
Assert-ErrorEnvelope 'RGenealogyInviteIssueBadRequest' 400 @('OPERATION_KEY_INVALID')
Assert-ErrorEnvelope 'RGenealogyInviteRevokeBadRequest' 400 @('INVALID_REQUEST')
Assert-ErrorEnvelope 'RGenealogyInviteResolveBadRequest' 400 @('INVITE_CODE_INVALID')
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionBadRequest' 400 @('OPERATION_KEY_INVALID','REDEMPTION_TOKEN_INVALID')
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionStatusBadRequest' 400 @('OPERATION_KEY_INVALID')
Assert-ErrorEnvelope 'RGenealogyInviteUnauthorized' 401 @('AUTHENTICATION_REQUIRED')
Assert-ErrorEnvelope 'RGenealogyInviteForbidden' 403 @('INVITE_MANAGEMENT_FORBIDDEN')
Assert-ErrorEnvelope 'RGenealogyInviteListNotFound' 404 @('GENEALOGY_NOT_FOUND')
Assert-ErrorEnvelope 'RGenealogyInviteIssueNotFound' 404 @('GENEALOGY_NOT_FOUND')
Assert-ErrorEnvelope 'RGenealogyInviteRevokeNotFound' 404 @('GENEALOGY_NOT_FOUND','INVITE_TICKET_NOT_FOUND')
Assert-ErrorEnvelope 'RGenealogyInviteTicketNotAvailable' 404 @('INVITE_TICKET_NOT_AVAILABLE')
Assert-ErrorEnvelope 'RGenealogyInviteGrantNotAvailable' 404 @('INVITE_TICKET_NOT_AVAILABLE')
$issueConflict = Get-Schema 'RGenealogyInviteIssueConflict'
Assert-Union $issueConflict 'RGenealogyInviteIssueConflict' 'businessCode' @{
ACTIVE_INVITE_TICKET_LIMIT='#/components/schemas/RGenealogyInviteActiveLimitConflict'
IDEMPOTENCY_KEY_REUSED='#/components/schemas/RGenealogyInviteIssueKeyConflict'
OPERATION_KEY_EXPIRED='#/components/schemas/RGenealogyInviteIssueExpiredConflict'
ISSUED_SECRET_UNAVAILABLE='#/components/schemas/RGenealogyInviteIssuedSecretUnavailable'
}
Assert-ErrorEnvelope 'RGenealogyInviteActiveLimitConflict' 409 @('ACTIVE_INVITE_TICKET_LIMIT')
Assert-ErrorEnvelope 'RGenealogyInviteIssueKeyConflict' 409 @('IDEMPOTENCY_KEY_REUSED')
Assert-ErrorEnvelope 'RGenealogyInviteIssueExpiredConflict' 409 @('OPERATION_KEY_EXPIRED')
Assert-ErrorEnvelope 'RGenealogyInviteIssuedSecretUnavailable' 409 @('ISSUED_SECRET_UNAVAILABLE') @('ticket') @{ ticket='#/components/schemas/GenealogyInviteIssuedTicketSnapshot' }
Assert-ErrorEnvelope 'RGenealogyInviteStateConflict' 409 @('INVITE_TICKET_CONSUMED','INVITE_TICKET_EXPIRED') @('current') @{ current='#/components/schemas/GenealogyInviteTicketCurrentState' }
Assert-ErrorEnvelope 'RGenealogyInviteViewerConflict' 409 @('ACTIVE_PENDING_APPLICATION','ALREADY_MEMBER')
$redemptionConflict = Get-Schema 'RGenealogyInviteRedemptionConflict'
Assert-Union $redemptionConflict 'RGenealogyInviteRedemptionConflict' 'businessCode' @{
IDEMPOTENCY_KEY_REUSED='#/components/schemas/RGenealogyInviteRedemptionKeyReusedConflict'
OPERATION_KEY_EXPIRED='#/components/schemas/RGenealogyInviteRedemptionKeyExpiredConflict'
ACTIVE_PENDING_APPLICATION='#/components/schemas/RGenealogyInviteActivePendingConflict'
ALREADY_MEMBER='#/components/schemas/RGenealogyInviteAlreadyMemberConflict'
INVITE_TICKET_STATE_CHANGED='#/components/schemas/RGenealogyInviteRedemptionTicketConflict'
}
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionKeyReusedConflict' 409 @('IDEMPOTENCY_KEY_REUSED')
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionKeyExpiredConflict' 409 @('OPERATION_KEY_EXPIRED')
Assert-ErrorEnvelope 'RGenealogyInviteActivePendingConflict' 409 @('ACTIVE_PENDING_APPLICATION')
Assert-ErrorEnvelope 'RGenealogyInviteAlreadyMemberConflict' 409 @('ALREADY_MEMBER')
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionTicketConflict' 409 @('INVITE_TICKET_STATE_CHANGED') @('current') @{ current='#/components/schemas/GenealogyInviteTicketCurrentState' }
Assert-ErrorEnvelope 'RGenealogyInviteIssueUnprocessable' 422 @('GENEALOGY_NOT_READY','INVITATIONS_DISABLED')
Assert-ErrorEnvelope 'RGenealogyInviteRedemptionUnprocessable' 422 @('ACCOUNT_NOT_ELIGIBLE','CAPACITY_EXCEEDED','GENEALOGY_NOT_READY','INVITATIONS_DISABLED')
Assert-ErrorEnvelope 'RGenealogyInviteRateLimited' 429 @('RATE_LIMITED')
Assert-ErrorEnvelope 'RGenealogyInviteServerError' 500 @('INTERNAL_ERROR')
$statusNotAvailable = Get-Schema 'RGenealogyInviteRedemptionRequestNotAvailable'
Assert-ClosedObject $statusNotAvailable 'RGenealogyInviteRedemptionRequestNotAvailable' @('code','businessCode','acceptUntil') @('code','businessCode','acceptUntil')
if ($statusNotAvailable) {
if ($statusNotAvailable.properties.code.type -ne 'integer' -or @($statusNotAvailable.properties.code.enum).Count -ne 1 -or $statusNotAvailable.properties.code.enum[0] -ne 404 -or
$statusNotAvailable.properties.businessCode.type -ne 'string' -or @($statusNotAvailable.properties.businessCode.enum).Count -ne 1 -or $statusNotAvailable.properties.businessCode.enum[0] -ne 'INVITE_REDEMPTION_REQUEST_NOT_AVAILABLE') { Add-Issue 'redemption status 404 code/businessCode drifted' }
Assert-DateTime $statusNotAvailable 'RGenealogyInviteRedemptionRequestNotAvailable' 'acceptUntil'
}
Assert-Envelope 'RGenealogyInviteTicketList' '#/components/schemas/GenealogyInviteTicketList'
Assert-Envelope 'RIssuedGenealogyInviteTicket' '#/components/schemas/IssuedGenealogyInviteTicket'
Assert-Envelope 'RRevokedGenealogyInviteTicket' '#/components/schemas/RevokedGenealogyInviteTicket'
Assert-Envelope 'RResolvedGenealogyInviteTicket' '#/components/schemas/ResolvedGenealogyInviteTicket'
Assert-Envelope 'RGenealogyInviteRedemptionReceipt' '#/components/schemas/GenealogyInviteRedemptionReceipt'
Assert-Envelope 'RGenealogyInviteRedemptionRequestStatus' '#/components/schemas/GenealogyInviteRedemptionRequestStatus'
function Visit-SensitiveSchema([object]$Schema, [string]$Label, [string]$Trail, [string[]]$Allowed, [string[]]$RefStack, [int]$Depth = 0) {
if (-not $Schema) { return }
if ($Depth -gt 40) { Add-Issue "$Label response schema closure exceeds depth 40 at $Trail"; return }
if ($Schema.'$ref') {
$ref = [string]$Schema.'$ref'
if ($ref -notmatch '^#/components/schemas/') { Add-Issue "$Label uses unsupported response ref: $ref"; return }
$name = $ref.Split('/')[-1]
if ($RefStack -contains $name) { return }
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "$Label response closure is missing schema: $name"; return }
if ((Is-True $owner.Value.'x-sensitive') -and $Allowed -notcontains $Trail) { Add-Issue "$Label response closure exposes sensitive schema $name at $Trail" }
Visit-SensitiveSchema $owner.Value $Label $Trail $Allowed (@($RefStack) + $name) ($Depth + 1)
return
}
if ($Schema.properties) {
foreach ($property in $Schema.properties.PSObject.Properties) {
$propertyTrail = "$Trail.$($property.Name)"
$forbidden = @('inviteCode','redemptionToken','inviteeUserId','inviteeAccountId','issuerUserId','issuerAccountId','inviterUserId','phone','applicantName','relationDesc','applyReason','auditRemark','joinApplication')
if ($forbidden -contains $property.Name -and $Allowed -notcontains $propertyTrail) { Add-Issue "$Label response closure exposes forbidden field: $propertyTrail" }
Visit-SensitiveSchema $property.Value $Label $propertyTrail $Allowed $RefStack ($Depth + 1)
}
}
foreach ($keyword in @('oneOf','allOf','anyOf')) {
foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) { Visit-SensitiveSchema $branch $Label $Trail $Allowed $RefStack ($Depth + 1) }
}
if ($Schema.items) { Visit-SensitiveSchema $Schema.items $Label "$Trail[]" $Allowed $RefStack ($Depth + 1) }
if ($Schema.additionalProperties -and -not ($Schema.additionalProperties -is [System.Boolean])) { Visit-SensitiveSchema $Schema.additionalProperties $Label "$Trail.*" $Allowed $RefStack ($Depth + 1) }
}
foreach ($entry in $operations) {
$operation = $resolved[$entry.Id]
if (-not $operation) { continue }
foreach ($responseProperty in $operation.responses.PSObject.Properties) {
$statusCode = $responseProperty.Name
$response = Resolve-Response $responseProperty.Value
$media = if ($response -and $response.content) { $response.content.PSObject.Properties['application/json'] } else { $null }
if (-not $media) { continue }
$allowed = @()
if ($entry.Id -eq 'appIssueGenealogyInviteTicket' -and $statusCode -eq '200') { $allowed = @('$.data.inviteCode') }
if ($entry.Id -eq 'appResolveGenealogyInviteTicket' -and $statusCode -eq '200') { $allowed = @('$.data.redemptionToken') }
Visit-SensitiveSchema $media.Value.schema "$($entry.Id) $statusCode" '$' $allowed @()
}
}
$joinPath = '/genealogy/app/genealogies/{genealogyId}/join-applies'
$ordinaryJoin = Get-Operation $joinPath 'post'
if ($ordinaryJoin) {
$ordinaryContent = $ordinaryJoin.requestBody.content.PSObject.Properties['application/json']
if (-not $ordinaryContent -or [string]$ordinaryContent.Value.schema.'$ref' -ne '#/components/schemas/AppGenealogyJoinApplicationBody') { Add-Issue 'ordinary join POST must remain owned by AppGenealogyJoinApplicationBody' }
$ordinarySchema = if ($ordinaryContent) { $ordinaryContent.Value.schema } else { $null }
function Visit-OrdinaryJoinSchema([object]$Schema, [string[]]$RefStack = @()) {
if (-not $Schema) { return }
if ($Schema.'$ref') {
$name = ([string]$Schema.'$ref').Split('/')[-1]
if ($name -in @('GenealogyInviteCode','GenealogyRedemptionToken','GenealogyInviteTicketId','GenealogyInviteRedemptionRequestKey')) { Add-Issue "ordinary join schema must not reference invitation owner: $name" }
if ($RefStack -contains $name) { return }
$owner = $document.components.schemas.PSObject.Properties[$name]
if ($owner) { Visit-OrdinaryJoinSchema $owner.Value (@($RefStack) + $name) }
return
}
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if ($property.Name -in @('inviteCode','inviteTicketId','redemptionToken','redemptionRequestKey')) { Add-Issue "ordinary join schema must not consume invitation field: $($property.Name)" }
Visit-OrdinaryJoinSchema $property.Value $RefStack
}
foreach ($keyword in @('oneOf','allOf','anyOf')) { foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) { Visit-OrdinaryJoinSchema $branch $RefStack } }
if ($Schema.items) { Visit-OrdinaryJoinSchema $Schema.items $RefStack }
}
Visit-OrdinaryJoinSchema $ordinarySchema
}
$expectedInvitationOperations = @($operations | ForEach-Object { "$($_.Method) $($_.Path)" } | Sort-Object)
$actualInvitationOperations = @()
function Test-InvitationContractReachable([object]$Schema, [string]$Label, [string[]]$RefStack = @(), [int]$Depth = 0) {
if (-not $Schema) { return $false }
if ($Depth -gt 40) { Add-Issue "$Label contract closure exceeds depth 40"; return $true }
if ($Schema.'$ref') {
$ref = [string]$Schema.'$ref'
if ($ref -notmatch '^#/components/schemas/') { Add-Issue "$Label uses unsupported schema ref: $ref"; return $true }
$name = $ref.Split('/')[-1]
if ($name -in @('GenealogyInviteCode','GenealogyRedemptionToken','GenealogyInviteTicketId','GenealogyInviteIssueRequestKey','GenealogyInviteRedemptionRequestKey')) { return $true }
if ($RefStack -contains $name) { return $false }
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "$Label contract closure is missing schema: $name"; return $true }
return Test-InvitationContractReachable $owner.Value $Label (@($RefStack) + $name) ($Depth + 1)
}
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if ($property.Name -in @('inviteCode','inviteTicketId','redemptionToken','redemptionRequestKey','issueRequestKey')) { return $true }
if (Test-InvitationContractReachable $property.Value $Label $RefStack ($Depth + 1)) { return $true }
}
foreach ($keyword in @('oneOf','allOf','anyOf')) {
foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) {
if (Test-InvitationContractReachable $branch $Label $RefStack ($Depth + 1)) { return $true }
}
}
if ($Schema.items -and (Test-InvitationContractReachable $Schema.items $Label $RefStack ($Depth + 1))) { return $true }
if ($Schema.additionalProperties -and -not ($Schema.additionalProperties -is [System.Boolean]) -and (Test-InvitationContractReachable $Schema.additionalProperties $Label $RefStack ($Depth + 1))) { return $true }
return $false
}
foreach ($pathProperty in $document.paths.PSObject.Properties) {
$path = $pathProperty.Name
foreach ($methodProperty in $pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') }) {
$operation = $methodProperty.Value
foreach ($parameter in @(Get-Parameters $path $operation)) {
$parameterRef = [string]$parameter.schema.'$ref'
if ($parameter.in -in @('path','query') -and ($parameter.name -match '(?i)invite.*code|redemption.*token' -or $parameterRef -in @('#/components/schemas/GenealogyInviteCode','#/components/schemas/GenealogyRedemptionToken'))) { Add-Issue "secret must not appear in path/query parameter: $($methodProperty.Name) $path $($parameter.name)" }
}
$semanticText = "$path $($operation.operationId) $($operation.summary)"
$usesInvitationOwner = $false
foreach ($parameter in @(Get-Parameters $path $operation)) { if (Test-InvitationContractReachable $parameter.schema "$($methodProperty.Name) $path parameter") { $usesInvitationOwner = $true } }
if ($operation.requestBody) {
$requestBody = $operation.requestBody
if ($requestBody.'$ref') {
$requestBodyName = ([string]$requestBody.'$ref').Split('/')[-1]
$requestBodyOwner = $document.components.requestBodies.PSObject.Properties[$requestBodyName]
if (-not $requestBodyOwner) { Add-Issue "$($methodProperty.Name) $path is missing requestBody owner $requestBodyName" } else { $requestBody = $requestBodyOwner.Value }
}
foreach ($media in @($requestBody.content.PSObject.Properties)) { if (Test-InvitationContractReachable $media.Value.schema "$($methodProperty.Name) $path request") { $usesInvitationOwner = $true } }
}
foreach ($responseProperty in @($operation.responses.PSObject.Properties)) {
$response = Resolve-Response $responseProperty.Value
foreach ($media in @($response.content.PSObject.Properties)) { if (Test-InvitationContractReachable $media.Value.schema "$($methodProperty.Name) $path response $($responseProperty.Name)") { $usesInvitationOwner = $true } }
}
$unrelatedInvitation = $path -match '(?i)/ceremonies/' -or $path -match '(?i)/promotions?$' -or $path -match '(?i)/appPromotion(?:/|$)'
$semanticMatch = $semanticText -match '(?i)invite|invitation|direct[-_/ ]?join|join[-_/ ]?by[-_/ ]?code|邀请码|直接加入|凭码加入'
if ($path -like '/genealogy/app/*' -and ($usesInvitationOwner -or (-not $unrelatedInvitation -and $semanticMatch))) { $actualInvitationOperations += "$($methodProperty.Name) $path" }
}
}
if ((@($actualInvitationOperations | Sort-Object) -join ',') -ne ($expectedInvitationOperations -join ',')) { Add-Issue "APP invitation owner set must be exactly six operations; actual=$(@($actualInvitationOperations | Sort-Object) -join ',')" }
if ($issues.Count -gt 0) {
Write-Output 'INVITE-TICKET-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output 'Only accept APP.openapi.json and APP.openapi.yaml re-exported together from one backend version; never hand-edit the protected files.'
exit 1
}
Write-Output 'INVITE-TICKET-OPENAPI-CONTRACT PASS'
+679
View File
@@ -0,0 +1,679 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$operations = @(
[pscustomobject]@{ Path = '/genealogy/app/genealogies/public'; Method = 'get'; Id = 'appSearchPublicGenealogies'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RGenealogySearchCursorPage'; Parameters = @('header:clientid','query:cursor','query:keyword','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies'; Method = 'post'; Id = 'appCreateGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationReceipt'; Parameters = @('header:Idempotency-Key','header:clientid','path:genealogyId') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-apply-requests/{requestKey}'; Method = 'get'; Id = 'appGetGenealogyJoinApplicationRequest'; Responses = @('200','400','401','404','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationRequestStatus'; Parameters = @('header:clientid','path:requestKey') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/mine'; Method = 'get'; Id = 'appListMyGenealogyJoinApplications'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RMyGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','query:cursor','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/{applyId}'; Method = 'delete'; Id = 'appWithdrawGenealogyJoinApplication'; Responses = @('200','400','401','404','409','429','500'); SuccessRef = '#/components/schemas/RWithdrawnGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/pending'; Method = 'get'; Id = 'appListPendingGenealogyJoinApplications'; Responses = @('200','400','401','403','404','429','500'); SuccessRef = '#/components/schemas/RPendingGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','path:genealogyId','query:cursor','query:limit') },
[pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit'; Method = 'put'; Id = 'appReviewGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RReviewedGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId','path:genealogyId') }
)
function Add-Issue([string]$Message) { $script:issues.Add($Message) }
function Test-JsonBoolean([object]$Value, [bool]$Expected) {
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsJsonArray([object]$Value) {
return $null -ne $Value -and $Value.GetType().IsArray
}
function Get-LocalComponentName([string]$Ref, [string]$Section, [string]$Label) {
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "$Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Test-IsNonNullable([object]$Schema) {
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-JsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) {
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "$Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed) {
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureComponentRef([object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label) {
if (-not $Value) { return $false }
$properties = @($Value.PSObject.Properties.Name)
$actualRef = [string]$Value.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "$Label must be the sole exact local ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef $Section $Label)
return $true
}
function Test-IsExactReferenceObjectRef([object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label) {
if (-not $Value) { return $false }
$actualRef = [string]$Value.'$ref'
$semanticSiblings = @($Value.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($actualRef -cne $ExpectedRef -or $semanticSiblings.Count -gt 0) {
Add-Issue "$Label must use exact local ref $ExpectedRef with only harmless summary/description siblings; actual: $actualRef; semantic siblings: $($semanticSiblings -join ',')"
return $false
}
[void](Get-LocalComponentName $actualRef $Section $Label)
return $true
}
function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) {
return Test-IsPureComponentRef $Schema $ExpectedRef 'schemas' $Label
}
function Get-Schema([string]$Name) {
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) { Add-Issue "missing schema owner: $Name"; return $null }
return $property.Value
}
function Get-Operation([string]$Path, [string]$Method) {
$pathProperty = $document.paths.PSObject.Properties[$Path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
if (-not $operation) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path"; return $null }
return $operation.Value
}
function Get-Parameters([string]$Path, [object]$Operation) {
if (-not $Operation) { return @() }
$pathItem = $document.paths.PSObject.Properties[$Path].Value
$byIdentity = [ordered]@{}
foreach ($scope in @(@($pathItem.parameters), @($Operation.parameters))) {
$scopeIdentities = @{}
foreach ($parameter in $scope) {
if (-not $parameter) { continue }
$resolved = $parameter
if ($parameter.'$ref') {
$name = Get-LocalComponentName ([string]$parameter.'$ref') 'parameters' "$Path parameter"
if (-not $name) { continue }
$parameterRefSiblings = @($parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "$Path parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "missing parameter owner: $name"; continue }
$resolved = $owner.Value
}
$identity = "$($resolved.in):$($resolved.name)".ToLowerInvariant()
if ($scopeIdentities.ContainsKey($identity)) { Add-Issue "$Path contains duplicate parameter in one scope: $identity"; continue }
$scopeIdentities[$identity] = $true
$byIdentity[$identity] = $resolved
}
}
return @($byIdentity.Values)
}
function Get-Parameter([string]$Path, [object]$Operation, [string]$In, [string]$Name) {
$matches = @(Get-Parameters $Path $Operation | Where-Object { $_.in -eq $In -and $_.name -eq $Name })
if ($matches.Count -ne 1) { Add-Issue "$($Operation.operationId) must define exactly one ${In}:${Name}"; return $null }
return $matches[0]
}
function Assert-ClosedObject([object]$Schema, [string]$Name, [string[]]$Fields, [string[]]$Required) {
if (-not $Schema) { return }
$actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-JsonBoolean $Schema.additionalProperties $false) -or
($actualFields -join ',') -ne ((@($Fields | Sort-Object)) -join ',') -or
($actualRequired -join ',') -ne ((@($Required | Sort-Object)) -join ',')) {
Add-Issue "$Name must be closed; fields=$($Fields -join ','); required=$($Required -join ',')"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
}
function Assert-StringOwner([string]$Name, [int]$Min, [int]$Max, [string]$Pattern = '') {
$schema = Get-Schema $Name
if (-not $schema) { return }
if ($schema.type -ne 'string' -or -not (Test-IsNonNullable $schema) -or [int]$schema.minLength -ne $Min -or [int]$schema.maxLength -ne $Max) {
Add-Issue "$Name must be a non-null string length $Min..$Max"
}
if ($Pattern -and [string]$schema.pattern -ne $Pattern) { Add-Issue "$Name pattern drifted" }
Assert-NoConflictingSchemaKeywords $schema $Name
Assert-AllowedSchemaKeywords $schema $Name @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
function Assert-PropertyRef([object]$Schema, [string]$Name, [string]$Field, [string]$Ref) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) { Add-Issue "$Name.$Field must use $Ref"; return }
[void](Test-IsPureSchemaRef $property.Value $Ref "$Name.$Field")
}
function Assert-Union([object]$Schema, [string]$Name, [string]$Discriminator, [hashtable]$Mapping) {
if (-not $Schema) { return }
$expectedRefs = @($Mapping.Values | Sort-Object -Unique)
$actualRefs = @($Schema.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$actualKeys = @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object)
$expectedKeys = @($Mapping.Keys | Sort-Object)
if ($Schema.discriminator.propertyName -ne $Discriminator) { Add-Issue "$Name discriminator property must be $Discriminator" }
if (($actualRefs -join ',') -ne ($expectedRefs -join ',') -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')) { Add-Issue "$Name oneOf/mapping branches drifted" }
foreach ($key in $expectedKeys) {
if ([string]$Schema.discriminator.mapping.$key -ne [string]$Mapping[$key]) { Add-Issue "$Name mapping $key drifted" }
}
foreach ($branch in @($Schema.oneOf)) {
$branchRef = [string]$branch.'$ref'
if ($branchRef -notin $expectedRefs) {
Add-Issue "$Name contains an unexpected oneOf branch: $branchRef"
} else {
[void](Test-IsPureSchemaRef $branch $branchRef "$Name oneOf branch")
}
}
Assert-AllowedSchemaKeywords $Schema $Name @('oneOf', 'discriminator')
if ($Schema.discriminator -and
(@($Schema.discriminator.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'mapping,propertyName') {
Add-Issue "$Name discriminator must contain only mapping and propertyName"
}
}
function Assert-SingleEnum([object]$Schema, [string]$Name, [string]$Field, [string]$Value) {
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property -or $property.Value.type -ne 'string' -or -not (Test-IsNonNullable $property.Value) -or -not (Test-IsJsonArray $property.Value.enum) -or @($property.Value.enum).Count -ne 1 -or $property.Value.enum[0] -ne $Value) { Add-Issue "$Name.$Field must be the single non-null value $Value" }
if ($property) { Assert-NoConflictingSchemaKeywords $property.Value "$Name.$Field" @('enum') }
if ($property) { Assert-AllowedSchemaKeywords $property.Value "$Name.$Field" @('type', 'enum', 'nullable') }
}
function Assert-RequestBody([object]$Operation, [string]$Label, [string]$Ref) {
if (-not $Operation) { return }
$content = $Operation.requestBody.content
$media = if ($content) { $content.PSObject.Properties['application/json'] } else { $null }
if (-not (Test-JsonBoolean $Operation.requestBody.required $true) -or -not $media -or $content.PSObject.Properties.Count -ne 1) {
Add-Issue "$Label must require only application/json with $Ref"
} elseif ($media) {
[void](Test-IsPureSchemaRef $media.Value.schema $Ref "$Label request schema")
}
}
function Assert-PathId([string]$Path, [object]$Operation, [string]$Name, [string]$Ref) {
if (-not $Operation) { return }
$parameter = Get-Parameter $Path $Operation 'path' $Name
if ($parameter) {
if (-not (Test-JsonBoolean $parameter.required $true)) { Add-Issue "$($Operation.operationId) $Name must be required" }
[void](Test-IsPureSchemaRef $parameter.schema $Ref "$($Operation.operationId) $Name")
}
}
function Assert-Envelope([string]$Name, [string]$DataRef) {
$schema = Get-Schema $Name
Assert-ClosedObject $schema $Name @('code','data') @('code','data')
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne 200) { Add-Issue "$Name.code must be non-null integer enum [200]" }
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-PropertyRef $schema $Name 'data' $DataRef
}
function Assert-ErrorEnvelope([string]$Name, [int]$Status, [string[]]$BusinessCodes, [bool]$HasCurrent = $false) {
$schema = Get-Schema $Name
$fields = if ($HasCurrent) { @('code','businessCode','current') } else { @('code','businessCode') }
Assert-ClosedObject $schema $Name $fields $fields
if (-not $schema) { return }
if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne $Status) { Add-Issue "$Name.code must be non-null integer enum [$Status]" }
$actualCodes = @($schema.properties.businessCode.enum | Sort-Object)
if ($schema.properties.businessCode.type -ne 'string' -or -not (Test-IsNonNullable $schema.properties.businessCode) -or -not (Test-IsJsonArray $schema.properties.businessCode.enum) -or ($actualCodes -join ',') -ne ((@($BusinessCodes | Sort-Object)) -join ',')) { Add-Issue "$Name.businessCode enum drifted" }
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
if ($HasCurrent) { Assert-PropertyRef $schema $Name 'current' '#/components/schemas/GenealogyJoinApplicationCurrentState' }
}
function Resolve-Response([object]$Response, [string]$Label = 'response') {
if (-not $Response) { return $null }
if ($Response.'$ref') {
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($responseRefSiblings.Count -gt 0) {
Add-Issue "$Label ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "$Label references missing response owner: $name"
return $null
}
return $owner.Value
}
return $Response
}
function Get-ExpectedResponseSchemaRef([string]$OperationId, [string]$Status, [string]$SuccessRef) {
if ($Status -eq '200') { return $SuccessRef }
if ($OperationId -eq 'appGetGenealogyJoinApplicationRequest' -and $Status -eq '404') { return '#/components/schemas/RGenealogyJoinApplicationRequestNotAvailable' }
if ($Status -eq '409') {
if ($OperationId -eq 'appCreateGenealogyJoinApplication') { return '#/components/schemas/RJoinApplicationKeyConflict' }
if ($OperationId -in @('appWithdrawGenealogyJoinApplication','appReviewGenealogyJoinApplication')) { return '#/components/schemas/RJoinApplicationStateConflict' }
}
return @{
'400' = '#/components/schemas/RJoinApplicationBadRequest'
'401' = '#/components/schemas/RJoinApplicationUnauthorized'
'403' = '#/components/schemas/RJoinApplicationForbidden'
'404' = '#/components/schemas/RJoinApplicationNotFound'
'422' = '#/components/schemas/RJoinApplicationUnprocessable'
'429' = '#/components/schemas/RJoinApplicationRateLimited'
'500' = '#/components/schemas/RJoinApplicationServerError'
}[$Status]
}
function Assert-ResponseContract([object]$Operation, [string]$Label, [string[]]$Statuses, [string]$SuccessRef) {
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
if (($actual -join ',') -ne ((@($Statuses | Sort-Object)) -join ',')) { Add-Issue "$Label response set drifted: $($actual -join ',')" }
foreach ($status in $actual) {
if ($status -eq 'default' -or $status -match '^3') { Add-Issue "$Label must not use default or 3xx responses" }
$response = Resolve-Response $Operation.responses.PSObject.Properties[$status].Value "$Label $status response"
if (-not $response) { continue }
$media = if ($response.content) { $response.content.PSObject.Properties['application/json'] } else { $null }
if (-not $media -or $response.content.PSObject.Properties.Count -ne 1) { Add-Issue "$Label $status must use only application/json" }
$expectedRef = Get-ExpectedResponseSchemaRef $Operation.operationId $status $SuccessRef
$actualRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' }
if ($expectedRef -and $media) { [void](Test-IsPureSchemaRef $media.Value.schema $expectedRef "$Label $status response schema") }
$cache = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $cache) {
Add-Issue "$Label $status must use the shared PrivateNoStore header owner"
} else {
[void](Test-IsExactReferenceObjectRef $cache.Value '#/components/headers/PrivateNoStore' 'headers' "$Label $status Cache-Control")
}
if ($status -eq '429') {
$retry = if ($response.headers) { $response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $retry) {
Add-Issue "$Label 429 must use the shared RetryAfter header owner"
} else {
[void](Test-IsExactReferenceObjectRef $retry.Value '#/components/headers/RetryAfter' 'headers' "$Label 429 Retry-After")
}
}
}
}
function Assert-SecurityAndClient([string]$Path, [object]$Operation, [string]$Label) {
if (-not $Operation) { return }
$security = @($Operation.security)
if ($security.Count -ne 1 -or $security[0].PSObject.Properties.Count -ne 1 -or $security[0].PSObject.Properties.Name -notcontains 'SaToken') {
Add-Issue "$Label must require SaToken without an anonymous alternative"
}
$client = Get-Parameter $Path $Operation 'header' 'clientid'
if ($client -and (-not (Test-JsonBoolean $client.required $true) -or $client.schema.type -ne 'string' -or -not (Test-IsNonNullable $client.schema) -or [int]$client.schema.minLength -ne 1 -or [int]$client.schema.maxLength -ne 128)) {
Add-Issue "$Label clientid must be a required non-null string bounded to 1..128"
}
if ($client) { Assert-NoConflictingSchemaKeywords $client.schema "$Label clientid" }
if ($client) { Assert-AllowedSchemaKeywords $client.schema "$Label clientid" @('type', 'minLength', 'maxLength', 'nullable') }
}
$parityOutput = @(& node (Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js') 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML parity failed: $($parityOutput -join ' | ')"
}
$resolvedOperations = @{}
foreach ($entry in $operations) {
$operation = Get-Operation $entry.Path $entry.Method
$resolvedOperations[$entry.Id] = $operation
if ($operation -and [string]$operation.operationId -ne $entry.Id) { Add-Issue "$($entry.Method.ToUpperInvariant()) $($entry.Path) operationId must be $($entry.Id)" }
Assert-SecurityAndClient $entry.Path $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)"
Assert-ResponseContract $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)" $entry.Responses $entry.SuccessRef
if ($operation) {
$actualParameters = @(Get-Parameters $entry.Path $operation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$expectedParameters = @($entry.Parameters | Sort-Object)
if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) { Add-Issue "$($entry.Id) parameters must be exactly $($expectedParameters -join ','); actual=$($actualParameters -join ',')" }
}
}
foreach ($entry in $operations) {
$duplicates = @($document.paths.PSObject.Properties | ForEach-Object { $_.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') -and $_.Value.operationId -eq $entry.Id } })
if ($duplicates.Count -ne 1) { Add-Issue "operationId must be globally unique: $($entry.Id)" }
}
$headerOwners = if ($document.components.PSObject.Properties['headers']) { $document.components.headers } else { $null }
$privateHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['PrivateNoStore'] } else { $null }
$privateHeader = if ($privateHeaderProperty) { $privateHeaderProperty.Value } else { $null }
if (-not $privateHeader -or $privateHeader.schema.type -ne 'string' -or -not (Test-IsNonNullable $privateHeader.schema) -or -not (Test-IsJsonArray $privateHeader.schema.enum) -or @($privateHeader.schema.enum).Count -ne 1 -or $privateHeader.schema.enum[0] -ne 'private, no-store') { Add-Issue 'PrivateNoStore header must be a fixed non-null string enum [private, no-store]' }
if ($privateHeader) { Assert-NoConflictingSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('enum') }
if ($privateHeader) { Assert-AllowedSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('type', 'enum', 'nullable') }
$retryHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['RetryAfter'] } else { $null }
$retryHeader = if ($retryHeaderProperty) { $retryHeaderProperty.Value } else { $null }
if (-not $retryHeader -or $retryHeader.schema.type -ne 'integer' -or -not (Test-IsNonNullable $retryHeader.schema) -or [int]$retryHeader.schema.minimum -ne 1 -or [int]$retryHeader.schema.maximum -ne 120) { Add-Issue 'RetryAfter header must be a non-null integer 1..120 seconds' }
if ($retryHeader) { Assert-NoConflictingSchemaKeywords $retryHeader.schema 'RetryAfter header schema' }
if ($retryHeader) { Assert-AllowedSchemaKeywords $retryHeader.schema 'RetryAfter header schema' @('type', 'minimum', 'maximum', 'nullable') }
Assert-StringOwner 'GenealogyId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'JoinApplicationId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
Assert-StringOwner 'JoinApplicationCursor' 1 512 '^[A-Za-z0-9_-]+$'
Assert-StringOwner 'GenealogyJoinApplicationRequestKey' 40 61 '^gja\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$'
$requestKeyOwner = Get-Schema 'GenealogyJoinApplicationRequestKey'
if ($requestKeyOwner) {
foreach ($extension in @(
@{ Name = 'x-issued-at-source'; Value = 'KEY_EPOCH_MILLISECONDS' },
@{ Name = 'x-accept-window-seconds'; Value = 600 },
@{ Name = 'x-max-future-skew-seconds'; Value = 300 },
@{ Name = 'x-resolve-sla-seconds'; Value = 120 },
@{ Name = 'x-random-min-bits'; Value = 128 }
)) {
if ($requestKeyOwner.PSObject.Properties[$extension.Name].Value -ne $extension.Value) { Add-Issue "GenealogyJoinApplicationRequestKey $($extension.Name) must be $($extension.Value)" }
}
}
$applyBody = Get-Schema 'AppGenealogyJoinApplicationBody'
Assert-ClosedObject $applyBody 'AppGenealogyJoinApplicationBody' @('applicantName','relationDesc','applyReason') @('applicantName','relationDesc')
foreach ($field in @(
@{ Name = 'applicantName'; Max = 50 },
@{ Name = 'relationDesc'; Max = 100 },
@{ Name = 'applyReason'; Max = 500 }
)) {
$property = if ($applyBody) { $applyBody.properties.PSObject.Properties[$field.Name].Value } else { $null }
if ($property -and ($property.type -ne 'string' -or [int]$property.minLength -ne 1 -or [int]$property.maxLength -ne $field.Max -or $property.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) {
Add-Issue "AppGenealogyJoinApplicationBody.$($field.Name) must use JOIN_APPLICATION_TEXT_V1 and length 1..$($field.Max)"
}
}
$reviewBody = Get-Schema 'AppGenealogyJoinReviewBody'
if ($reviewBody) {
Assert-Union $reviewBody 'AppGenealogyJoinReviewBody' 'decision' @{
APPROVE = '#/components/schemas/AppGenealogyJoinApproveBody'
REJECT = '#/components/schemas/AppGenealogyJoinRejectBody'
}
if ($reviewBody.PSObject.Properties['additionalProperties']) { Add-Issue 'review union wrapper must leave closure to its two concrete branches under OpenAPI 3.0.1' }
}
$approveBody = Get-Schema 'AppGenealogyJoinApproveBody'
$rejectBody = Get-Schema 'AppGenealogyJoinRejectBody'
Assert-ClosedObject $approveBody 'AppGenealogyJoinApproveBody' @('decision') @('decision')
Assert-ClosedObject $rejectBody 'AppGenealogyJoinRejectBody' @('decision','rejectionReason') @('decision','rejectionReason')
Assert-SingleEnum $approveBody 'AppGenealogyJoinApproveBody' 'decision' 'APPROVE'
Assert-SingleEnum $rejectBody 'AppGenealogyJoinRejectBody' 'decision' 'REJECT'
if ($rejectBody -and ($rejectBody.properties.rejectionReason.type -ne 'string' -or [int]$rejectBody.properties.rejectionReason.minLength -ne 1 -or [int]$rejectBody.properties.rejectionReason.maxLength -ne 500 -or $rejectBody.properties.rejectionReason.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) {
Add-Issue 'rejectionReason must use JOIN_APPLICATION_TEXT_V1 and length 1..500'
}
$searchItem = Get-Schema 'AppGenealogySearchItem'
Assert-ClosedObject $searchItem 'AppGenealogySearchItem' @('genealogyId','genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel','memberCount','updatedAt','viewerState') @('genealogyId','genealogyName','surname','regionName','viewerState')
Assert-PropertyRef $searchItem 'AppGenealogySearchItem' 'genealogyId' '#/components/schemas/GenealogyId'
if ($searchItem) {
$expectedViewerStates = @('NOT_JOINED','MEMBER','PENDING','REJECTED','FORMER_MEMBER','OWNER')
if ((@($searchItem.properties.viewerState.enum | Sort-Object) -join ',') -ne ((@($expectedViewerStates | Sort-Object)) -join ',')) { Add-Issue 'AppGenealogySearchItem.viewerState enum drifted' }
foreach ($forbidden in @('phone','managerName','managerPhone','userId','inviterUserId','auditUserId','canApply')) {
if ($searchItem.properties.PSObject.Properties[$forbidden]) { Add-Issue "search projection leaks or duplicates state: $forbidden" }
}
}
$pendingItem = Get-Schema 'PendingGenealogyJoinApplicationItem'
Assert-ClosedObject $pendingItem 'PendingGenealogyJoinApplicationItem' @('applyId','applicantName','relationDesc','applyReason','submittedAt') @('applyId','applicantName','relationDesc','submittedAt')
Assert-PropertyRef $pendingItem 'PendingGenealogyJoinApplicationItem' 'applyId' '#/components/schemas/JoinApplicationId'
if ($pendingItem -and $pendingItem.properties.submittedAt.format -ne 'date-time') { Add-Issue 'pending submittedAt must be RFC3339 date-time' }
$mineUnion = Get-Schema 'MyGenealogyJoinApplicationItem'
$mineMapping = @{
PENDING = '#/components/schemas/MyPendingGenealogyJoinApplication'
APPROVED = '#/components/schemas/MyApprovedGenealogyJoinApplication'
REJECTED = '#/components/schemas/MyRejectedGenealogyJoinApplication'
WITHDRAWN = '#/components/schemas/MyWithdrawnGenealogyJoinApplication'
}
Assert-Union $mineUnion 'MyGenealogyJoinApplicationItem' 'status' $mineMapping
$mineBranches = @(
@{ Name = 'MyPendingGenealogyJoinApplication'; Status = 'PENDING'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt') },
@{ Name = 'MyApprovedGenealogyJoinApplication'; Status = 'APPROVED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') },
@{ Name = 'MyRejectedGenealogyJoinApplication'; Status = 'REJECTED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt','rejectionReason'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt','rejectionReason') },
@{ Name = 'MyWithdrawnGenealogyJoinApplication'; Status = 'WITHDRAWN'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') }
)
foreach ($branch in $mineBranches) {
$name = $branch.Name
$schema = Get-Schema $name
if ($schema) {
Assert-ClosedObject $schema $name $branch.Fields $branch.Required
Assert-SingleEnum $schema $name 'status' $branch.Status
foreach ($field in @('applyId','genealogyId')) { Assert-PropertyRef $schema $name $field $(if ($field -eq 'applyId') { '#/components/schemas/JoinApplicationId' } else { '#/components/schemas/GenealogyId' }) }
if ($schema.properties.submittedAt.format -ne 'date-time') { Add-Issue "$name.submittedAt must be date-time" }
if ($schema.properties.PSObject.Properties['resolvedAt'] -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$name.resolvedAt must be date-time" }
}
}
$rejectedMine = Get-Schema 'MyRejectedGenealogyJoinApplication'
if ($rejectedMine -and @($rejectedMine.required) -notcontains 'rejectionReason') { Add-Issue 'REJECTED mine branch must require applicant-visible rejectionReason' }
foreach ($pageName in @('GenealogySearchCursorPage','MyGenealogyJoinApplicationCursorPage','PendingGenealogyJoinApplicationCursorPage')) {
$page = Get-Schema $pageName
Assert-ClosedObject $page $pageName @('items','nextCursor') @('items')
if ($page -and ($page.properties.PSObject.Properties['total'] -or $page.properties.PSObject.Properties['pageNum'])) { Add-Issue "$pageName must not expose total/pageNum" }
}
$pageItemRefs = @{
GenealogySearchCursorPage = '#/components/schemas/AppGenealogySearchItem'
MyGenealogyJoinApplicationCursorPage = '#/components/schemas/MyGenealogyJoinApplicationItem'
PendingGenealogyJoinApplicationCursorPage = '#/components/schemas/PendingGenealogyJoinApplicationItem'
}
foreach ($pageName in $pageItemRefs.Keys) {
$page = $document.components.schemas.PSObject.Properties[$pageName].Value
if (-not $page) { continue }
if ($page.properties.items.type -ne 'array' -or [string]$page.properties.items.items.'$ref' -ne $pageItemRefs[$pageName] -or [int]$page.properties.items.maxItems -ne 50) { Add-Issue "$pageName.items must be a max-50 array of $($pageItemRefs[$pageName])" }
Assert-PropertyRef $page $pageName 'nextCursor' '#/components/schemas/JoinApplicationCursor'
}
$receipt = Get-Schema 'GenealogyJoinApplicationReceipt'
Assert-ClosedObject $receipt 'GenealogyJoinApplicationReceipt' @('applyId','genealogyId','status','submittedAt') @('applyId','genealogyId','status','submittedAt')
Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'applyId' '#/components/schemas/JoinApplicationId'
Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-SingleEnum $receipt 'GenealogyJoinApplicationReceipt' 'status' 'PENDING'
if ($receipt -and $receipt.properties.submittedAt.format -ne 'date-time') { Add-Issue 'GenealogyJoinApplicationReceipt.submittedAt must be date-time' }
Assert-Envelope 'RGenealogySearchCursorPage' '#/components/schemas/GenealogySearchCursorPage'
Assert-Envelope 'RGenealogyJoinApplicationReceipt' '#/components/schemas/GenealogyJoinApplicationReceipt'
Assert-Envelope 'RGenealogyJoinApplicationRequestStatus' '#/components/schemas/GenealogyJoinApplicationRequestStatus'
Assert-Envelope 'RMyGenealogyJoinApplicationCursorPage' '#/components/schemas/MyGenealogyJoinApplicationCursorPage'
Assert-Envelope 'RWithdrawnGenealogyJoinApplicationReceipt' '#/components/schemas/WithdrawnGenealogyJoinApplicationReceipt'
Assert-Envelope 'RReviewedGenealogyJoinApplicationReceipt' '#/components/schemas/ReviewedGenealogyJoinApplicationReceipt'
Assert-Envelope 'RPendingGenealogyJoinApplicationCursorPage' '#/components/schemas/PendingGenealogyJoinApplicationCursorPage'
$reviewedUnion = Get-Schema 'ReviewedGenealogyJoinApplicationReceipt'
Assert-Union $reviewedUnion 'ReviewedGenealogyJoinApplicationReceipt' 'status' @{
APPROVED = '#/components/schemas/ApprovedGenealogyJoinApplicationReceipt'
REJECTED = '#/components/schemas/RejectedGenealogyJoinApplicationReceipt'
}
foreach ($branch in @(
@{ Name='ApprovedGenealogyJoinApplicationReceipt'; Status='APPROVED'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') },
@{ Name='RejectedGenealogyJoinApplicationReceipt'; Status='REJECTED'; Fields=@('applyId','status','resolvedAt','rejectionReason'); Required=@('applyId','status','resolvedAt','rejectionReason') },
@{ Name='WithdrawnGenealogyJoinApplicationReceipt'; Status='WITHDRAWN'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') }
)) {
$schema = Get-Schema $branch.Name
Assert-ClosedObject $schema $branch.Name $branch.Fields $branch.Required
Assert-SingleEnum $schema $branch.Name 'status' $branch.Status
Assert-PropertyRef $schema $branch.Name 'applyId' '#/components/schemas/JoinApplicationId'
if ($schema -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$($branch.Name).resolvedAt must be date-time" }
}
$currentState = Get-Schema 'GenealogyJoinApplicationCurrentState'
Assert-ClosedObject $currentState 'GenealogyJoinApplicationCurrentState' @('applyId','status','resolvedAt') @('applyId','status')
Assert-PropertyRef $currentState 'GenealogyJoinApplicationCurrentState' 'applyId' '#/components/schemas/JoinApplicationId'
if ($currentState) {
$states = @('PENDING','APPROVED','REJECTED','WITHDRAWN')
if ((@($currentState.properties.status.enum | Sort-Object) -join ',') -ne ((@($states | Sort-Object)) -join ',')) { Add-Issue 'current-state status enum drifted' }
}
Assert-ErrorEnvelope 'RJoinApplicationBadRequest' 400 @('CURSOR_INVALID','INVALID_REQUEST','OPERATION_KEY_INVALID')
Assert-ErrorEnvelope 'RJoinApplicationUnauthorized' 401 @('AUTHENTICATION_REQUIRED')
Assert-ErrorEnvelope 'RJoinApplicationForbidden' 403 @('JOIN_APPLICATION_FORBIDDEN')
Assert-ErrorEnvelope 'RJoinApplicationNotFound' 404 @('GENEALOGY_NOT_FOUND','JOIN_APPLICATION_NOT_FOUND')
Assert-ErrorEnvelope 'RJoinApplicationKeyConflict' 409 @('ACTIVE_PENDING_EXISTS','IDEMPOTENCY_KEY_REUSED','OPERATION_KEY_EXPIRED')
Assert-ErrorEnvelope 'RJoinApplicationStateConflict' 409 @('JOIN_APPLICATION_DECISION_CONFLICT','JOIN_APPLICATION_STATE_CHANGED') $true
if ($document.components.schemas.PSObject.Properties['RJoinApplicationConflict']) { Add-Issue 'remove broad RJoinApplicationConflict; each mutation operation must reference its precise 409 owner' }
Assert-ErrorEnvelope 'RJoinApplicationUnprocessable' 422 @('GENEALOGY_NOT_PUBLIC_APPLY','JOIN_APPLICATION_NOT_ALLOWED','REJECTION_REASON_INVALID')
Assert-ErrorEnvelope 'RJoinApplicationRateLimited' 429 @('RATE_LIMITED')
Assert-ErrorEnvelope 'RJoinApplicationServerError' 500 @('INTERNAL_ERROR')
$notAvailable = Get-Schema 'RGenealogyJoinApplicationRequestNotAvailable'
Assert-ClosedObject $notAvailable 'RGenealogyJoinApplicationRequestNotAvailable' @('code','businessCode','acceptUntil') @('code','businessCode','acceptUntil')
if ($notAvailable -and ($notAvailable.properties.code.enum[0] -ne 404 -or $notAvailable.properties.businessCode.enum[0] -ne 'JOIN_APPLICATION_REQUEST_NOT_AVAILABLE' -or $notAvailable.properties.acceptUntil.format -ne 'date-time')) { Add-Issue 'status 404 contract drifted' }
$post = $resolvedOperations['appCreateGenealogyJoinApplication']
if ($post) {
Assert-RequestBody $post 'join POST' '#/components/schemas/AppGenealogyJoinApplicationBody'
Assert-PathId $operations[1].Path $post 'genealogyId' '#/components/schemas/GenealogyId'
$key = Get-Parameter $operations[1].Path $post 'header' 'Idempotency-Key'
if ($key) {
if (-not (Test-JsonBoolean $key.required $true)) { Add-Issue 'join POST Idempotency-Key must be required' }
[void](Test-IsPureSchemaRef $key.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'join POST Idempotency-Key')
}
$description = [string]$post.description
foreach ($pattern in @('canonical.*method.*path.*genealogyId.*tenant.*account.*client.*body','unique.*account.*tenant.*genealogy.*PENDING','same key.*same canonical.*same receipt','same key.*different.*409','domain transaction.*application.*SUCCEEDED','FAILED_NO_COMMIT.*no domain effects','same transaction.*READY.*PUBLIC_APPLY.*application eligibility','single winner.*PUBLIC_APPLY.*MEMBER_ONLY')) {
if ($description -notmatch "(?i)$pattern") { Add-Issue "join POST description misses: $pattern" }
}
if (-not (Test-IsJsonArray $post.'x-idempotency-scope') -or
(@($post.'x-idempotency-scope') -join ',') -ne 'method,path,genealogyId,tenant,account,client,canonicalBody' -or
-not (Test-IsJsonArray $post.'x-active-pending-unique-scope') -or
(@($post.'x-active-pending-unique-scope') -join ',') -ne 'tenant,account,genealogyId' -or
-not (Test-IsJsonArray $post.'x-domain-transaction-effects') -or
(@($post.'x-domain-transaction-effects') -join ',') -ne 'JOIN_APPLICATION,SUCCEEDED_RECEIPT' -or
-not (Test-IsJsonArray $post.'x-revalidates') -or
(@($post.'x-revalidates') -join ',') -ne 'genealogyState,accessPreset,applicationEligibility' -or
[string]$post.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
-not (Test-JsonBoolean $post.'x-same-request-replays-receipt' $true) -or
$post.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED') { Add-Issue 'join POST machine-readable idempotency/replay/transaction extensions drifted' }
}
$status = $resolvedOperations['appGetGenealogyJoinApplicationRequest']
if ($status) {
if ($status.PSObject.Properties['requestBody']) { Add-Issue 'status GET must not define a request body' }
$requestKey = Get-Parameter $operations[2].Path $status 'path' 'requestKey'
if ($requestKey) {
if (-not (Test-JsonBoolean $requestKey.required $true)) { Add-Issue 'status requestKey must be required' }
[void](Test-IsPureSchemaRef $requestKey.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'status requestKey')
}
foreach ($pattern in @('read-only.*no side effect','ABSENT.*PENDING.*SUCCEEDED.*FAILED_NO_COMMIT','terminal.*immutable','cross-account.*tenant.*client.*404','before.*acceptUntil.*404','after.*acceptUntil.*computed.*FAILED_NO_COMMIT.*no write','PENDING.*resolveBy','domain effect.*SUCCEEDED.*same transaction')) {
if ([string]$status.description -notmatch "(?i)$pattern") { Add-Issue "status GET description misses: $pattern" }
}
if (-not (Test-JsonBoolean $status.'x-read-only' $true) -or -not (Test-JsonBoolean $status.'x-expired-absent-zero-write' $true)) { Add-Issue 'status GET read-only/zero-write extensions drifted' }
$status404 = Resolve-Response $status.responses.PSObject.Properties['404'].Value 'status GET 404 response'
$status404Retry = if ($status404.headers) { $status404.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $status404Retry) {
Add-Issue 'status GET 404 must use RetryAfter before acceptUntil'
} else {
[void](Test-IsExactReferenceObjectRef $status404Retry.Value '#/components/headers/RetryAfter' 'headers' 'status GET 404 Retry-After')
}
}
$statusUnion = Get-Schema 'GenealogyJoinApplicationRequestStatus'
Assert-Union $statusUnion 'GenealogyJoinApplicationRequestStatus' 'status' @{
PENDING = '#/components/schemas/PendingGenealogyJoinApplicationRequest'
SUCCEEDED = '#/components/schemas/SucceededGenealogyJoinApplicationRequest'
FAILED_NO_COMMIT = '#/components/schemas/FailedGenealogyJoinApplicationRequest'
}
if ($statusUnion -and (-not (Test-JsonBoolean $statusUnion.'x-terminal-immutable' $true) -or -not (Test-IsJsonArray $statusUnion.'x-state-transitions') -or (@($statusUnion.'x-state-transitions') -join ',') -ne 'ABSENT->PENDING,PENDING->SUCCEEDED,PENDING->FAILED_NO_COMMIT')) { Add-Issue 'operation status transitions or terminal immutability drifted' }
$pendingStatus = Get-Schema 'PendingGenealogyJoinApplicationRequest'
$succeededStatus = Get-Schema 'SucceededGenealogyJoinApplicationRequest'
$failedStatus = Get-Schema 'FailedGenealogyJoinApplicationRequest'
Assert-ClosedObject $pendingStatus 'PendingGenealogyJoinApplicationRequest' @('status','resolveBy','retryAfterSeconds') @('status','resolveBy','retryAfterSeconds')
Assert-SingleEnum $pendingStatus 'PendingGenealogyJoinApplicationRequest' 'status' 'PENDING'
if ($pendingStatus -and ($pendingStatus.properties.resolveBy.format -ne 'date-time' -or $pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30)) { Add-Issue 'PENDING operation status timing fields drifted' }
Assert-ClosedObject $succeededStatus 'SucceededGenealogyJoinApplicationRequest' @('status','result') @('status','result')
Assert-SingleEnum $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'status' 'SUCCEEDED'
Assert-PropertyRef $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'result' '#/components/schemas/GenealogyJoinApplicationReceipt'
Assert-ClosedObject $failedStatus 'FailedGenealogyJoinApplicationRequest' @('status') @('status')
Assert-SingleEnum $failedStatus 'FailedGenealogyJoinApplicationRequest' 'status' 'FAILED_NO_COMMIT'
if ($failedStatus -and ($failedStatus.'x-domain-effects' -ne 'NONE' -or -not (Test-JsonBoolean $failedStatus.'x-active-application-created' $false))) { Add-Issue 'FAILED_NO_COMMIT must machine-lock zero application effects' }
foreach ($listId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) {
$operation = $resolvedOperations[$listId]
if (-not $operation) { continue }
$limit = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'limit'
$cursor = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'cursor'
if ($limit -and ($limit.schema.type -ne 'integer' -or -not (Test-IsNonNullable $limit.schema) -or [int]$limit.schema.minimum -ne 1 -or [int]$limit.schema.maximum -ne 50)) { Add-Issue "$listId limit must be a non-null integer 1..50" }
if ($limit) {
Assert-NoConflictingSchemaKeywords $limit.schema "$listId limit"
Assert-AllowedSchemaKeywords $limit.schema "$listId limit" @('type', 'minimum', 'maximum', 'nullable')
}
if ($cursor) { [void](Test-IsPureSchemaRef $cursor.schema '#/components/schemas/JoinApplicationCursor' "$listId cursor") }
foreach ($pattern in @('stable.*cursor','no total','tenant.*account.*client','filter.*cursor','tie-breaker')) {
if ([string]$operation.description -notmatch "(?i)$pattern") { Add-Issue "$listId pagination description misses: $pattern" }
}
$expectedOrder = if ($listId -eq 'appSearchPublicGenealogies') { 'updatedAt:desc,genealogyId:desc' } else { 'submittedAt:desc,applyId:desc' }
if (-not (Test-IsJsonArray $operation.'x-cursor-scope') -or (@($operation.'x-cursor-scope') -join ',') -ne 'tenant,account,client,filters' -or -not (Test-IsJsonArray $operation.'x-cursor-order') -or (@($operation.'x-cursor-order') -join ',') -ne $expectedOrder -or -not (Test-JsonBoolean $operation.'x-cursor-no-total' $true)) { Add-Issue "$listId cursor extensions drifted" }
}
$search = $resolvedOperations['appSearchPublicGenealogies']
if ($search) {
$keyword = Get-Parameter $operations[0].Path $search 'query' 'keyword'
if ($keyword -and (-not (Test-JsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or -not (Test-IsNonNullable $keyword.schema) -or [int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -ne 50)) { Add-Issue 'search keyword must be required non-null string length 1..50' }
if ($keyword) {
Assert-NoConflictingSchemaKeywords $keyword.schema 'search keyword'
Assert-AllowedSchemaKeywords $keyword.schema 'search keyword' @('type', 'minLength', 'maxLength', 'nullable')
}
}
$withdraw = $resolvedOperations['appWithdrawGenealogyJoinApplication']
if ($withdraw) {
if ($withdraw.PSObject.Properties['requestBody']) { Add-Issue 'withdraw DELETE must not define a request body' }
Assert-PathId $operations[4].Path $withdraw 'applyId' '#/components/schemas/JoinApplicationId'
foreach ($pattern in @('WHERE status=PENDING','same withdraw.*same 200','audit.*race.*409','current.*state','terminal.*immutable','cross-account.*tenant.*404')) {
if ([string]$withdraw.description -notmatch "(?i)$pattern") { Add-Issue "withdraw description misses: $pattern" }
}
if ($withdraw.'x-cas-where' -ne 'status=PENDING' -or -not (Test-JsonBoolean $withdraw.'x-same-action-replay' $true)) { Add-Issue 'withdraw CAS extensions drifted' }
}
$audit = $resolvedOperations['appReviewGenealogyJoinApplication']
if ($audit) {
Assert-RequestBody $audit 'audit PUT' '#/components/schemas/AppGenealogyJoinReviewBody'
Assert-PathId $operations[6].Path $audit 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PathId $operations[6].Path $audit 'applyId' '#/components/schemas/JoinApplicationId'
foreach ($pattern in @('WHERE status=PENDING','same decision.*same 200','different rejection.*409','opposite decision.*409','permission.*READY.*PUBLIC_APPLY','same transaction.*member.*application','unique member')) {
if ([string]$audit.description -notmatch "(?i)$pattern") { Add-Issue "audit description misses: $pattern" }
}
if ($audit.'x-cas-where' -ne 'status=PENDING' -or -not (Test-IsJsonArray $audit.'x-transaction-effects') -or (@($audit.'x-transaction-effects') -join ',') -ne 'UNIQUE_MEMBER_RELATION,APPLICATION_TERMINAL_STATE' -or -not (Test-IsJsonArray $audit.'x-revalidates') -or (@($audit.'x-revalidates') -join ',') -ne 'permission,genealogyState,accessPreset' -or -not (Test-JsonBoolean $audit.'x-same-decision-replays-receipt' $true) -or $audit.'x-opposite-decision-error' -ne 'JOIN_APPLICATION_DECISION_CONFLICT') { Add-Issue 'audit CAS/replay/transaction extensions drifted' }
}
$pendingOperation = $resolvedOperations['appListPendingGenealogyJoinApplications']
Assert-PathId $operations[5].Path $pendingOperation 'genealogyId' '#/components/schemas/GenealogyId'
foreach ($readId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) {
$read = $resolvedOperations[$readId]
if ($read -and $read.PSObject.Properties['requestBody']) { Add-Issue "$readId must not define a request body" }
}
foreach ($legacy in @('GenealogyJoinApplyBody','GenealogyJoinAuditBody','AppGenealogyJoinApplyBody','AppGenealogyJoinAuditBody')) {
if ($document.components.schemas.PSObject.Properties[$legacy]) { Add-Issue "legacy APP join schema must be removed: $legacy" }
}
# No successful join response may expose account, phone, inviter, or auditor identities anywhere in its recursive schema closure.
$queue = New-Object System.Collections.Generic.Queue[string]
$seen = @{}
foreach ($entry in $operations) { $queue.Enqueue(([string]$entry.SuccessRef).Split('/')[-1]) }
while ($queue.Count -gt 0) {
$name = $queue.Dequeue()
if ($seen.ContainsKey($name)) { continue }
$seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) { Add-Issue "successful response closure is missing schema: $name"; continue }
$schema = $owner.Value
foreach ($forbidden in @('phone','appUserId','appUserPhone','inviterUserId','inviterPhone','auditUserId','auditPhone')) {
if ($schema.properties -and $schema.properties.PSObject.Properties.Name -contains $forbidden) { Add-Issue "$name leaks forbidden identity field: $forbidden" }
}
$json = $schema | ConvertTo-Json -Depth 40 -Compress
foreach ($match in [regex]::Matches($json, '#/components/schemas/(?<Name>[A-Za-z0-9._-]+)')) {
$queue.Enqueue($match.Groups['Name'].Value)
}
}
if ($issues.Count -gt 0) {
Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output 'Only accept APP.openapi.json and APP.openapi.yaml re-exported together from one backend version; never hand-edit the protected files.'
exit 1
}
Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT PASS'
+3 -7
View File
@@ -234,18 +234,14 @@ foreach ($forbidden in @('query?.step', 'step=ancestor', 'finishPage(', 'genealo
}
Assert-NotContains -Content $g03 -Unexpected 'onLoad(' -Message 'G03 没有路由业务参数,不得保留无语义的 onLoad 兼容壳'
Assert-Contains -Content $g05 -Expected 'import { findGenealogyFixture, getGenealogyFixtureAccess } from "@/data/mock.js";' -Message 'G05 必须消费唯一家谱夹具与访问判定所有者'
Assert-Contains -Content $g05 -Expected 'appApi.getOverview(genealogyId.value' -Message 'G05 必须消费真实家谱概览 owner'
Assert-Matches -Content $g05 -Pattern '(?s)import \{\s*goBack,\s*goRoot,\s*handleBackPress,\s*openPage,\s*returnTo,\s*\} from "@/utils/navigation\.js";' -Message 'G05 必须只消费导航网关与 Android 返回适配器'
Assert-Contains -Content $g05 -Expected '@back="requestBack"' -Message 'G05 页头必须使用统一返回入口'
Assert-Contains -Content $g05 -Expected 'const requestBack = () => goBack();' -Message 'G05 返回必须保留真实栈并支持深链父页回退'
Assert-Contains -Content $g05 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G05 Android 返回必须使用同步适配器'
Assert-Contains -Content $g05 -Expected 'const accessRole = ref("guest");' -Message 'G05 未验证访问默认必须是最低权限'
Assert-Contains -Content $g05 -Expected 'const fixture = findGenealogyFixture(genealogyId.value);' -Message 'G05 身份和内容必须来自共享家谱夹具'
Assert-Contains -Content $g05 -Expected 'const access = getGenealogyFixtureAccess(genealogyId.value);' -Message 'G05 显示角色必须来自共享夹具解析器'
Assert-Contains -Content $g05 -Expected 'publicRelation.value = access.relation;' -Message 'G05 公开操作必须消费共享关系合同'
Assert-NotContains -Content $g05 -Unexpected 'fixture.membership' -Message 'G05 不得用可变展示夹具直接判权'
Assert-Contains -Content $g05 -Expected 'viewMode === "preview" ? "本地流程预览" : "公开家谱"' -Message 'G05 本地创建 ID 必须进入不含成员权限的预览态'
Assert-Contains -Content $g05 -Expected "v-if=`"viewMode === 'public' && publicActionLabel`"" -Message 'G05 只有具备关系动作的公开家谱可显示入口,本地预览不得申请自己'
Assert-Contains -Content $g05 -Expected 'accessRole.value = result.accessRole;' -Message 'G05 显示角色必须来自概览窄映射'
Assert-NotContains -Content $g05 -Unexpected 'findGenealogyFixture' -Message 'G05 不得继续读取本地家谱夹具'
Assert-Contains -Content $g05 -Expected 'overviewState.value = "no-permission";' -Message 'G05 未知家谱必须明确拒绝访问'
Assert-Contains -Content $g05 -Expected 'returnTo("G01", {})' -Message 'G05 状态页显式按钮必须返回家谱根页'
foreach ($mapping in @(
+1 -1
View File
@@ -5,7 +5,7 @@ $files=@('pages/family/f01-family-feed.vue','pages/family/f02-publish-feed.vue',
$pages=@{}
foreach($file in $files){$content=Get-Content -LiteralPath(Join-Path $root $file)-Raw -Encoding utf8;if($content-match"@/utils/api\.js|\bappApi\b"){ throw "$file must not connect the API layer" };Assert-Contains $content 'ModulePageBackground' "$file must use its module background";$pages[$file]=$content}
$g01=Get-Content -LiteralPath(Join-Path $root 'pages/genealogy/g01-my-genealogies.vue')-Raw -Encoding utf8
if($g01-match"@/utils/api\.js|\bappApi\b"){ throw 'G01 must not connect the API layer' }
if($g01-notmatch"@/utils/api\.js" -or $g01-notmatch"\bappApi\.getMyGenealogies\b"){ throw 'G01 must consume its tested remote list owner' }
Assert-Contains $g01 'GenealogyPageBackground' 'G01 must use its genealogy background'
if($g01 -notmatch '(?s)<PageHeader\s+root\s+notice\s+title="我的家谱"[^>]+@notice="toNotifications"') { throw 'G01 root header must expose the notice action without a back control' }
if($pages['pages/family/f01-family-feed.vue'] -notmatch '(?s)<PageHeader\s+root\s+title="家族动态"\s+:action="hasValidContext \? ''发布'' : ''''"\s+@action="toPublish"') { throw 'F01 root header must expose publish only for a valid family context and without a back control' }
+1 -1
View File
@@ -9,7 +9,7 @@ const moduleHolder = { exports: {} };
new Function("module", source)(moduleHolder);
const { runtimeConfig, resolveRuntimeMode } = moduleHolder.exports;
assert.strictEqual(resolveRuntimeMode(), "mock");
assert.strictEqual(resolveRuntimeMode(), "remote");
runtimeConfig.mode = "remote";
assert.strictEqual(resolveRuntimeMode(), "remote");
+166
View File
@@ -0,0 +1,166 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
let nextResponse;
const requests = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{
personId: 1001,
name: "汤文远",
generation: 12,
generationName: "文",
birthDate: "1900-01-02T00:00:00+08:00",
relationName: "始祖",
spouses: [{
personId: 1002,
name: "李氏",
generation: 12,
generationName: "文",
birthDate: "1905-03-04T00:00:00+08:00",
}],
children: [{
personId: 1011,
name: "汤正明",
generation: 13,
generationName: "正",
fatherId: 1001,
children: [{
personId: 1021,
name: "汤志成",
generation: 14,
generationName: "志",
fatherId: 1011,
deathDate: "2020-05-06T00:00:00+08:00",
}],
}],
}],
},
};
const tree = await appApi.getTree("900001001");
assert.deepStrictEqual(tree, [
{
id: "1001",
parentId: null,
name: "汤文远",
relation: "始祖",
generation: 12,
branch: "文字辈",
years: "1900-01-02—",
sex: "",
personStatus: "",
},
{
id: "1002",
parentId: null,
name: "李氏",
relation: "配偶",
generation: 12,
branch: "文字辈",
years: "1905-03-04—",
sex: "",
personStatus: "",
},
{
id: "1011",
parentId: "1001",
name: "汤正明",
relation: "后代",
generation: 13,
branch: "正字辈",
years: "生卒待补",
sex: "",
personStatus: "",
},
{
id: "1021",
parentId: "1011",
name: "汤志成",
relation: "后代",
generation: 14,
branch: "志字辈",
years: "—2020-05-06",
sex: "",
personStatus: "",
},
]);
assert.strictEqual(
requests[0].url,
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/900001001/lineage/tree",
);
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: [{
personId: 1001,
name: "重复根",
generation: 1,
children: [{ personId: 1001, name: "重复子", generation: 2 }],
}],
},
};
await assert.rejects(
appApi.getTree("900001001"),
(error) => error?.code === "LINEAGE_TREE_RESPONSE_INVALID",
);
process.stdout.write("T01-TREE-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+14 -3
View File
@@ -30,7 +30,14 @@ $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding
if (Test-Path -LiteralPath $t02Path) { throw 'T02 duplicate route file must be removed' }
if ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' }
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'T01 design phase must not connect the API layer' }
foreach ($required in @(
'appApi.getTree',
'createRequestController',
'isRequestCancelled',
'treeRequestController.abort()',
'onShow(() =>',
'requestController: treeRequestController'
)) { Assert-Contains $page $required "Missing T01 remote data contract: $required" }
foreach ($required in @(
'const treeState = ref("loading")',
@@ -59,9 +66,7 @@ foreach ($required in @(
'openPage("T06"',
'openPage("T07"',
'query.genealogyId',
'getGenealogyFixtureAccess',
'genealogyContext.isCurrentGenealogyInvalidated()',
'["owner", "member"].includes(access.accessRole)',
':id="`tree-member-${member.id}`"',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
'@scroll="handleTreeScroll"',
@@ -73,6 +78,12 @@ foreach ($required in @(
'class="tree-recenter"'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
foreach ($fixtureToken in @('getGenealogyFixtureAccess', 'treeMembers', 'mockTree')) {
if ($page -match [regex]::Escape($fixtureToken)) {
throw "T01 must not use a fixture as its remote tree source: $fixtureToken"
}
}
$invalidatedGuardIndex = $page.IndexOf('genealogyContext.isCurrentGenealogyInvalidated()')
$contextWriteIndex = $page.IndexOf('genealogyContext.setCurrentGenealogyId(genealogyId.value)')
if ($invalidatedGuardIndex -lt 0 -or $contextWriteIndex -le $invalidatedGuardIndex) {
+147
View File
@@ -0,0 +1,147 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
let nextResponse;
const requests = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: {
personId: 1001,
genealogyId: 900001001,
genealogyName: "联调测试家谱",
name: "汤文远",
generation: 12,
generationName: "文",
sex: "MALE",
fatherId: 900,
fatherName: "汤世德",
motherId: 901,
motherName: "周氏",
birthDate: "1900-01-02T00:00:00+08:00",
birthPlace: "祖居地",
personStatus: "ALIVE",
biography: "生平资料",
},
},
};
const person = await appApi.getPerson("900001001", "1001");
assert.deepStrictEqual(person, {
id: "1001",
genealogyId: "900001001",
genealogyName: "联调测试家谱",
name: "汤文远",
generation: 12,
generationName: "文",
relation: "家谱成员",
branch: "文字辈",
sex: "MALE",
birthDate: "1900-01-02",
deathDate: "",
years: "1900-01-02—",
birthplace: "祖居地",
biography: "生平资料",
status: "normal",
relatives: [
{ id: "900", name: "汤世德", relation: "父亲" },
{ id: "901", name: "周氏", relation: "母亲" },
],
});
assert.strictEqual(
requests[0].url,
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/900001001/lineage/persons/1001",
);
assert.strictEqual(requests[0].method, "GET");
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: {
personId: 1002,
genealogyId: 900001001,
name: "wrong identity",
generation: 12,
},
},
};
await assert.rejects(
appApi.getPerson("900001001", "1001"),
(error) => error?.code === "LINEAGE_PERSON_RESPONSE_INVALID",
);
nextResponse = {
statusCode: 200,
data: {
code: 200,
data: {
personId: 1001,
genealogyId: 900001001,
name: "invalid date",
generation: 12,
birthDate: "not-a-date",
},
},
};
await assert.rejects(
appApi.getPerson("900001001", "1001"),
(error) => error?.code === "LINEAGE_PERSON_RESPONSE_INVALID",
);
process.stdout.write("T03-MEMBER-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding UTF8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
$issues = [System.Collections.Generic.List[string]]::new()
foreach ($required in @(
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'onUnload',
'loadSequence',
'memberState.value = "loading";'
)) {
if (-not $page.Contains($required)) {
$issues.Add("T03 page missing remote read owner: $required")
}
}
if ($page.Contains('@/data/mock.js') -or $page.Contains('findTreeMemberPresentationFixture')) {
$issues.Add('T03 must not retain the fixture read after the remote owner lands')
}
foreach ($required in @(
'const normalizeLineagePersonDetail =',
'LINEAGE_PERSON_RESPONSE_INVALID',
'return normalizeLineagePersonDetail(result, normalizedGenealogyId, normalizedPersonId)'
)) {
if (-not $api.Contains($required)) {
$issues.Add("API owner missing strict T03 contract: $required")
}
}
if ($issues.Count -gt 0) {
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('T03-MEMBER-REMOTE-CONTRACT BLOCKED')
foreach ($issue in $issues) {
$lines.Add("- $issue")
}
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'T03-MEMBER-REMOTE-CONTRACT PASS'