feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
import type { PageEnvelope, PageQuery } from './instances.js';
|
import type { PageEnvelope, PageQuery } from './instances.js';
|
||||||
|
|
||||||
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
||||||
export const SCHEDULED_OPERATION_TYPES = ['restart-service', 'reboot-system', 'send-sms'] as const;
|
export const SCHEDULED_OPERATION_TYPES = [
|
||||||
|
'restart-service',
|
||||||
|
'reboot-system',
|
||||||
|
'restart-baseband',
|
||||||
|
'send-sms',
|
||||||
|
'backup-data',
|
||||||
|
] as const;
|
||||||
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
|
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
|
||||||
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
|
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
|
||||||
export const SCHEDULED_RUN_OUTCOMES = [
|
export const SCHEDULED_RUN_OUTCOMES = [
|
||||||
@@ -13,16 +19,35 @@ export const SCHEDULED_RUN_OUTCOMES = [
|
|||||||
'needs-attention',
|
'needs-attention',
|
||||||
] as const;
|
] as const;
|
||||||
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
|
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
|
||||||
|
export const SCHEDULE_TRIGGER_KINDS = ['cron', 'fixed', 'interval'] as const;
|
||||||
|
export const SCHEDULE_INTERVAL_UNITS = ['mins', 'hours', 'days'] as const;
|
||||||
|
|
||||||
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
|
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
|
||||||
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
|
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
|
||||||
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
|
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
|
||||||
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
|
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
|
||||||
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
|
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
|
||||||
|
export type ScheduleTriggerKind = (typeof SCHEDULE_TRIGGER_KINDS)[number];
|
||||||
|
export type ScheduleIntervalUnit = (typeof SCHEDULE_INTERVAL_UNITS)[number];
|
||||||
|
|
||||||
export type ScheduleTargetSelector =
|
export type ScheduleTargetSelector =
|
||||||
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
||||||
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] };
|
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
|
||||||
|
| { readonly mode: 'group'; readonly groupId: string }
|
||||||
|
| { readonly mode: 'all' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub-style scheduling vocabulary. `cron` keeps the existing five-field engine, `fixed` is a set of
|
||||||
|
* weekday/time pairs, and `interval` is a plain wall-clock period.
|
||||||
|
*/
|
||||||
|
export type ScheduleTrigger =
|
||||||
|
| { readonly kind: 'cron'; readonly expression: string }
|
||||||
|
| {
|
||||||
|
readonly kind: 'fixed';
|
||||||
|
readonly weekdays: readonly number[];
|
||||||
|
readonly times: readonly string[];
|
||||||
|
}
|
||||||
|
| { readonly kind: 'interval'; readonly value: number; readonly unit: ScheduleIntervalUnit };
|
||||||
|
|
||||||
export interface ScheduleRetryPolicy {
|
export interface ScheduleRetryPolicy {
|
||||||
readonly maxRetries: number;
|
readonly maxRetries: number;
|
||||||
@@ -32,6 +57,8 @@ export interface ScheduleRetryPolicy {
|
|||||||
export interface ScheduledSmsInput {
|
export interface ScheduledSmsInput {
|
||||||
readonly recipients: readonly string[];
|
readonly recipients: readonly string[];
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
|
/** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */
|
||||||
|
readonly randomDelaySeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledSmsSummary {
|
export interface ScheduledSmsSummary {
|
||||||
@@ -42,10 +69,13 @@ export interface ScheduledSmsSummary {
|
|||||||
export interface CreateScheduledTaskRequest {
|
export interface CreateScheduledTaskRequest {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly operationType: ScheduledOperationType;
|
readonly operationType: ScheduledOperationType;
|
||||||
|
/** Optional so legacy cron-only callers keep working; parsers always fill it in. */
|
||||||
|
readonly trigger?: ScheduleTrigger;
|
||||||
readonly cronExpression: string;
|
readonly cronExpression: string;
|
||||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||||
readonly targetSelector: ScheduleTargetSelector;
|
readonly targetSelector: ScheduleTargetSelector;
|
||||||
readonly sms?: ScheduledSmsInput;
|
readonly sms?: ScheduledSmsInput;
|
||||||
|
readonly delaySeconds?: number;
|
||||||
readonly effectiveStartAt?: string;
|
readonly effectiveStartAt?: string;
|
||||||
readonly effectiveEndAt?: string;
|
readonly effectiveEndAt?: string;
|
||||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||||
@@ -58,10 +88,12 @@ export interface UpdateScheduledTaskRequest {
|
|||||||
readonly version: number;
|
readonly version: number;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly operationType?: ScheduledOperationType;
|
readonly operationType?: ScheduledOperationType;
|
||||||
|
readonly trigger?: ScheduleTrigger;
|
||||||
readonly cronExpression?: string;
|
readonly cronExpression?: string;
|
||||||
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
||||||
readonly targetSelector?: ScheduleTargetSelector;
|
readonly targetSelector?: ScheduleTargetSelector;
|
||||||
readonly sms?: ScheduledSmsInput | null;
|
readonly sms?: ScheduledSmsInput | null;
|
||||||
|
readonly delaySeconds?: number | null;
|
||||||
readonly effectiveStartAt?: string | null;
|
readonly effectiveStartAt?: string | null;
|
||||||
readonly effectiveEndAt?: string | null;
|
readonly effectiveEndAt?: string | null;
|
||||||
readonly misfirePolicy?: ScheduleMisfirePolicy;
|
readonly misfirePolicy?: ScheduleMisfirePolicy;
|
||||||
@@ -74,10 +106,12 @@ export interface ScheduledTask {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly operationType: ScheduledOperationType;
|
readonly operationType: ScheduledOperationType;
|
||||||
|
readonly trigger?: ScheduleTrigger;
|
||||||
readonly cronExpression: string;
|
readonly cronExpression: string;
|
||||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||||
readonly targetSelector: ScheduleTargetSelector;
|
readonly targetSelector: ScheduleTargetSelector;
|
||||||
readonly sms?: ScheduledSmsSummary;
|
readonly sms?: ScheduledSmsSummary;
|
||||||
|
readonly delaySeconds?: number;
|
||||||
readonly effectiveStartAt?: string;
|
readonly effectiveStartAt?: string;
|
||||||
readonly effectiveEndAt?: string;
|
readonly effectiveEndAt?: string;
|
||||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||||
@@ -130,10 +164,12 @@ export interface CronPreview {
|
|||||||
const CREATE_KEYS = new Set([
|
const CREATE_KEYS = new Set([
|
||||||
'name',
|
'name',
|
||||||
'operationType',
|
'operationType',
|
||||||
|
'trigger',
|
||||||
'cronExpression',
|
'cronExpression',
|
||||||
'timezone',
|
'timezone',
|
||||||
'targetSelector',
|
'targetSelector',
|
||||||
'sms',
|
'sms',
|
||||||
|
'delaySeconds',
|
||||||
'effectiveStartAt',
|
'effectiveStartAt',
|
||||||
'effectiveEndAt',
|
'effectiveEndAt',
|
||||||
'misfirePolicy',
|
'misfirePolicy',
|
||||||
@@ -204,42 +240,147 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
|
|||||||
tags: strings(source.tags, 'tags', 200),
|
tags: strings(source.tags, 'tags', 200),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (source.mode === 'group') {
|
||||||
|
exactKeys(source, new Set(['mode', 'groupId']));
|
||||||
|
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
|
||||||
|
}
|
||||||
|
if (source.mode === 'all') {
|
||||||
|
exactKeys(source, new Set(['mode']));
|
||||||
|
return { mode: 'all' };
|
||||||
|
}
|
||||||
throw new TypeError('targetSelector mode is invalid');
|
throw new TypeError('targetSelector mode is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERVAL_UNIT_MS: Record<ScheduleIntervalUnit, number> = {
|
||||||
|
mins: 60_000,
|
||||||
|
hours: 3_600_000,
|
||||||
|
days: 86_400_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function intervalMilliseconds(
|
||||||
|
trigger: Extract<ScheduleTrigger, { kind: 'interval' }>,
|
||||||
|
): number {
|
||||||
|
return trigger.value * INTERVAL_UNIT_MS[trigger.unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOfDay(value: unknown): string {
|
||||||
|
const raw = string(value, 'time', 5);
|
||||||
|
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(raw);
|
||||||
|
const hour = match?.[1];
|
||||||
|
const minute = match?.[2];
|
||||||
|
if (!hour || !minute) throw new TypeError('time must use HH:MM syntax');
|
||||||
|
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdays(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value) || value.length === 0 || value.length > 7)
|
||||||
|
throw new TypeError('weekdays must contain 1-7 values');
|
||||||
|
const result = value.map((item) => {
|
||||||
|
if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 7)
|
||||||
|
throw new TypeError('weekdays must be between Monday (1) and Sunday (7)');
|
||||||
|
return item as number;
|
||||||
|
});
|
||||||
|
if (new Set(result).size !== result.length) throw new TypeError('weekdays must be unique');
|
||||||
|
return result.sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function times(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value) || value.length === 0 || value.length > 48)
|
||||||
|
throw new TypeError('times must contain 1-48 values');
|
||||||
|
const result = value.map(timeOfDay);
|
||||||
|
if (new Set(result).size !== result.length) throw new TypeError('times must be unique');
|
||||||
|
return result.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScheduleTrigger(value: unknown): ScheduleTrigger {
|
||||||
|
const source = object(value, 'trigger');
|
||||||
|
if (source.kind === 'cron') {
|
||||||
|
exactKeys(source, new Set(['kind', 'expression']));
|
||||||
|
return { kind: 'cron', expression: cronExpression(source.expression) };
|
||||||
|
}
|
||||||
|
if (source.kind === 'fixed') {
|
||||||
|
exactKeys(source, new Set(['kind', 'weekdays', 'times']));
|
||||||
|
return { kind: 'fixed', weekdays: weekdays(source.weekdays), times: times(source.times) };
|
||||||
|
}
|
||||||
|
if (source.kind === 'interval') {
|
||||||
|
exactKeys(source, new Set(['kind', 'value', 'unit']));
|
||||||
|
if (!Number.isSafeInteger(source.value) || (source.value as number) < 1)
|
||||||
|
throw new TypeError('interval value must be a positive integer');
|
||||||
|
if ((source.value as number) > 10_000)
|
||||||
|
throw new TypeError('interval value must be at most 10000');
|
||||||
|
return {
|
||||||
|
kind: 'interval',
|
||||||
|
value: source.value as number,
|
||||||
|
unit: member(source.unit, SCHEDULE_INTERVAL_UNITS, 'interval unit'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new TypeError('trigger kind is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical five-field cron kept for the legacy `cron_expression` column and for display. Only the
|
||||||
|
* `cron` kind is exact; `fixed` collapses to its earliest pair and `interval` to its step form.
|
||||||
|
*/
|
||||||
|
export function deriveCronExpression(trigger: ScheduleTrigger): string {
|
||||||
|
if (trigger.kind === 'cron') return trigger.expression;
|
||||||
|
if (trigger.kind === 'interval') {
|
||||||
|
if (trigger.unit === 'mins')
|
||||||
|
return trigger.value === 1 ? '* * * * *' : `*/${trigger.value} * * * *`;
|
||||||
|
if (trigger.unit === 'hours')
|
||||||
|
return trigger.value === 1 ? '0 * * * *' : `0 */${trigger.value} * * *`;
|
||||||
|
return trigger.value === 1 ? '0 0 * * *' : `0 0 */${trigger.value} * *`;
|
||||||
|
}
|
||||||
|
const earliest = trigger.times[0] ?? '00:00';
|
||||||
|
const [hour, minute] = earliest.split(':');
|
||||||
|
const days = trigger.weekdays.length === 7 ? '*' : trigger.weekdays.join(',');
|
||||||
|
return `${Number(minute)} ${Number(hour)} * * ${days}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the effective trigger for a stored or legacy task. */
|
||||||
|
export function scheduleTriggerOf(task: {
|
||||||
|
readonly trigger?: ScheduleTrigger;
|
||||||
|
readonly cronExpression: string;
|
||||||
|
}): ScheduleTrigger {
|
||||||
|
return task.trigger ?? { kind: 'cron', expression: task.cronExpression };
|
||||||
|
}
|
||||||
|
|
||||||
function sms(value: unknown): ScheduledSmsInput {
|
function sms(value: unknown): ScheduledSmsInput {
|
||||||
const source = object(value, 'sms');
|
const source = object(value, 'sms');
|
||||||
exactKeys(source, new Set(['recipients', 'content']));
|
exactKeys(source, new Set(['recipients', 'content', 'randomDelaySeconds']));
|
||||||
|
const randomDelaySeconds =
|
||||||
|
source.randomDelaySeconds === undefined
|
||||||
|
? undefined
|
||||||
|
: numberInRange(source.randomDelaySeconds, 'sms.randomDelaySeconds', 0, 600);
|
||||||
return {
|
return {
|
||||||
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
|
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
|
||||||
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
|
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
|
||||||
return recipient;
|
return recipient;
|
||||||
}),
|
}),
|
||||||
content: string(source.content, 'SMS content', 2_000),
|
content: string(source.content, 'SMS content', 2_000),
|
||||||
|
...(randomDelaySeconds === undefined ? {} : { randomDelaySeconds }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberInRange(value: unknown, label: string, minimum: number, maximum: number): number {
|
||||||
|
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum)
|
||||||
|
throw new TypeError(`${label} must be between ${minimum} and ${maximum}`);
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
|
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
|
||||||
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
|
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
|
||||||
const source = object(value, 'retryPolicy');
|
const source = object(value, 'retryPolicy');
|
||||||
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
|
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
|
||||||
const maxRetries = source.maxRetries;
|
const maxRetries = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10);
|
||||||
const intervalSeconds = source.intervalSeconds;
|
const intervalSeconds = numberInRange(
|
||||||
if (
|
source.intervalSeconds,
|
||||||
!Number.isSafeInteger(maxRetries) ||
|
'retryPolicy.intervalSeconds',
|
||||||
(maxRetries as number) < 0 ||
|
1,
|
||||||
(maxRetries as number) > 10
|
86_400,
|
||||||
)
|
);
|
||||||
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
|
if (operationType === 'reboot-system' && maxRetries > 0)
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(intervalSeconds) ||
|
|
||||||
(intervalSeconds as number) < 1 ||
|
|
||||||
(intervalSeconds as number) > 86_400
|
|
||||||
)
|
|
||||||
throw new TypeError('retryPolicy.intervalSeconds must be between 1 and 86400');
|
|
||||||
if (operationType === 'reboot-system' && (maxRetries as number) > 0)
|
|
||||||
throw new TypeError('System reboot automatic retries are not supported');
|
throw new TypeError('System reboot automatic retries are not supported');
|
||||||
return { maxRetries: maxRetries as number, intervalSeconds: intervalSeconds as number };
|
return { maxRetries, intervalSeconds };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
|
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
|
||||||
@@ -248,6 +389,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
|||||||
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||||
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
|
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
|
||||||
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
|
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
|
||||||
|
if (source.trigger === undefined && source.cronExpression === undefined)
|
||||||
|
throw new TypeError('trigger or cronExpression is required');
|
||||||
|
const trigger =
|
||||||
|
source.trigger === undefined
|
||||||
|
? ({ kind: 'cron', expression: cronExpression(source.cronExpression) } as const)
|
||||||
|
: parseScheduleTrigger(source.trigger);
|
||||||
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
|
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
|
||||||
if (operationType === 'send-sms' && !smsValue)
|
if (operationType === 'send-sms' && !smsValue)
|
||||||
throw new TypeError('SMS configuration is required');
|
throw new TypeError('SMS configuration is required');
|
||||||
@@ -265,13 +412,21 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
|||||||
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
||||||
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
||||||
throw new TypeError('enabled must be boolean');
|
throw new TypeError('enabled must be boolean');
|
||||||
|
const delaySeconds =
|
||||||
|
source.delaySeconds === undefined
|
||||||
|
? undefined
|
||||||
|
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
|
||||||
|
if (delaySeconds !== undefined && operationType !== 'reboot-system')
|
||||||
|
throw new TypeError('delaySeconds is only valid for reboot-system');
|
||||||
return {
|
return {
|
||||||
name: string(source.name, 'name', 120),
|
name: string(source.name, 'name', 120),
|
||||||
operationType,
|
operationType,
|
||||||
cronExpression: cronExpression(source.cronExpression),
|
trigger,
|
||||||
|
cronExpression: deriveCronExpression(trigger),
|
||||||
timezone: AUTOMATION_TIMEZONE,
|
timezone: AUTOMATION_TIMEZONE,
|
||||||
targetSelector: targetSelector(source.targetSelector),
|
targetSelector: targetSelector(source.targetSelector),
|
||||||
...(smsValue ? { sms: smsValue } : {}),
|
...(smsValue ? { sms: smsValue } : {}),
|
||||||
|
...(delaySeconds === undefined ? {} : { delaySeconds }),
|
||||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||||
misfirePolicy:
|
misfirePolicy:
|
||||||
@@ -299,8 +454,13 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
|||||||
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
|
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
|
||||||
if (source.operationType !== undefined)
|
if (source.operationType !== undefined)
|
||||||
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||||
if (source.cronExpression !== undefined)
|
if (source.trigger !== undefined) {
|
||||||
|
result.trigger = parseScheduleTrigger(source.trigger);
|
||||||
|
result.cronExpression = deriveCronExpression(result.trigger as ScheduleTrigger);
|
||||||
|
} else if (source.cronExpression !== undefined) {
|
||||||
result.cronExpression = cronExpression(source.cronExpression);
|
result.cronExpression = cronExpression(source.cronExpression);
|
||||||
|
result.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger;
|
||||||
|
}
|
||||||
if (source.timezone !== undefined) {
|
if (source.timezone !== undefined) {
|
||||||
if (source.timezone !== AUTOMATION_TIMEZONE)
|
if (source.timezone !== AUTOMATION_TIMEZONE)
|
||||||
throw new TypeError('timezone must be Asia/Shanghai');
|
throw new TypeError('timezone must be Asia/Shanghai');
|
||||||
@@ -309,6 +469,11 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
|||||||
if (source.targetSelector !== undefined)
|
if (source.targetSelector !== undefined)
|
||||||
result.targetSelector = targetSelector(source.targetSelector);
|
result.targetSelector = targetSelector(source.targetSelector);
|
||||||
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
|
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
|
||||||
|
if (source.delaySeconds !== undefined)
|
||||||
|
result.delaySeconds =
|
||||||
|
source.delaySeconds === null
|
||||||
|
? null
|
||||||
|
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
|
||||||
if (source.effectiveStartAt !== undefined)
|
if (source.effectiveStartAt !== undefined)
|
||||||
result.effectiveStartAt =
|
result.effectiveStartAt =
|
||||||
source.effectiveStartAt === null
|
source.effectiveStartAt === null
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export * from './errors.js';
|
|||||||
export * from './instances.js';
|
export * from './instances.js';
|
||||||
export * from './jobs.js';
|
export * from './jobs.js';
|
||||||
export * from './operations.js';
|
export * from './operations.js';
|
||||||
|
export * from './organization.js';
|
||||||
|
export * from './notifications.js';
|
||||||
export const contractsWorkspaceReady = true;
|
export const contractsWorkspaceReady = true;
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ export interface InstanceInput {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly origin: string;
|
readonly origin: string;
|
||||||
readonly tags?: readonly string[];
|
readonly tags?: readonly string[];
|
||||||
|
readonly groupId?: string | null;
|
||||||
readonly password?: PasswordUpdate;
|
readonly password?: PasswordUpdate;
|
||||||
}
|
}
|
||||||
export interface InstancePatch {
|
export interface InstancePatch {
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly origin?: string;
|
readonly origin?: string;
|
||||||
readonly tags?: readonly string[];
|
readonly tags?: readonly string[];
|
||||||
|
readonly groupId?: string | null;
|
||||||
readonly password?: PasswordUpdate;
|
readonly password?: PasswordUpdate;
|
||||||
}
|
}
|
||||||
export interface LoginInput {
|
export interface LoginInput {
|
||||||
@@ -58,6 +60,7 @@ export interface Instance {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly origin: string;
|
readonly origin: string;
|
||||||
readonly tags: readonly string[];
|
readonly tags: readonly string[];
|
||||||
|
readonly groupId: string | null;
|
||||||
readonly revision: Revision;
|
readonly revision: Revision;
|
||||||
readonly capabilityStatus: CapabilityStatus;
|
readonly capabilityStatus: CapabilityStatus;
|
||||||
readonly freshness: SnapshotFreshness;
|
readonly freshness: SnapshotFreshness;
|
||||||
@@ -68,6 +71,7 @@ export interface InstanceFilters {
|
|||||||
readonly capabilityStatus?: CapabilityStatus;
|
readonly capabilityStatus?: CapabilityStatus;
|
||||||
readonly freshness?: SnapshotFreshness;
|
readonly freshness?: SnapshotFreshness;
|
||||||
readonly tag?: string;
|
readonly tag?: string;
|
||||||
|
readonly groupId?: string;
|
||||||
readonly credentialConfigured?: boolean;
|
readonly credentialConfigured?: boolean;
|
||||||
}
|
}
|
||||||
export type InstancePageQuery = PageQuery<'name' | 'status' | 'freshness' | 'updatedAt'> &
|
export type InstancePageQuery = PageQuery<'name' | 'status' | 'freshness' | 'updatedAt'> &
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NOTIFICATION_CHANNEL_SPECS,
|
||||||
|
NotificationChannelConfigError,
|
||||||
|
isNotificationChannelType,
|
||||||
|
notificationChannelDefaults,
|
||||||
|
notificationChannelFields,
|
||||||
|
notificationChannelLabel,
|
||||||
|
notificationChannelSecretKeys,
|
||||||
|
notificationChannelSpec,
|
||||||
|
parseKeyValueField,
|
||||||
|
splitNotificationChannelConfig,
|
||||||
|
} from './notifications.js';
|
||||||
|
|
||||||
|
describe('notification channel specs', () => {
|
||||||
|
it('describes every Hub channel type exactly once', () => {
|
||||||
|
const types = NOTIFICATION_CHANNEL_SPECS.map((spec) => spec.type);
|
||||||
|
expect(new Set(types).size).toBe(types.length);
|
||||||
|
expect(types).toEqual([
|
||||||
|
'webhook',
|
||||||
|
'bark',
|
||||||
|
'pushplus',
|
||||||
|
'wecom_app',
|
||||||
|
'wecom_robot',
|
||||||
|
'dingtalk_robot',
|
||||||
|
'dingtalk_app',
|
||||||
|
'feishu_robot',
|
||||||
|
'telegram',
|
||||||
|
'email',
|
||||||
|
'serverchan',
|
||||||
|
]);
|
||||||
|
expect(NOTIFICATION_CHANNEL_SPECS.every((spec) => spec.summary.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps field keys unique and every field kind renderable', () => {
|
||||||
|
for (const spec of NOTIFICATION_CHANNEL_SPECS) {
|
||||||
|
const keys = spec.fields.map((field) => field.key);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
for (const field of spec.fields) {
|
||||||
|
expect([
|
||||||
|
'text',
|
||||||
|
'url',
|
||||||
|
'secret',
|
||||||
|
'number',
|
||||||
|
'select',
|
||||||
|
'toggle',
|
||||||
|
'key-values',
|
||||||
|
] as const).toContain(field.kind);
|
||||||
|
expect(field.label.length).toBeGreaterThan(0);
|
||||||
|
if (field.kind === 'select') expect((field.options ?? []).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks specs up by type and tolerates unknown input', () => {
|
||||||
|
expect(notificationChannelSpec('telegram')?.label).toBe('Telegram');
|
||||||
|
expect(notificationChannelSpec('nope')).toBeUndefined();
|
||||||
|
expect(isNotificationChannelType('bark')).toBe(true);
|
||||||
|
expect(isNotificationChannelType('Bark')).toBe(false);
|
||||||
|
expect(notificationChannelLabel('nope')).toBe('nope');
|
||||||
|
expect(notificationChannelFields('nope')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitNotificationChannelConfig', () => {
|
||||||
|
it('fills defaults and separates secrets from plain config', () => {
|
||||||
|
const { config, secrets } = splitNotificationChannelConfig('bark', {
|
||||||
|
server_url: 'https://bark.example.test',
|
||||||
|
device_key: ' key-1 ',
|
||||||
|
group: 'Island',
|
||||||
|
});
|
||||||
|
expect(config).toEqual({
|
||||||
|
server_url: 'https://bark.example.test',
|
||||||
|
group: 'Island',
|
||||||
|
sound: '',
|
||||||
|
level: '',
|
||||||
|
icon: '',
|
||||||
|
auto_copy: true,
|
||||||
|
save_history: true,
|
||||||
|
});
|
||||||
|
expect(secrets).toEqual({ device_key: 'key-1' });
|
||||||
|
expect(notificationChannelSecretKeys('bark')).toEqual(['device_key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a stored secret forward when the editor leaves it blank', () => {
|
||||||
|
const { config, secrets } = splitNotificationChannelConfig(
|
||||||
|
'telegram',
|
||||||
|
{ bot_token: '', chat_id: '42' },
|
||||||
|
{ bot_token: 'stored-token' },
|
||||||
|
);
|
||||||
|
expect(secrets).toEqual({ bot_token: 'stored-token' });
|
||||||
|
expect(config['chat_id']).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the missing required fields in the local language', () => {
|
||||||
|
expect(() => splitNotificationChannelConfig('serverchan', {})).toThrowError(
|
||||||
|
NotificationChannelConfigError,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
splitNotificationChannelConfig('serverchan', {});
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as NotificationChannelConfigError;
|
||||||
|
expect(failure.fields).toEqual(['send_key']);
|
||||||
|
expect(failure.message).toContain('SendKey');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates numeric ranges and select membership', () => {
|
||||||
|
const relay = {
|
||||||
|
smtp_host: 'smtp.example.test',
|
||||||
|
sender_address: 'a@example.test',
|
||||||
|
receiver_addresses: 'b@example.test',
|
||||||
|
};
|
||||||
|
expect(() => splitNotificationChannelConfig('email', { ...relay, smtp_port: 99_999 })).toThrow(
|
||||||
|
/SMTP 端口/u,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
splitNotificationChannelConfig('bark', { device_key: 'k', level: 'loud' }),
|
||||||
|
).toThrow(/推送级别/u);
|
||||||
|
expect(
|
||||||
|
splitNotificationChannelConfig('email', {
|
||||||
|
...relay,
|
||||||
|
smtp_port: '587',
|
||||||
|
smtp_security: 'starttls',
|
||||||
|
}).config['smtp_port'],
|
||||||
|
).toBe(587);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown channel types instead of guessing a schema', () => {
|
||||||
|
expect(() => splitNotificationChannelConfig('sms' as never, {} as never)).toThrowError(
|
||||||
|
NotificationChannelConfigError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps declared defaults for untouched fields', () => {
|
||||||
|
expect(notificationChannelDefaults('webhook')).toEqual({
|
||||||
|
url: '',
|
||||||
|
http_method: 'POST',
|
||||||
|
headers: '',
|
||||||
|
});
|
||||||
|
expect(notificationChannelFields('webhook').map((field) => field.kind)).toEqual([
|
||||||
|
'url',
|
||||||
|
'select',
|
||||||
|
'secret',
|
||||||
|
'key-values',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseKeyValueField', () => {
|
||||||
|
it('reads colon lines and ignores anything malformed', () => {
|
||||||
|
expect(parseKeyValueField('X-A: 1\n\nno-separator\nX-B: a/b \n:c-empty')).toEqual({
|
||||||
|
'X-A': '1',
|
||||||
|
'X-B': 'a/b',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for non-text input', () => {
|
||||||
|
expect(parseKeyValueField(undefined)).toEqual({});
|
||||||
|
expect(parseKeyValueField({})).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
/**
|
||||||
|
* Notification channel contract shared by the control plane and the console.
|
||||||
|
*
|
||||||
|
* The field tables mirror the official SimAdminHub channel configuration so a
|
||||||
|
* channel created here speaks the same wire format as one created there.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NOTIFICATION_CHANNEL_TYPES = [
|
||||||
|
'webhook',
|
||||||
|
'bark',
|
||||||
|
'pushplus',
|
||||||
|
'wecom_app',
|
||||||
|
'wecom_robot',
|
||||||
|
'dingtalk_robot',
|
||||||
|
'dingtalk_app',
|
||||||
|
'feishu_robot',
|
||||||
|
'telegram',
|
||||||
|
'email',
|
||||||
|
'serverchan',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationChannelType = (typeof NOTIFICATION_CHANNEL_TYPES)[number];
|
||||||
|
|
||||||
|
export const NOTIFICATION_EVENT_TYPES = [
|
||||||
|
'sms',
|
||||||
|
'ddns',
|
||||||
|
'version',
|
||||||
|
'system',
|
||||||
|
'device',
|
||||||
|
'automation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationChannelFieldKind =
|
||||||
|
| 'text'
|
||||||
|
| 'url'
|
||||||
|
| 'secret'
|
||||||
|
| 'number'
|
||||||
|
| 'select'
|
||||||
|
| 'toggle'
|
||||||
|
| 'key-values';
|
||||||
|
|
||||||
|
export interface NotificationChannelFieldOption {
|
||||||
|
readonly value: string;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationChannelFieldSpec {
|
||||||
|
readonly key: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly kind: NotificationChannelFieldKind;
|
||||||
|
readonly required?: boolean;
|
||||||
|
readonly default?: string | number | boolean;
|
||||||
|
readonly options?: readonly NotificationChannelFieldOption[];
|
||||||
|
readonly placeholder?: string;
|
||||||
|
readonly help?: string;
|
||||||
|
readonly maxLength?: number;
|
||||||
|
readonly minimum?: number;
|
||||||
|
readonly maximum?: number;
|
||||||
|
/** Only meaningful for `url` fields: the value is joined with a path segment. */
|
||||||
|
readonly trailingPath?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationChannelTypeSpec {
|
||||||
|
readonly type: NotificationChannelType;
|
||||||
|
readonly label: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly fields: readonly NotificationChannelFieldSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_LEVEL = [
|
||||||
|
{ value: '', label: '默认' },
|
||||||
|
{ value: 'passive', label: 'passive' },
|
||||||
|
{ value: 'active', label: 'active' },
|
||||||
|
{ value: 'timeSensitive', label: 'timeSensitive' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const NOTIFICATION_CHANNEL_SPECS: readonly NotificationChannelTypeSpec[] = [
|
||||||
|
{
|
||||||
|
type: 'webhook',
|
||||||
|
label: 'Webhook',
|
||||||
|
summary: '向自定义地址投递 JSON,可选 HMAC 签名校验。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'url', label: '回调地址', kind: 'url', required: true, maxLength: 2000 },
|
||||||
|
{
|
||||||
|
key: 'http_method',
|
||||||
|
label: '请求方法',
|
||||||
|
kind: 'select',
|
||||||
|
default: 'POST',
|
||||||
|
options: [
|
||||||
|
{ value: 'POST', label: 'POST' },
|
||||||
|
{ value: 'PUT', label: 'PUT' },
|
||||||
|
{ value: 'PATCH', label: 'PATCH' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'secret',
|
||||||
|
label: '签名密钥',
|
||||||
|
kind: 'secret',
|
||||||
|
maxLength: 4096,
|
||||||
|
help: '填写后随请求附带 X-Hub-Timestamp 与 X-Hub-Signature。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'headers',
|
||||||
|
label: '附加请求头',
|
||||||
|
kind: 'key-values',
|
||||||
|
maxLength: 2000,
|
||||||
|
placeholder: 'Authorization: Bearer ...',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bark',
|
||||||
|
label: 'Bark',
|
||||||
|
summary: '投递到 Bark 服务端,支持分组、铃声与推送级别。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'server_url', label: '服务器地址', kind: 'url', default: 'https://api.day.app' },
|
||||||
|
{ key: 'device_key', label: '设备 Key', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'group', label: '分组', kind: 'text', maxLength: 160 },
|
||||||
|
{ key: 'sound', label: '铃声', kind: 'text', maxLength: 80 },
|
||||||
|
{ key: 'level', label: '推送级别', kind: 'select', default: '', options: SELECT_LEVEL },
|
||||||
|
{ key: 'icon', label: '图标地址', kind: 'url', maxLength: 2000 },
|
||||||
|
{ key: 'auto_copy', label: '自动复制', kind: 'toggle', default: true },
|
||||||
|
{ key: 'save_history', label: '保存历史', kind: 'toggle', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'pushplus',
|
||||||
|
label: 'PushPlus',
|
||||||
|
summary: '通过 pushplus.plus 发送微信推送,支持 HTML 与话题分组。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'token', label: 'Token', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'topic', label: '话题', kind: 'text', maxLength: 160 },
|
||||||
|
{
|
||||||
|
key: 'template',
|
||||||
|
label: '消息模板',
|
||||||
|
kind: 'select',
|
||||||
|
default: 'txt',
|
||||||
|
options: [
|
||||||
|
{ value: 'txt', label: 'txt' },
|
||||||
|
{ value: 'html', label: 'html' },
|
||||||
|
{ value: 'json', label: 'json' },
|
||||||
|
{ value: 'cloudPrint', label: '云打印' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'channel', label: '发送渠道', kind: 'text', maxLength: 80 },
|
||||||
|
{ key: 'callback_url', label: '回调地址', kind: 'url', maxLength: 2000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'wecom_app',
|
||||||
|
label: '企业微信应用',
|
||||||
|
summary: '使用企业微信自建应用接口,按成员、部门或标签投递。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'api_base_url',
|
||||||
|
label: '接口地址',
|
||||||
|
kind: 'url',
|
||||||
|
default: 'https://qyapi.weixin.qq.com',
|
||||||
|
},
|
||||||
|
{ key: 'corp_id', label: '企业 ID', kind: 'text', required: true, maxLength: 160 },
|
||||||
|
{ key: 'agent_id', label: '应用 AgentId', kind: 'text', required: true, maxLength: 80 },
|
||||||
|
{ key: 'secret', label: '应用 Secret', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'to_user', label: '接收成员', kind: 'text', default: '@all', maxLength: 1000 },
|
||||||
|
{ key: 'to_party', label: '接收部门', kind: 'text', maxLength: 500 },
|
||||||
|
{ key: 'to_tag', label: '接收标签', kind: 'text', maxLength: 500 },
|
||||||
|
{ key: 'safe', label: '仅接收方可读', kind: 'toggle', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'wecom_robot',
|
||||||
|
label: '企业微信群机器人',
|
||||||
|
summary: '直接向群机器人 Webhook 投递 Markdown 消息。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
|
||||||
|
{ key: 'key', label: '密钥', kind: 'secret', maxLength: 512 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'dingtalk_robot',
|
||||||
|
label: '钉钉群机器人',
|
||||||
|
summary: '支持加签校验的钉钉自定义机器人,可 @ 指定手机号。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
|
||||||
|
{ key: 'access_token', label: 'Access Token', kind: 'secret', maxLength: 512 },
|
||||||
|
{ key: 'secret', label: '加签密钥', kind: 'secret', maxLength: 512 },
|
||||||
|
{ key: 'at_mobiles', label: '@ 手机号', kind: 'text', maxLength: 1000 },
|
||||||
|
{ key: 'at_all', label: '@ 所有人', kind: 'toggle', default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'dingtalk_app',
|
||||||
|
label: '钉钉应用',
|
||||||
|
summary: '使用钉钉企业内部机器人向指定会话发送消息。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'app_key', label: 'App Key', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'app_secret', label: 'App Secret', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'robot_code', label: 'Robot Code', kind: 'text', required: true, maxLength: 160 },
|
||||||
|
{
|
||||||
|
key: 'open_conversation_id',
|
||||||
|
label: '会话 ID',
|
||||||
|
kind: 'text',
|
||||||
|
required: true,
|
||||||
|
maxLength: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'msg_key',
|
||||||
|
label: '消息类型',
|
||||||
|
kind: 'select',
|
||||||
|
default: 'sampleText',
|
||||||
|
options: [
|
||||||
|
{ value: 'sampleText', label: 'sampleText' },
|
||||||
|
{ value: 'sampleMarkdown', label: 'sampleMarkdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'feishu_robot',
|
||||||
|
label: '飞书群机器人',
|
||||||
|
summary: '飞书自定义机器人,支持签名校验与关键词。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook 地址', kind: 'url', required: true, maxLength: 2000 },
|
||||||
|
{ key: 'token', label: 'Token', kind: 'secret', maxLength: 512 },
|
||||||
|
{ key: 'secret', label: '加签密钥', kind: 'secret', maxLength: 512 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'telegram',
|
||||||
|
label: 'Telegram',
|
||||||
|
summary: '通过 Bot API 向指定聊天发送消息,可指向自建中转地址。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'api_base_url', label: '接口地址', kind: 'url', default: 'https://api.telegram.org' },
|
||||||
|
{ key: 'bot_token', label: 'Bot Token', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'chat_id', label: 'Chat ID', kind: 'text', required: true, maxLength: 160 },
|
||||||
|
{
|
||||||
|
key: 'parse_mode',
|
||||||
|
label: '解析模式',
|
||||||
|
kind: 'select',
|
||||||
|
default: '',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: '纯文本' },
|
||||||
|
{ value: 'Markdown', label: 'Markdown' },
|
||||||
|
{ value: 'MarkdownV2', label: 'MarkdownV2' },
|
||||||
|
{ value: 'HTML', label: 'HTML' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'disable_web_page_preview', label: '关闭网页预览', kind: 'toggle', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
label: '邮件',
|
||||||
|
summary: '通过 SMTP 投递,支持隐式 TLS、STARTTLS 与明文端口。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'smtp_host', label: 'SMTP 地址', kind: 'text', required: true, maxLength: 255 },
|
||||||
|
{
|
||||||
|
key: 'smtp_port',
|
||||||
|
label: 'SMTP 端口',
|
||||||
|
kind: 'number',
|
||||||
|
default: 465,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 65_535,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smtp_security',
|
||||||
|
label: '传输加密',
|
||||||
|
kind: 'select',
|
||||||
|
default: 'implicit_tls',
|
||||||
|
options: [
|
||||||
|
{ value: 'implicit_tls', label: '隐式 TLS' },
|
||||||
|
{ value: 'starttls', label: 'STARTTLS' },
|
||||||
|
{ value: 'none', label: '不加密' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'allow_insecure_tls', label: '允许自签证书', kind: 'toggle', default: false },
|
||||||
|
{ key: 'username', label: '登录账号', kind: 'text', maxLength: 255 },
|
||||||
|
{ key: 'password', label: '登录密码', kind: 'secret', maxLength: 512 },
|
||||||
|
{ key: 'sender_address', label: '发件地址', kind: 'text', required: true, maxLength: 255 },
|
||||||
|
{ key: 'sender_name', label: '发件人名称', kind: 'text', maxLength: 160 },
|
||||||
|
{
|
||||||
|
key: 'receiver_addresses',
|
||||||
|
label: '收件地址',
|
||||||
|
kind: 'text',
|
||||||
|
required: true,
|
||||||
|
maxLength: 2000,
|
||||||
|
placeholder: 'a@example.com, b@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'message_format',
|
||||||
|
label: '正文格式',
|
||||||
|
kind: 'select',
|
||||||
|
default: 'plain',
|
||||||
|
options: [
|
||||||
|
{ value: 'plain', label: '纯文本' },
|
||||||
|
{ value: 'html', label: 'HTML' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'serverchan',
|
||||||
|
label: 'Server 酱',
|
||||||
|
summary: '兼容 Server 酱³ 与经典 SendKey 两种推送地址。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'send_key', label: 'SendKey', kind: 'secret', required: true, maxLength: 512 },
|
||||||
|
{ key: 'uid', label: 'UID', kind: 'text', maxLength: 160 },
|
||||||
|
{ key: 'channel', label: '渠道', kind: 'text', maxLength: 80 },
|
||||||
|
{ key: 'openid', label: 'OpenID', kind: 'text', maxLength: 160 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationChannelConfigValue = string | number | boolean;
|
||||||
|
export type NotificationChannelConfigMap = Readonly<Record<string, NotificationChannelConfigValue>>;
|
||||||
|
|
||||||
|
const SPEC_INDEX = new Map<string, NotificationChannelTypeSpec>(
|
||||||
|
NOTIFICATION_CHANNEL_SPECS.map((spec) => [spec.type, spec]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isNotificationChannelType(value: unknown): value is NotificationChannelType {
|
||||||
|
return typeof value === 'string' && SPEC_INDEX.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationChannelSpec(
|
||||||
|
type: NotificationChannelType | string,
|
||||||
|
): NotificationChannelTypeSpec | undefined {
|
||||||
|
return SPEC_INDEX.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationChannelLabel(type: string): string {
|
||||||
|
return notificationChannelSpec(type)?.label ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationChannelFields(
|
||||||
|
type: NotificationChannelType | string,
|
||||||
|
): readonly NotificationChannelFieldSpec[] {
|
||||||
|
return notificationChannelSpec(type)?.fields ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationChannelSecretKeys(
|
||||||
|
type: NotificationChannelType | string,
|
||||||
|
): readonly string[] {
|
||||||
|
return notificationChannelFields(type)
|
||||||
|
.filter((field) => field.kind === 'secret')
|
||||||
|
.map((field) => field.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults for every field of a type, used to prefill the editor. */
|
||||||
|
export function notificationChannelDefaults(
|
||||||
|
type: NotificationChannelType | string,
|
||||||
|
): Record<string, NotificationChannelConfigValue> {
|
||||||
|
const config: Record<string, NotificationChannelConfigValue> = {};
|
||||||
|
for (const field of notificationChannelFields(type)) {
|
||||||
|
if (field.kind === 'secret') continue;
|
||||||
|
if (field.default !== undefined) config[field.key] = field.default;
|
||||||
|
else if (field.kind === 'toggle') config[field.key] = false;
|
||||||
|
else if (field.kind === 'number') config[field.key] = 0;
|
||||||
|
else config[field.key] = '';
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationChannelConfigError extends Error {
|
||||||
|
readonly fields: readonly string[];
|
||||||
|
|
||||||
|
constructor(message: string, fields: readonly string[] = []) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'NotificationChannelConfigError';
|
||||||
|
this.fields = fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a raw editor payload against the type table and splits it into the
|
||||||
|
* values that may be persisted in clear text and the values that must go to the
|
||||||
|
* secret store.
|
||||||
|
*/
|
||||||
|
export function splitNotificationChannelConfig(
|
||||||
|
type: NotificationChannelType | string,
|
||||||
|
input: Readonly<Record<string, unknown>>,
|
||||||
|
existingSecrets: Readonly<Record<string, string>> = {},
|
||||||
|
): {
|
||||||
|
readonly config: Record<string, NotificationChannelConfigValue>;
|
||||||
|
readonly secrets: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const spec = notificationChannelSpec(type);
|
||||||
|
if (!spec) throw new NotificationChannelConfigError(`未知的通知通道类型:${String(type)}`);
|
||||||
|
const config: Record<string, NotificationChannelConfigValue> = {};
|
||||||
|
const secrets: Record<string, string> = {};
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const field of spec.fields) {
|
||||||
|
const raw = input[field.key];
|
||||||
|
if (field.kind === 'toggle') {
|
||||||
|
config[field.key] = raw === undefined ? field.default === true : raw === true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.kind === 'number') {
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
const fallback = typeof field.default === 'number' ? field.default : 0;
|
||||||
|
if (field.required && fallback === 0) missing.push(field.key);
|
||||||
|
config[field.key] = fallback;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = typeof raw === 'number' ? raw : Number(asTrimmedString(raw));
|
||||||
|
if (!Number.isFinite(parsed))
|
||||||
|
throw new NotificationChannelConfigError(`${field.label} 必须是数字`, [field.key]);
|
||||||
|
const minimum = field.minimum ?? Number.MIN_SAFE_INTEGER;
|
||||||
|
const maximum = field.maximum ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (parsed < minimum || parsed > maximum)
|
||||||
|
throw new NotificationChannelConfigError(
|
||||||
|
`${field.label} 必须在 ${minimum} 到 ${maximum} 之间`,
|
||||||
|
[field.key],
|
||||||
|
);
|
||||||
|
config[field.key] = Math.trunc(parsed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textValue = asTrimmedString(raw) ?? '';
|
||||||
|
const maxLength = field.maxLength ?? 2000;
|
||||||
|
if (textValue.length > maxLength)
|
||||||
|
throw new NotificationChannelConfigError(`${field.label} 长度超出限制`, [field.key]);
|
||||||
|
|
||||||
|
if (field.kind === 'secret') {
|
||||||
|
const carried = existingSecrets[field.key];
|
||||||
|
if (textValue === '') {
|
||||||
|
if (carried) secrets[field.key] = carried;
|
||||||
|
else if (field.required) missing.push(field.key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
secrets[field.key] = textValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === 'select' && field.options) {
|
||||||
|
const allowed = new Set(field.options.map((option) => option.value));
|
||||||
|
if (textValue !== '' && !allowed.has(textValue))
|
||||||
|
throw new NotificationChannelConfigError(`${field.label} 不是允许的取值`, [field.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textValue === '') {
|
||||||
|
if (field.required) missing.push(field.key);
|
||||||
|
config[field.key] = typeof field.default === 'string' ? field.default : '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
config[field.key] = textValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0)
|
||||||
|
throw new NotificationChannelConfigError(
|
||||||
|
`缺少必填项:${missing
|
||||||
|
.map(
|
||||||
|
(key) => notificationChannelFields(type).find((field) => field.key === key)?.label ?? key,
|
||||||
|
)
|
||||||
|
.join('、')}`,
|
||||||
|
missing,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { config, secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the `key-values` editor control ("a: 1\nb: 2") into an ordered map. */
|
||||||
|
export function parseKeyValueField(value: unknown): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
if (typeof value !== 'string') return result;
|
||||||
|
for (const line of value.split(/\r?\n/u)) {
|
||||||
|
const separator = line.indexOf(':');
|
||||||
|
if (separator <= 0) continue;
|
||||||
|
const key = line.slice(0, separator).trim().slice(0, 128);
|
||||||
|
const item = line
|
||||||
|
.slice(separator + 1)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 1024);
|
||||||
|
if (key) result[key] = item;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export interface DeviceGroup {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly deviceCount: number;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceGroupInput {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceGroupPatch {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTag {
|
||||||
|
readonly tag: string;
|
||||||
|
readonly color: string;
|
||||||
|
readonly deviceCount: number;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTagInput {
|
||||||
|
readonly tag: string;
|
||||||
|
readonly color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTagPatch {
|
||||||
|
readonly color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAG_COLORS = [
|
||||||
|
'',
|
||||||
|
'coral',
|
||||||
|
'turquoise',
|
||||||
|
'lime',
|
||||||
|
'yellow',
|
||||||
|
'pink',
|
||||||
|
'purple',
|
||||||
|
'blue',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TagColor = (typeof TAG_COLORS)[number];
|
||||||
|
|
||||||
|
function text(value: unknown, field: string, maximum: number): string {
|
||||||
|
if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
|
||||||
|
const result = value.trim();
|
||||||
|
if (result.length > maximum)
|
||||||
|
throw new TypeError(`${field} must be at most ${maximum} characters`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeviceGroupInput(value: unknown): DeviceGroupInput {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const name = text(record?.name, 'name', 100);
|
||||||
|
if (!name) throw new TypeError('name must not be empty');
|
||||||
|
const description =
|
||||||
|
record?.description === undefined ? '' : text(record.description, 'description', 500);
|
||||||
|
return { name, description };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeviceGroupPatch(value: unknown): DeviceGroupPatch {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const patch: { name?: string; description?: string } = {};
|
||||||
|
if (record?.name !== undefined) {
|
||||||
|
const name = text(record.name, 'name', 100);
|
||||||
|
if (!name) throw new TypeError('name must not be empty');
|
||||||
|
patch.name = name;
|
||||||
|
}
|
||||||
|
if (record?.description !== undefined)
|
||||||
|
patch.description = text(record.description, 'description', 500);
|
||||||
|
if (Object.keys(patch).length === 0) throw new TypeError('no group fields to update');
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeviceTagInput(value: unknown): DeviceTagInput {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const tag = text(record?.tag, 'tag', 100);
|
||||||
|
if (!tag) throw new TypeError('tag must not be empty');
|
||||||
|
const color = record?.color === undefined ? '' : text(record.color, 'color', 32);
|
||||||
|
return { tag, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeviceTagPatch(value: unknown): DeviceTagPatch {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record?.color === undefined) throw new TypeError('no tag fields to update');
|
||||||
|
return { color: text(record.color, 'color', 32) };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,67 +3,477 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
|
import { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
|
||||||
import { operationAcceptance58e2204, operationAcceptanceOverrides58e2204, acceptancePolicyCatalog, controlPlaneAcceptance, fixtureDisposition58e2204, renderOperationAcceptanceMatrix, surfaceGroups58e2204, policyGroups58e2204, scenarioGroups58e2204, availabilityGroups58e2204 } from '../src/acceptance-58e2204.ts';
|
import {
|
||||||
|
operationAcceptance58e2204,
|
||||||
|
operationAcceptanceOverrides58e2204,
|
||||||
|
acceptancePolicyCatalog,
|
||||||
|
controlPlaneAcceptance,
|
||||||
|
fixtureDisposition58e2204,
|
||||||
|
renderOperationAcceptanceMatrix,
|
||||||
|
surfaceGroups58e2204,
|
||||||
|
policyGroups58e2204,
|
||||||
|
scenarioGroups58e2204,
|
||||||
|
availabilityGroups58e2204,
|
||||||
|
} from '../src/acceptance-58e2204.ts';
|
||||||
import { DENY_REASONS, selectReadonlyOperations } from '../../test-fixtures/scripts/collector.ts';
|
import { DENY_REASONS, selectReadonlyOperations } from '../../test-fixtures/scripts/collector.ts';
|
||||||
|
|
||||||
const fixtureManifestPath=fileURLToPath(new URL('../../test-fixtures/src/manifest.json',import.meta.url));
|
const fixtureManifestPath = fileURLToPath(
|
||||||
const matrixPath=fileURLToPath(new URL('../../../docs/product/operation-acceptance-matrix.md',import.meta.url));
|
new URL('../../test-fixtures/src/manifest.json', import.meta.url),
|
||||||
const acceptanceSourcePath=fileURLToPath(new URL('../src/acceptance-58e2204.ts',import.meta.url));
|
);
|
||||||
const iaPath=fileURLToPath(new URL('../../../docs/product/information-architecture.md',import.meta.url));
|
const matrixPath = fileURLToPath(
|
||||||
const manifest=JSON.parse(await readFile(fixtureManifestPath,'utf8'));
|
new URL('../../../docs/product/operation-acceptance-matrix.md', import.meta.url),
|
||||||
const byId=new Map(upstream58e2204Operations.map((o:any)=>[o.operationId,o]));
|
);
|
||||||
const canonicalRoutes=new Set(['/fleet','/instances/new','/instances/:id/overview','/instances/:id/cellular','/instances/:id/device-network','/instances/:id/messages','/instances/:id/calls','/instances/:id/esim','/instances/:id/notifications','/instances/:id/automation','/instances/:id/ota','/settings/instances/:id']);
|
const acceptanceSourcePath = fileURLToPath(
|
||||||
const assertExactPartition=(groups:readonly {ids:readonly string[]}[])=>{const ids=groups.flatMap(g=>[...g.ids]);assert.equal(ids.length,117);assert.equal(new Set(ids).size,117);assert.deepEqual([...ids].sort(),[...byId.keys()].sort());};
|
new URL('../src/acceptance-58e2204.ts', import.meta.url),
|
||||||
|
);
|
||||||
|
const iaPath = fileURLToPath(
|
||||||
|
new URL('../../../docs/product/information-architecture.md', import.meta.url),
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(await readFile(fixtureManifestPath, 'utf8'));
|
||||||
|
const byId = new Map(upstream58e2204Operations.map((o: any) => [o.operationId, o]));
|
||||||
|
const canonicalRoutes = new Set([
|
||||||
|
'/fleet',
|
||||||
|
'/instances/new',
|
||||||
|
'/instances/:id/overview',
|
||||||
|
'/instances/:id/cellular',
|
||||||
|
'/instances/:id/device-network',
|
||||||
|
'/instances/:id/messages',
|
||||||
|
'/instances/:id/calls',
|
||||||
|
'/instances/:id/esim',
|
||||||
|
'/instances/:id/notifications',
|
||||||
|
'/instances/:id/automation',
|
||||||
|
'/instances/:id/ota',
|
||||||
|
'/settings/instances/:id',
|
||||||
|
]);
|
||||||
|
const assertExactPartition = (groups: readonly { ids: readonly string[] }[]) => {
|
||||||
|
const ids = groups.flatMap((g) => [...g.ids]);
|
||||||
|
assert.equal(ids.length, 117);
|
||||||
|
assert.equal(new Set(ids).size, 117);
|
||||||
|
assert.deepEqual([...ids].sort(), [...byId.keys()].sort());
|
||||||
|
};
|
||||||
|
|
||||||
test('RED→GREEN: surface/policy/scenario/availability are literal exact operation partitions',()=>{
|
test('RED→GREEN: surface/policy/scenario/availability are literal exact operation partitions', () => {
|
||||||
for(const groups of [surfaceGroups58e2204,policyGroups58e2204,scenarioGroups58e2204,availabilityGroups58e2204])assertExactPartition(groups);
|
for (const groups of [
|
||||||
assert.ok(surfaceGroups58e2204.filter(g=>g.surfaceId.startsWith('calls/')).length>=5);
|
surfaceGroups58e2204,
|
||||||
assert.ok(surfaceGroups58e2204.filter(g=>g.surfaceId.startsWith('cellular/')).length>=5);
|
policyGroups58e2204,
|
||||||
for(const group of surfaceGroups58e2204)for(const id of group.ids){const row=operationAcceptance58e2204.find(x=>x.operationId===id)!;assert.equal(row.surfaceId,group.surfaceId);assert.equal(row.primaryRoute,group.primaryRoute);}
|
scenarioGroups58e2204,
|
||||||
const tuples=new Set(policyGroups58e2204.map(g=>Object.values(g.policies).join('/')));assert.ok(policyGroups58e2204.length>=8);assert.ok(tuples.size>=8);
|
availabilityGroups58e2204,
|
||||||
const policy=(id:string)=>policyGroups58e2204.find(g=>g.ids.includes(id))!;assert.match(policy('getSmsList').groupId,/list/);assert.match(policy('getHealth').groupId,/detail/);assert.match(policy('getNetworkOperatorsScan').groupId,/scan/);assert.match(policy('postData').groupId,/direct/);assert.match(policy('postAuthLogin').groupId,/auth/);assert.match(policy('postSmsBatchDelete').groupId,/destructive/);assert.match(policy('postOtaApply').groupId,/ota/);
|
])
|
||||||
assert.ok(scenarioGroups58e2204.length>=8);const scenario=(id:string)=>scenarioGroups58e2204.find(g=>g.ids.includes(id))!.scenarios;assert.equal(scenario('getHealth').empty.applicable,false);assert.equal(scenario('getSmsList').empty.applicable,true);assert.equal(scenario('getStats').partial.applicable,true);assert.equal(scenario('getAuthStatus').partial.applicable,false);assert.equal(scenario('getNetworkOperatorsScan').empty.applicable,true);assert.equal(scenario('getNetworkOperatorsScan').partial.applicable,true);assert.equal(scenario('getNetworkOperatorsScan')['policy-forbidden'].applicable,true);assert.equal(scenario('getNetworkOperatorsScan')['unknown-result'].applicable,false);for(const id of ['postSmsBatchDelete','postNotificationsQueueRetryAll','postNotificationsQueueClear'])assert.equal(scenario(id).partial.applicable,true,id);for(const id of ['postSystemReboot','postServiceRestart','deleteCallHistoryId','postBandLock','postOtaApply','postOtaUpload','postOtaOnlinePrepare','postOtaLatestRelease','postOtaCancel','postData'])assert.equal(scenario(id).partial.applicable,false,id);for(const id of ['postData','postSmsBatchDelete']){assert.equal(scenario(id)['policy-forbidden'].applicable,true);assert.equal(scenario(id)['unknown-result'].applicable,true);}for(const id of ['getHealth','getSmsList']){assert.equal(scenario(id)['policy-forbidden'].applicable,false);assert.equal(scenario(id)['unknown-result'].applicable,false);}for(const group of scenarioGroups58e2204)assert.equal(group.scenarios['owner-switch'].applicable,true);
|
assertExactPartition(groups);
|
||||||
assert.ok(availabilityGroups58e2204.length>=4);for(const group of availabilityGroups58e2204)assert.ok(group.reason.length>20);
|
assert.ok(surfaceGroups58e2204.filter((g) => g.surfaceId.startsWith('calls/')).length >= 5);
|
||||||
|
assert.ok(surfaceGroups58e2204.filter((g) => g.surfaceId.startsWith('cellular/')).length >= 5);
|
||||||
|
for (const group of surfaceGroups58e2204)
|
||||||
|
for (const id of group.ids) {
|
||||||
|
const row = operationAcceptance58e2204.find((x) => x.operationId === id)!;
|
||||||
|
assert.equal(row.surfaceId, group.surfaceId);
|
||||||
|
assert.equal(row.primaryRoute, group.primaryRoute);
|
||||||
|
}
|
||||||
|
const tuples = new Set(policyGroups58e2204.map((g) => Object.values(g.policies).join('/')));
|
||||||
|
assert.ok(policyGroups58e2204.length >= 8);
|
||||||
|
assert.ok(tuples.size >= 8);
|
||||||
|
const policy = (id: string) => policyGroups58e2204.find((g) => g.ids.includes(id))!;
|
||||||
|
assert.match(policy('getSmsList').groupId, /list/);
|
||||||
|
assert.match(policy('getHealth').groupId, /detail/);
|
||||||
|
assert.match(policy('getNetworkOperatorsScan').groupId, /scan/);
|
||||||
|
assert.match(policy('postData').groupId, /direct/);
|
||||||
|
assert.match(policy('postAuthLogin').groupId, /auth/);
|
||||||
|
assert.match(policy('postSmsBatchDelete').groupId, /destructive/);
|
||||||
|
assert.match(policy('postOtaApply').groupId, /ota/);
|
||||||
|
assert.ok(scenarioGroups58e2204.length >= 8);
|
||||||
|
const scenario = (id: string) => scenarioGroups58e2204.find((g) => g.ids.includes(id))!.scenarios;
|
||||||
|
assert.equal(scenario('getHealth').empty.applicable, false);
|
||||||
|
assert.equal(scenario('getSmsList').empty.applicable, true);
|
||||||
|
assert.equal(scenario('getStats').partial.applicable, true);
|
||||||
|
assert.equal(scenario('getAuthStatus').partial.applicable, false);
|
||||||
|
assert.equal(scenario('getNetworkOperatorsScan').empty.applicable, true);
|
||||||
|
assert.equal(scenario('getNetworkOperatorsScan').partial.applicable, true);
|
||||||
|
assert.equal(scenario('getNetworkOperatorsScan')['policy-forbidden'].applicable, true);
|
||||||
|
assert.equal(scenario('getNetworkOperatorsScan')['unknown-result'].applicable, false);
|
||||||
|
for (const id of [
|
||||||
|
'postSmsBatchDelete',
|
||||||
|
'postNotificationsQueueRetryAll',
|
||||||
|
'postNotificationsQueueClear',
|
||||||
|
])
|
||||||
|
assert.equal(scenario(id).partial.applicable, true, id);
|
||||||
|
for (const id of [
|
||||||
|
'postSystemReboot',
|
||||||
|
'postServiceRestart',
|
||||||
|
'deleteCallHistoryId',
|
||||||
|
'postBandLock',
|
||||||
|
'postOtaApply',
|
||||||
|
'postOtaUpload',
|
||||||
|
'postOtaOnlinePrepare',
|
||||||
|
'postOtaLatestRelease',
|
||||||
|
'postOtaCancel',
|
||||||
|
'postData',
|
||||||
|
])
|
||||||
|
assert.equal(scenario(id).partial.applicable, false, id);
|
||||||
|
for (const id of ['postData', 'postSmsBatchDelete']) {
|
||||||
|
assert.equal(scenario(id)['policy-forbidden'].applicable, true);
|
||||||
|
assert.equal(scenario(id)['unknown-result'].applicable, true);
|
||||||
|
}
|
||||||
|
for (const id of ['getHealth', 'getSmsList']) {
|
||||||
|
assert.equal(scenario(id)['policy-forbidden'].applicable, false);
|
||||||
|
assert.equal(scenario(id)['unknown-result'].applicable, false);
|
||||||
|
}
|
||||||
|
for (const group of scenarioGroups58e2204)
|
||||||
|
assert.equal(group.scenarios['owner-switch'].applicable, true);
|
||||||
|
assert.ok(availabilityGroups58e2204.length >= 4);
|
||||||
|
for (const group of availabilityGroups58e2204) assert.ok(group.reason.length > 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RED→GREEN: acceptance ledger is exact, Registry-bound and IA-owned',()=>{
|
test('RED→GREEN: acceptance ledger is exact, Registry-bound and IA-owned', () => {
|
||||||
assert.equal(operationAcceptance58e2204.length,117);
|
assert.equal(operationAcceptance58e2204.length, 117);
|
||||||
assert.equal(new Set(operationAcceptance58e2204.map(x=>x.operationId)).size,117);
|
assert.equal(new Set(operationAcceptance58e2204.map((x) => x.operationId)).size, 117);
|
||||||
assert.deepEqual([...operationAcceptance58e2204.map(x=>x.operationId)].sort(),[...byId.keys()].sort());
|
assert.deepEqual(
|
||||||
for(const row of operationAcceptance58e2204){const op:any=byId.get(row.operationId);assert.ok(op);assert.equal(row.method,op.method);assert.equal(row.pathTemplate,op.pathTemplate);assert.equal(row.upstreamDomain,op.upstreamDomain);assert.equal(row.riskLevel,op.riskLevel);assert.equal(row.confirmationUX,op.confirmationPolicy);assert.ok(canonicalRoutes.has(row.primaryRoute),row.operationId);assert.ok(row.surfaceId);assert.ok(row.uiStrategy);assert.match(row.availability,/^(planned|unsupported-version|deferred-with-reason)$/);if(row.availability==='deferred-with-reason')assert.ok(row.availabilityReason);assert.ok(row.requiredStates.request.length&&row.requiredStates.freshness.length&&row.requiredStates.support.length);assert.ok(row.requiredStates.scenarios.length||row.requiredStates.naRationale);assert.ok(row.evidenceIds.length);}
|
[...operationAcceptance58e2204.map((x) => x.operationId)].sort(),
|
||||||
|
[...byId.keys()].sort(),
|
||||||
|
);
|
||||||
|
for (const row of operationAcceptance58e2204) {
|
||||||
|
const op: any = byId.get(row.operationId);
|
||||||
|
assert.ok(op);
|
||||||
|
assert.equal(row.method, op.method);
|
||||||
|
assert.equal(row.pathTemplate, op.pathTemplate);
|
||||||
|
assert.equal(row.upstreamDomain, op.upstreamDomain);
|
||||||
|
assert.equal(row.riskLevel, op.riskLevel);
|
||||||
|
assert.equal(row.confirmationUX, op.confirmationPolicy);
|
||||||
|
assert.ok(canonicalRoutes.has(row.primaryRoute), row.operationId);
|
||||||
|
assert.ok(row.surfaceId);
|
||||||
|
assert.ok(row.uiStrategy);
|
||||||
|
assert.match(row.availability, /^(planned|unsupported-version|deferred-with-reason)$/);
|
||||||
|
if (row.availability === 'deferred-with-reason') assert.ok(row.availabilityReason);
|
||||||
|
assert.ok(
|
||||||
|
row.requiredStates.request.length &&
|
||||||
|
row.requiredStates.freshness.length &&
|
||||||
|
row.requiredStates.support.length,
|
||||||
|
);
|
||||||
|
assert.ok(row.requiredStates.scenarios.length || row.requiredStates.naRationale);
|
||||||
|
assert.ok(row.evidenceIds.length);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RED→GREEN: exact overrides and policy-bound acceptance are auditable',()=>{
|
test('RED→GREEN: exact overrides and policy-bound acceptance are auditable', () => {
|
||||||
assert.deepEqual(Object.keys(operationAcceptanceOverrides58e2204).sort(),[...byId.keys()].sort());
|
assert.deepEqual(
|
||||||
const policyFields=['preconditionPolicyId','stalePolicyId','unsupportedPolicyId','retryPolicyId','resultPolicyId'];
|
Object.keys(operationAcceptanceOverrides58e2204).sort(),
|
||||||
for(const row of operationAcceptance58e2204){const override:any=(operationAcceptanceOverrides58e2204 as any)[row.operationId];assert.ok(override);assert.ok(row.availabilityReason?.length>20,row.operationId);assert.ok(row.versionEvidencePolicy?.length>20,row.operationId);assert.equal(row.surfaceId,override.surfaceId);assert.equal(row.uiStrategy,override.uiStrategy);for(const scenario of ['empty','partial','policy-forbidden','unknown-result','owner-switch']){assert.equal(typeof row.scenarioAcceptance[scenario].applicable,'boolean');assert.ok(row.scenarioAcceptance[scenario].rationale.length>8);}for(const field of policyFields){assert.equal((row as any)[field],override[field]);assert.ok((acceptancePolicyCatalog as any)[field][(row as any)[field]],`${row.operationId}:${field}`);}}
|
[...byId.keys()].sort(),
|
||||||
|
);
|
||||||
|
const policyFields = [
|
||||||
|
'preconditionPolicyId',
|
||||||
|
'stalePolicyId',
|
||||||
|
'unsupportedPolicyId',
|
||||||
|
'retryPolicyId',
|
||||||
|
'resultPolicyId',
|
||||||
|
];
|
||||||
|
for (const row of operationAcceptance58e2204) {
|
||||||
|
const override: any = (operationAcceptanceOverrides58e2204 as any)[row.operationId];
|
||||||
|
assert.ok(override);
|
||||||
|
assert.ok(row.availabilityReason?.length > 20, row.operationId);
|
||||||
|
assert.ok(row.versionEvidencePolicy?.length > 20, row.operationId);
|
||||||
|
assert.equal(row.surfaceId, override.surfaceId);
|
||||||
|
assert.equal(row.uiStrategy, override.uiStrategy);
|
||||||
|
for (const scenario of [
|
||||||
|
'empty',
|
||||||
|
'partial',
|
||||||
|
'policy-forbidden',
|
||||||
|
'unknown-result',
|
||||||
|
'owner-switch',
|
||||||
|
]) {
|
||||||
|
assert.equal(typeof row.scenarioAcceptance[scenario].applicable, 'boolean');
|
||||||
|
assert.ok(row.scenarioAcceptance[scenario].rationale.length > 8);
|
||||||
|
}
|
||||||
|
for (const field of policyFields) {
|
||||||
|
assert.equal((row as any)[field], override[field]);
|
||||||
|
assert.ok(
|
||||||
|
(acceptancePolicyCatalog as any)[field][(row as any)[field]],
|
||||||
|
`${row.operationId}:${field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real acceptance is an explicit availability decision coherent with fixture disposition',async()=>{const fixture=new Map(fixtureDisposition58e2204.map(x=>[x.operationId,x]));const valid=new Set(['REAL_READ','REAL_READ_DEFERRED','REAL_WRITE_LATER','SIMULATED_HIGH_RISK','CONTRACT_ONLY']);for(const group of availabilityGroups58e2204){assert.ok(valid.has(group.realAcceptance),group.groupId);for(const id of group.ids){const row=operationAcceptance58e2204.find(x=>x.operationId===id)!;assert.equal(row.realAcceptance,group.realAcceptance,id);const status=fixture.get(id)!.runtimeFixtureStatus;if(status==='captured-readonly')assert.equal(group.realAcceptance,'REAL_READ',id);if(status==='denied-readonly-candidate')assert.equal(group.realAcceptance,'REAL_READ_DEFERRED',id);if(group.realAcceptance==='REAL_READ')assert.equal(status,'captured-readonly',id);if(group.realAcceptance==='REAL_READ_DEFERRED')assert.equal(status,'denied-readonly-candidate',id);}}const source=await readFile(acceptanceSourcePath,'utf8');assert.doesNotMatch(source,/function\s+realAcceptance\s*\(/);assert.doesNotMatch(source,/realAcceptanceIntent/);assert.equal(operationAcceptance58e2204.filter(x=>x.realAcceptance==='REAL_READ').length,39);assert.equal(operationAcceptance58e2204.filter(x=>x.realAcceptance==='REAL_READ_DEFERRED').length,11);});
|
test('real acceptance is an explicit availability decision coherent with fixture disposition', async () => {
|
||||||
|
const fixture = new Map(fixtureDisposition58e2204.map((x) => [x.operationId, x]));
|
||||||
test('Phase 1 bootstrap gate defers Fleet component/E2E evidence to the Phase 5 implementation gate',async()=>{const ia=await readFile(iaPath,'utf8');const bootstrap=ia.match(/### Phase 0 → Phase 1 workspace\/contract bootstrap gate([\s\S]*?)(?=### Phase 5 implementation gate)/)?.[1];const phase5=ia.match(/### Phase 5 implementation gate([\s\S]*)/)?.[1];assert.ok(bootstrap);assert.ok(phase5);assert.doesNotMatch(bootstrap!,/Fleet[^\n]*组件\/E2E/);assert.match(phase5!,/Fleet[^\n]*组件\/E2E/);assert.match(phase5!,/\[ \][^\n]*Fleet/);assert.match(bootstrap!,/\[x\][^\n]*最终独立规格与质量\/安全复审均已通过/);assert.doesNotMatch(bootstrap!,/PENDING final independent review/);assert.match(ia,/E2E[^\n]*N\/A[^\n]*Phase 5/);});
|
const valid = new Set([
|
||||||
|
'REAL_READ',
|
||||||
test('control-plane flows are independent, structured, and preserve safety invariants',()=>{const ids=['instance-create','instance-update','instance-delete','secret-set','secret-preserve','secret-clear','config-import-preview','config-import-confirm','credential-verify','saved-secret-login','temporary-secret-login','logout','401-recovery','auth-setup','auth-password-change','auth-settings-read','auth-settings-write','job-cancel','job-retry','audit-export','system-settings-update'];assert.deepEqual(controlPlaneAcceptance.map(x=>x.flowId).sort(),ids.sort());for(const flow of controlPlaneAcceptance)for(const field of ['route','risk','riskSubtype','confirmation','preconditions','result','failureRecovery','secretPolicy','evidence'])assert.ok(String((flow as any)[field]).length>1,`${flow.flowId}:${field}`);assert.match(controlPlaneAcceptance.find(x=>x.flowId==='instance-delete')!.result,/new jobId.*two-phase/i);for(const id of ['secret-set','secret-preserve','secret-clear'])assert.match(controlPlaneAcceptance.find(x=>x.flowId===id)!.secretPolicy,/never.*value/i);for(const id of ['saved-secret-login','temporary-secret-login'])assert.match(controlPlaneAcceptance.find(x=>x.flowId===id)!.failureRecovery,/no automatic replay/i);assert.match(controlPlaneAcceptance.find(x=>x.flowId==='job-retry')!.result,/new jobId.*lineage/i);});
|
'REAL_READ_DEFERRED',
|
||||||
|
'REAL_WRITE_LATER',
|
||||||
test('product risk prose has no conflicting action summaries',async()=>{const root=fileURLToPath(new URL('../../../docs/product/',import.meta.url));const names=['project-charter.md','personas-and-workflows.md','information-architecture.md','current-system-audit.md'];const docs=(await Promise.all(names.map(n=>readFile(`${root}${n}`,'utf8')))).join('\n');for(const expected of [/notifications config[^\n]*R2[^\n]*Job/i,/automation config[^\n]*R2[^\n]*Job/i,/eSIM[^\n]*download[^\n]*R1[^\n]*direct/i,/WLAN connect[^\n]*R1[^\n]*forget[^\n]*R2/i,/DDNS config[^\n]*R2[^\n]*Job/i,/baseband restart[^\n]*R3[^\n]*status[^\n]*R0/i])assert.match(docs,expected);assert.doesNotMatch(docs,/Notifications[^\n]*config R1/i);assert.doesNotMatch(docs,/Automation[^\n]*config R1/i);});
|
'SIMULATED_HIGH_RISK',
|
||||||
test('R2/R3, dedicated auth, write strategy, and split bulk/fleet policy are gated',()=>{
|
'CONTRACT_ONLY',
|
||||||
for(const row of operationAcceptance58e2204){const op:any=byId.get(row.operationId);if(['R2','R3'].includes(row.riskLevel)){assert.equal(row.executionMode,op.executionPolicy==='dedicatedFlow'?'dedicated-flow':'preparation-job');assert.match(row.preconditions,/fresh preflight/i);assert.match(row.confirmationUX,/explicit|strong/);assert.match(row.resultDestination,/jobs\/:jobId/);assert.match(row.retryRecovery,/new Job lineage/i);}if(row.riskLevel==='R3')assert.equal(op.capability,'job');if(row.method!=='GET'){assert.notEqual(row.uiStrategy,'read-panel');assert.notEqual(row.realAcceptance,'REAL_READ');assert.equal(row.fleetBatchable,false);}}
|
]);
|
||||||
const auth=operationAcceptance58e2204.filter(x=>['postAuthSetup','postAuthPassword','postAuthSettings','postAuthLogin','postAuthLogout'].includes(x.operationId));assert.equal(auth.length,5);assert.ok(auth.every(x=>x.uiStrategy==='dedicated-auth-flow'&&x.executionMode==='dedicated-flow'));for(const id of ['postAuthLogin','postAuthLogout']){const row=auth.find(x=>x.operationId===id)!;assert.equal(row.sessionSubtype,'session-sensitive');assert.match(row.retryRecovery,/no automatic replay/i);assert.match(row.resultDestination,/metadata-only audit/i);}
|
for (const group of availabilityGroups58e2204) {
|
||||||
const sms=operationAcceptance58e2204.find(x=>x.operationId==='postSmsBatchDelete')!;assert.equal(sms.resourceBulk,true);assert.equal(sms.fleetBatchable,false);for(const id of ['postNotificationsQueueRetryAll','postNotificationsQueueClear'])assert.equal(operationAcceptance58e2204.find(x=>x.operationId===id)!.resourceBulk,true,id);for(const id of ['postSmsClear','postCallHistoryClear','postNotificationsLogsClear','postAutomationLogsClear'])assert.equal(operationAcceptance58e2204.find(x=>x.operationId===id)!.resourceBulk,false,id);
|
assert.ok(valid.has(group.realAcceptance), group.groupId);
|
||||||
const health=operationAcceptance58e2204.find(x=>x.operationId==='getHealth')!;assert.equal(health.fleetBatchable,true);assert.equal(health.partialAggregationPolicy,'per-item');
|
for (const id of group.ids) {
|
||||||
|
const row = operationAcceptance58e2204.find((x) => x.operationId === id)!;
|
||||||
|
assert.equal(row.realAcceptance, group.realAcceptance, id);
|
||||||
|
const status = fixture.get(id)!.runtimeFixtureStatus;
|
||||||
|
if (status === 'captured-readonly') assert.equal(group.realAcceptance, 'REAL_READ', id);
|
||||||
|
if (status === 'denied-readonly-candidate')
|
||||||
|
assert.equal(group.realAcceptance, 'REAL_READ_DEFERRED', id);
|
||||||
|
if (group.realAcceptance === 'REAL_READ') assert.equal(status, 'captured-readonly', id);
|
||||||
|
if (group.realAcceptance === 'REAL_READ_DEFERRED')
|
||||||
|
assert.equal(status, 'denied-readonly-candidate', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const source = await readFile(acceptanceSourcePath, 'utf8');
|
||||||
|
assert.doesNotMatch(source, /function\s+realAcceptance\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /realAcceptanceIntent/);
|
||||||
|
assert.equal(
|
||||||
|
operationAcceptance58e2204.filter((x) => x.realAcceptance === 'REAL_READ').length,
|
||||||
|
39,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
operationAcceptance58e2204.filter((x) => x.realAcceptance === 'REAL_READ_DEFERRED').length,
|
||||||
|
11,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fixture disposition is authoritative and exactly follows collector plus 78-file manifest',()=>{
|
test('Phase 1 bootstrap gate defers Fleet component/E2E evidence to the Phase 5 implementation gate', async () => {
|
||||||
assert.equal(fixtureDisposition58e2204.length,117);assert.equal(new Set(fixtureDisposition58e2204.map(x=>x.operationId)).size,117);
|
const ia = await readFile(iaPath, 'utf8');
|
||||||
const selected=selectReadonlyOperations(upstream58e2204Operations as any[]);assert.equal(selected.selected.length,39);assert.equal(selected.denied.length,11);assert.equal(Object.keys(DENY_REASONS).length,11);
|
const bootstrap = ia.match(
|
||||||
const selectedIds=new Set(selected.selected.map((x:any)=>x.operationId));const denied=new Map(selected.denied.map((x:any)=>[x.operationId,x.denyReason]));
|
/### Phase 0 → Phase 1 workspace\/contract bootstrap gate([\s\S]*?)(?=### Phase 5 implementation gate)/,
|
||||||
const filesById=new Map<string,any[]>();for(const file of manifest.files){const match=/instance-[12]--([^.]+)\.json$/.exec(file.path);assert.ok(match);const list=filesById.get(match[1])??[];list.push(file);filesById.set(match[1],list);}
|
)?.[1];
|
||||||
for(const d of fixtureDisposition58e2204){if(selectedIds.has(d.operationId)){assert.equal(d.runtimeFixtureStatus,'captured-readonly');assert.equal(d.fixtureCount,2);assert.equal(filesById.get(d.operationId)?.length,2);assert.deepEqual(d.aliases,['instance-1','instance-2']);assert.ok(d.observedCategories.length);}else if(denied.has(d.operationId)){assert.equal(d.runtimeFixtureStatus,'denied-readonly-candidate');assert.equal(d.reason,denied.get(d.operationId));assert.equal(d.fixtureCount,0);}else {assert.match(d.runtimeFixtureStatus,/^not-eligible-readonly-capture/);assert.equal(d.fixtureCount,0);}}
|
const phase5 = ia.match(/### Phase 5 implementation gate([\s\S]*)/)?.[1];
|
||||||
assert.equal(manifest.realFixtureCount,78);
|
assert.ok(bootstrap);
|
||||||
|
assert.ok(phase5);
|
||||||
|
assert.doesNotMatch(bootstrap!, /Fleet[^\n]*组件\/E2E/);
|
||||||
|
assert.match(phase5!, /Fleet[^\n]*组件\/E2E/);
|
||||||
|
assert.match(phase5!, /\[ \][^\n]*Fleet/);
|
||||||
|
assert.match(bootstrap!, /\[x\][^\n]*最终独立规格与质量\/安全复审均已通过/);
|
||||||
|
assert.doesNotMatch(bootstrap!, /PENDING final independent review/);
|
||||||
|
assert.match(ia, /E2E[^\n]*N\/A[^\n]*Phase 5/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('product risk prose defers to Registry and known conflicts stay corrected',async()=>{const productRoot=fileURLToPath(new URL('../../../docs/product/',import.meta.url));const workflows=await readFile(`${productRoot}personas-and-workflows.md`,'utf8');const charter=await readFile(`${productRoot}project-charter.md`,'utf8');assert.doesNotMatch(workflows,/下载 profile[^\n]*R2 Job/);assert.match(workflows,/WLAN connect 为 R1/);assert.match(workflows,/notifications config 为 R2 Job/);assert.match(workflows,/fleetBatchable=true/);assert.match(charter,/resourceBulk.*fleetBatchable/);for(const id of ['postEsimProfiles','postEsimProfilesIccidEnable','postDeviceNetworkWlanConnect','postNotificationsConfig','postAutomationConfig','postDeviceNetworkDdnsConfig','postBasebandRestart']){assert.ok(operationAcceptance58e2204.find(x=>x.operationId===id),id);}});
|
test('control-plane flows are independent, structured, and preserve safety invariants', () => {
|
||||||
|
const ids = [
|
||||||
|
'instance-create',
|
||||||
|
'instance-update',
|
||||||
|
'instance-delete',
|
||||||
|
'secret-set',
|
||||||
|
'secret-preserve',
|
||||||
|
'secret-clear',
|
||||||
|
'config-import-preview',
|
||||||
|
'config-import-confirm',
|
||||||
|
'credential-verify',
|
||||||
|
'saved-secret-login',
|
||||||
|
'temporary-secret-login',
|
||||||
|
'logout',
|
||||||
|
'401-recovery',
|
||||||
|
'auth-setup',
|
||||||
|
'auth-password-change',
|
||||||
|
'auth-settings-read',
|
||||||
|
'auth-settings-write',
|
||||||
|
'job-cancel',
|
||||||
|
'job-retry',
|
||||||
|
'audit-export',
|
||||||
|
'system-settings-update',
|
||||||
|
];
|
||||||
|
assert.deepEqual(controlPlaneAcceptance.map((x) => x.flowId).sort(), ids.sort());
|
||||||
|
for (const flow of controlPlaneAcceptance)
|
||||||
|
for (const field of [
|
||||||
|
'route',
|
||||||
|
'risk',
|
||||||
|
'riskSubtype',
|
||||||
|
'confirmation',
|
||||||
|
'preconditions',
|
||||||
|
'result',
|
||||||
|
'failureRecovery',
|
||||||
|
'secretPolicy',
|
||||||
|
'evidence',
|
||||||
|
])
|
||||||
|
assert.ok(String((flow as any)[field]).length > 1, `${flow.flowId}:${field}`);
|
||||||
|
assert.match(
|
||||||
|
controlPlaneAcceptance.find((x) => x.flowId === 'instance-delete')!.result,
|
||||||
|
/new jobId.*two-phase/i,
|
||||||
|
);
|
||||||
|
for (const id of ['secret-set', 'secret-preserve', 'secret-clear'])
|
||||||
|
assert.match(
|
||||||
|
controlPlaneAcceptance.find((x) => x.flowId === id)!.secretPolicy,
|
||||||
|
/never.*value/i,
|
||||||
|
);
|
||||||
|
for (const id of ['saved-secret-login', 'temporary-secret-login'])
|
||||||
|
assert.match(
|
||||||
|
controlPlaneAcceptance.find((x) => x.flowId === id)!.failureRecovery,
|
||||||
|
/no automatic replay/i,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
controlPlaneAcceptance.find((x) => x.flowId === 'job-retry')!.result,
|
||||||
|
/new jobId.*lineage/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('generated matrix is synchronized and contains each operation exactly once',async()=>{const doc=await readFile(matrixPath,'utf8');assert.equal(doc,renderOperationAcceptanceMatrix());const ids=[...doc.matchAll(/^\| `([^`]+)` \|/gm)].map(x=>x[1]).filter(id=>byId.has(id));assert.equal(ids.length,117);assert.deepEqual(ids.sort(),[...byId.keys()].sort());});
|
test('product risk prose has no conflicting action summaries', async () => {
|
||||||
|
const root = fileURLToPath(new URL('../../../docs/product/', import.meta.url));
|
||||||
|
const names = [
|
||||||
|
'project-charter.md',
|
||||||
|
'personas-and-workflows.md',
|
||||||
|
'information-architecture.md',
|
||||||
|
'current-system-audit.md',
|
||||||
|
];
|
||||||
|
const docs = (await Promise.all(names.map((n) => readFile(`${root}${n}`, 'utf8')))).join('\n');
|
||||||
|
for (const expected of [
|
||||||
|
/notifications config[^\n]*R2[^\n]*Job/i,
|
||||||
|
/automation config[^\n]*R2[^\n]*Job/i,
|
||||||
|
/eSIM[^\n]*download[^\n]*R1[^\n]*direct/i,
|
||||||
|
/WLAN connect[^\n]*R1[^\n]*forget[^\n]*R2/i,
|
||||||
|
/DDNS config[^\n]*R2[^\n]*Job/i,
|
||||||
|
/baseband restart[^\n]*R3[^\n]*status[^\n]*R0/i,
|
||||||
|
])
|
||||||
|
assert.match(docs, expected);
|
||||||
|
assert.doesNotMatch(docs, /Notifications[^\n]*config R1/i);
|
||||||
|
assert.doesNotMatch(docs, /Automation[^\n]*config R1/i);
|
||||||
|
});
|
||||||
|
test('R2/R3, dedicated auth, write strategy, and split bulk/fleet policy are gated', () => {
|
||||||
|
for (const row of operationAcceptance58e2204) {
|
||||||
|
const op: any = byId.get(row.operationId);
|
||||||
|
if (['R2', 'R3'].includes(row.riskLevel)) {
|
||||||
|
assert.equal(
|
||||||
|
row.executionMode,
|
||||||
|
op.executionPolicy === 'dedicatedFlow' ? 'dedicated-flow' : 'preparation-job',
|
||||||
|
);
|
||||||
|
assert.match(row.preconditions, /fresh preflight/i);
|
||||||
|
assert.match(row.confirmationUX, /explicit|strong/);
|
||||||
|
assert.match(row.resultDestination, /jobs\/:jobId/);
|
||||||
|
assert.match(row.retryRecovery, /new Job lineage/i);
|
||||||
|
}
|
||||||
|
if (row.riskLevel === 'R3') assert.equal(op.capability, 'job');
|
||||||
|
if (row.method !== 'GET') {
|
||||||
|
assert.notEqual(row.uiStrategy, 'read-panel');
|
||||||
|
assert.notEqual(row.realAcceptance, 'REAL_READ');
|
||||||
|
assert.equal(row.fleetBatchable, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auth = operationAcceptance58e2204.filter((x) =>
|
||||||
|
[
|
||||||
|
'postAuthSetup',
|
||||||
|
'postAuthPassword',
|
||||||
|
'postAuthSettings',
|
||||||
|
'postAuthLogin',
|
||||||
|
'postAuthLogout',
|
||||||
|
].includes(x.operationId),
|
||||||
|
);
|
||||||
|
assert.equal(auth.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
auth.every(
|
||||||
|
(x) => x.uiStrategy === 'dedicated-auth-flow' && x.executionMode === 'dedicated-flow',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const id of ['postAuthLogin', 'postAuthLogout']) {
|
||||||
|
const row = auth.find((x) => x.operationId === id)!;
|
||||||
|
assert.equal(row.sessionSubtype, 'session-sensitive');
|
||||||
|
assert.match(row.retryRecovery, /no automatic replay/i);
|
||||||
|
assert.match(row.resultDestination, /metadata-only audit/i);
|
||||||
|
}
|
||||||
|
const sms = operationAcceptance58e2204.find((x) => x.operationId === 'postSmsBatchDelete')!;
|
||||||
|
assert.equal(sms.resourceBulk, true);
|
||||||
|
assert.equal(sms.fleetBatchable, false);
|
||||||
|
for (const id of ['postNotificationsQueueRetryAll', 'postNotificationsQueueClear'])
|
||||||
|
assert.equal(
|
||||||
|
operationAcceptance58e2204.find((x) => x.operationId === id)!.resourceBulk,
|
||||||
|
true,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
for (const id of [
|
||||||
|
'postSmsClear',
|
||||||
|
'postCallHistoryClear',
|
||||||
|
'postNotificationsLogsClear',
|
||||||
|
'postAutomationLogsClear',
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
operationAcceptance58e2204.find((x) => x.operationId === id)!.resourceBulk,
|
||||||
|
false,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
const health = operationAcceptance58e2204.find((x) => x.operationId === 'getHealth')!;
|
||||||
|
assert.equal(health.fleetBatchable, true);
|
||||||
|
assert.equal(health.partialAggregationPolicy, 'per-item');
|
||||||
|
});
|
||||||
|
|
||||||
test('matrix renders metadata-only safe fixture categories for every operation',()=>{const doc=renderOperationAcceptanceMatrix();const safeCategories=new Set(['success','unsupported','auth-required']);const fixture=new Map(fixtureDisposition58e2204.map(x=>[x.operationId,x]));const rows=[...doc.matchAll(/^\| `([^`]+)` \|.*?\| ([^|]*categories=([^;|]+);[^|]*) \|/gm)].filter(match=>byId.has(match[1]));assert.equal(rows.length,117);for(const [,id,metadata,rendered] of rows){const disposition=fixture.get(id)!;const expected=disposition.observedCategories.length?[...disposition.observedCategories].sort().join(','):'none';assert.equal(rendered.trim(),expected,id);for(const category of disposition.observedCategories)assert.ok(safeCategories.has(category),`${id}:${category}`);assert.doesNotMatch(metadata,/"(?:response|body|sourceInstanceAlias)"\s*:|https?:\/\//i,id);}assert.match(doc,/metadata-only/i);});
|
test('fixture disposition is authoritative and exactly follows collector plus 78-file manifest', () => {
|
||||||
|
assert.equal(fixtureDisposition58e2204.length, 117);
|
||||||
|
assert.equal(new Set(fixtureDisposition58e2204.map((x) => x.operationId)).size, 117);
|
||||||
|
const selected = selectReadonlyOperations(upstream58e2204Operations as any[]);
|
||||||
|
assert.equal(selected.selected.length, 39);
|
||||||
|
assert.equal(selected.denied.length, 11);
|
||||||
|
assert.equal(Object.keys(DENY_REASONS).length, 11);
|
||||||
|
const selectedIds = new Set(selected.selected.map((x: any) => x.operationId));
|
||||||
|
const denied = new Map(selected.denied.map((x: any) => [x.operationId, x.denyReason]));
|
||||||
|
const filesById = new Map<string, any[]>();
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const match = /instance-[12]--([^.]+)\.json$/.exec(file.path);
|
||||||
|
assert.ok(match);
|
||||||
|
const list = filesById.get(match[1]) ?? [];
|
||||||
|
list.push(file);
|
||||||
|
filesById.set(match[1], list);
|
||||||
|
}
|
||||||
|
for (const d of fixtureDisposition58e2204) {
|
||||||
|
if (selectedIds.has(d.operationId)) {
|
||||||
|
assert.equal(d.runtimeFixtureStatus, 'captured-readonly');
|
||||||
|
assert.equal(d.fixtureCount, 2);
|
||||||
|
assert.equal(filesById.get(d.operationId)?.length, 2);
|
||||||
|
assert.deepEqual(d.aliases, ['instance-1', 'instance-2']);
|
||||||
|
assert.ok(d.observedCategories.length);
|
||||||
|
} else if (denied.has(d.operationId)) {
|
||||||
|
assert.equal(d.runtimeFixtureStatus, 'denied-readonly-candidate');
|
||||||
|
assert.equal(d.reason, denied.get(d.operationId));
|
||||||
|
assert.equal(d.fixtureCount, 0);
|
||||||
|
} else {
|
||||||
|
assert.match(d.runtimeFixtureStatus, /^not-eligible-readonly-capture/);
|
||||||
|
assert.equal(d.fixtureCount, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(manifest.realFixtureCount, 78);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product risk prose defers to Registry and known conflicts stay corrected', async () => {
|
||||||
|
const productRoot = fileURLToPath(new URL('../../../docs/product/', import.meta.url));
|
||||||
|
const workflows = await readFile(`${productRoot}personas-and-workflows.md`, 'utf8');
|
||||||
|
const charter = await readFile(`${productRoot}project-charter.md`, 'utf8');
|
||||||
|
assert.doesNotMatch(workflows, /下载 profile[^\n]*R2 Job/);
|
||||||
|
assert.match(workflows, /WLAN connect 为 R1/);
|
||||||
|
assert.match(workflows, /notifications config 为 R2 Job/);
|
||||||
|
assert.match(workflows, /fleetBatchable=true/);
|
||||||
|
assert.match(charter, /resourceBulk.*fleetBatchable/);
|
||||||
|
for (const id of [
|
||||||
|
'postEsimProfiles',
|
||||||
|
'postEsimProfilesIccidEnable',
|
||||||
|
'postDeviceNetworkWlanConnect',
|
||||||
|
'postNotificationsConfig',
|
||||||
|
'postAutomationConfig',
|
||||||
|
'postDeviceNetworkDdnsConfig',
|
||||||
|
'postBasebandRestart',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
operationAcceptance58e2204.find((x) => x.operationId === id),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generated matrix is synchronized and contains each operation exactly once', async () => {
|
||||||
|
const doc = await readFile(matrixPath, 'utf8');
|
||||||
|
assert.equal(doc, renderOperationAcceptanceMatrix());
|
||||||
|
const ids = [...doc.matchAll(/^\| `([^`]+)` \|/gm)].map((x) => x[1]).filter((id) => byId.has(id));
|
||||||
|
assert.equal(ids.length, 117);
|
||||||
|
assert.deepEqual(ids.sort(), [...byId.keys()].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matrix renders metadata-only safe fixture categories for every operation', () => {
|
||||||
|
const doc = renderOperationAcceptanceMatrix();
|
||||||
|
const safeCategories = new Set(['success', 'unsupported', 'auth-required']);
|
||||||
|
const fixture = new Map(fixtureDisposition58e2204.map((x) => [x.operationId, x]));
|
||||||
|
const rows = [
|
||||||
|
...doc.matchAll(/^\| `([^`]+)` \|.*?\| ([^|]*categories=([^;|]+);[^|]*) \|/gm),
|
||||||
|
].filter((match) => byId.has(match[1]));
|
||||||
|
assert.equal(rows.length, 117);
|
||||||
|
for (const [, id, metadata, rendered] of rows) {
|
||||||
|
const disposition = fixture.get(id)!;
|
||||||
|
const expected = disposition.observedCategories.length
|
||||||
|
? [...disposition.observedCategories].sort().join(',')
|
||||||
|
: 'none';
|
||||||
|
assert.equal(rendered.trim(), expected, id);
|
||||||
|
for (const category of disposition.observedCategories)
|
||||||
|
assert.ok(safeCategories.has(category), `${id}:${category}`);
|
||||||
|
assert.doesNotMatch(metadata, /"(?:response|body|sourceInstanceAlias)"\s*:|https?:\/\//i, id);
|
||||||
|
}
|
||||||
|
assert.match(doc, /metadata-only/i);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,56 +4,119 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
|
import { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
|
||||||
|
|
||||||
const PHASE_0_2_COMMIT='9dffadbec271227da7ffb192f31dc78c6ee3c2be';
|
const PHASE_0_2_COMMIT = '9dffadbec271227da7ffb192f31dc78c6ee3c2be';
|
||||||
const BASELINE_PATH='packages/operation-registry/src/upstream-58e2204.ts';
|
const BASELINE_PATH = 'packages/operation-registry/src/upstream-58e2204.ts';
|
||||||
const repoRoot=fileURLToPath(new URL('../../../',import.meta.url));
|
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||||
const safetyFields=['operationId','riskLevel','confirmationPolicy','capability','executionPolicy'] as const;
|
const safetyFields = [
|
||||||
type SafetyRow=Record<(typeof safetyFields)[number],string>;
|
'operationId',
|
||||||
|
'riskLevel',
|
||||||
|
'confirmationPolicy',
|
||||||
|
'capability',
|
||||||
|
'executionPolicy',
|
||||||
|
] as const;
|
||||||
|
type SafetyRow = Record<(typeof safetyFields)[number], string>;
|
||||||
|
|
||||||
function git(...args:string[]){return execFileSync('git',args,{cwd:repoRoot,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();}
|
function git(...args: string[]) {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse only the JSON array literal assigned to the frozen raw operations constant; never evaluate TypeScript. */
|
/** Parse only the JSON array literal assigned to the frozen raw operations constant; never evaluate TypeScript. */
|
||||||
export function parseFrozenSafetyBaseline(source:string):SafetyRow[]{
|
export function parseFrozenSafetyBaseline(source: string): SafetyRow[] {
|
||||||
const marker='const rawUpstream58e2204Operations = ';
|
const marker = 'const rawUpstream58e2204Operations = ';
|
||||||
const start=source.indexOf('[',source.indexOf(marker)+marker.length);
|
const start = source.indexOf('[', source.indexOf(marker) + marker.length);
|
||||||
assert.ok(source.includes(marker)&&start>=0,'frozen operation array marker missing');
|
assert.ok(source.includes(marker) && start >= 0, 'frozen operation array marker missing');
|
||||||
let quoted=false,escaped=false,depth=0,end=-1;
|
let quoted = false,
|
||||||
for(let i=start;i<source.length;i++){
|
escaped = false,
|
||||||
const ch=source[i];
|
depth = 0,
|
||||||
if(quoted){if(escaped)escaped=false;else if(ch==='\\')escaped=true;else if(ch==='"')quoted=false;continue;}
|
end = -1;
|
||||||
if(ch==='"'){quoted=true;continue;}if(ch==='[')depth++;else if(ch===']'&&--depth===0){end=i+1;break;}
|
for (let i = start; i < source.length; i++) {
|
||||||
}
|
const ch = source[i];
|
||||||
assert.ok(end>start,'unterminated frozen operation array');
|
if (quoted) {
|
||||||
const parsed=JSON.parse(source.slice(start,end));
|
if (escaped) escaped = false;
|
||||||
assert.ok(Array.isArray(parsed),'frozen operation baseline is not an array');
|
else if (ch === '\\') escaped = true;
|
||||||
return parsed.map((row:any,index:number)=>Object.fromEntries(safetyFields.map(field=>{assert.equal(typeof row?.[field],'string',`baseline row ${index} missing ${field}`);return [field,row[field]];})) as SafetyRow);
|
else if (ch === '"') quoted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
quoted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '[') depth++;
|
||||||
|
else if (ch === ']' && --depth === 0) {
|
||||||
|
end = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(end > start, 'unterminated frozen operation array');
|
||||||
|
const parsed = JSON.parse(source.slice(start, end));
|
||||||
|
assert.ok(Array.isArray(parsed), 'frozen operation baseline is not an array');
|
||||||
|
return parsed.map(
|
||||||
|
(row: any, index: number) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
safetyFields.map((field) => {
|
||||||
|
assert.equal(typeof row?.[field], 'string', `baseline row ${index} missing ${field}`);
|
||||||
|
return [field, row[field]];
|
||||||
|
}),
|
||||||
|
) as SafetyRow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertExactSafetyBaseline(actual:readonly SafetyRow[],expected:readonly SafetyRow[]){
|
export function assertExactSafetyBaseline(
|
||||||
assert.equal(expected.length,117,'independent baseline must contain exactly 117 operations');
|
actual: readonly SafetyRow[],
|
||||||
assert.equal(new Set(expected.map(x=>x.operationId)).size,117,'independent baseline operationIds must be unique');
|
expected: readonly SafetyRow[],
|
||||||
assert.equal(actual.length,117,'current Registry must contain exactly 117 operations');
|
) {
|
||||||
assert.equal(new Set(actual.map(x=>x.operationId)).size,117,'current Registry operationIds must be unique');
|
assert.equal(expected.length, 117, 'independent baseline must contain exactly 117 operations');
|
||||||
const sort=(rows:readonly SafetyRow[])=>[...rows].sort((a,b)=>a.operationId.localeCompare(b.operationId));
|
assert.equal(
|
||||||
assert.deepEqual(sort(actual),sort(expected),'current Registry safety fields differ from frozen Phase 0.2 Git object; update requires explicit safety review');
|
new Set(expected.map((x) => x.operationId)).size,
|
||||||
|
117,
|
||||||
|
'independent baseline operationIds must be unique',
|
||||||
|
);
|
||||||
|
assert.equal(actual.length, 117, 'current Registry must contain exactly 117 operations');
|
||||||
|
assert.equal(
|
||||||
|
new Set(actual.map((x) => x.operationId)).size,
|
||||||
|
117,
|
||||||
|
'current Registry operationIds must be unique',
|
||||||
|
);
|
||||||
|
const sort = (rows: readonly SafetyRow[]) =>
|
||||||
|
[...rows].sort((a, b) => a.operationId.localeCompare(b.operationId));
|
||||||
|
assert.deepEqual(
|
||||||
|
sort(actual),
|
||||||
|
sort(expected),
|
||||||
|
'current Registry safety fields differ from frozen Phase 0.2 Git object; update requires explicit safety review',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const project=(rows:readonly any[]):SafetyRow[]=>rows.map(row=>Object.fromEntries(safetyFields.map(field=>[field,row[field]])) as SafetyRow);
|
const project = (rows: readonly any[]): SafetyRow[] =>
|
||||||
|
rows.map(
|
||||||
|
(row) => Object.fromEntries(safetyFields.map((field) => [field, row[field]])) as SafetyRow,
|
||||||
|
);
|
||||||
|
|
||||||
test('independent Phase 0.2 Git object freezes all 117 Registry safety decisions',()=>{
|
test('independent Phase 0.2 Git object freezes all 117 Registry safety decisions', () => {
|
||||||
assert.equal(git('rev-parse','9dffadb'),PHASE_0_2_COMMIT);
|
assert.equal(git('rev-parse', '9dffadb'), PHASE_0_2_COMMIT);
|
||||||
assert.equal(git('cat-file','-t',PHASE_0_2_COMMIT),'commit');
|
assert.equal(git('cat-file', '-t', PHASE_0_2_COMMIT), 'commit');
|
||||||
const baseline=parseFrozenSafetyBaseline(git('show',`${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
const baseline = parseFrozenSafetyBaseline(git('show', `${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||||
assertExactSafetyBaseline(project(upstream58e2204Operations),baseline);
|
assertExactSafetyBaseline(project(upstream58e2204Operations), baseline);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('independent safety comparison rejects risk, confirmation, capability and execution-policy mutations',()=>{
|
test('independent safety comparison rejects risk, confirmation, capability and execution-policy mutations', () => {
|
||||||
const baseline=parseFrozenSafetyBaseline(git('show',`${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
const baseline = parseFrozenSafetyBaseline(git('show', `${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||||
const mutations:[string,string,string][]=[
|
const mutations: [string, string, string][] = [
|
||||||
['postData','riskLevel','R0'],
|
['postData', 'riskLevel', 'R0'],
|
||||||
['postSmsSend','confirmationPolicy','none'],
|
['postSmsSend', 'confirmationPolicy', 'none'],
|
||||||
['postSmsSend','capability','query'],
|
['postSmsSend', 'capability', 'query'],
|
||||||
['postSmsSend','executionPolicy','dedicatedFlow']
|
['postSmsSend', 'executionPolicy', 'dedicatedFlow'],
|
||||||
];
|
];
|
||||||
for(const [operationId,field,value] of mutations){const current=structuredClone(project(upstream58e2204Operations));(current.find(x=>x.operationId===operationId)! as any)[field]=value;assert.throws(()=>assertExactSafetyBaseline(current,baseline),/safety fields differ/,`${operationId}:${field}`);}
|
for (const [operationId, field, value] of mutations) {
|
||||||
|
const current = structuredClone(project(upstream58e2204Operations));
|
||||||
|
(current.find((x) => x.operationId === operationId)! as any)[field] = value;
|
||||||
|
assert.throws(
|
||||||
|
() => assertExactSafetyBaseline(current, baseline),
|
||||||
|
/safety fields differ/,
|
||||||
|
`${operationId}:${field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,60 +10,135 @@ const fixturePath = fileURLToPath(new URL('./fixtures/main-routes-58e2204.json',
|
|||||||
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
|
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
|
||||||
const key = (operation: { method: string; path?: string; pathTemplate?: string }) =>
|
const key = (operation: { method: string; path?: string; pathTemplate?: string }) =>
|
||||||
`${operation.method} ${operation.pathTemplate ?? operation.path}`;
|
`${operation.method} ${operation.pathTemplate ?? operation.path}`;
|
||||||
const evidenceFixturePath = fileURLToPath(new URL('./fixtures/source-evidence-58e2204.json', import.meta.url));
|
const evidenceFixturePath = fileURLToPath(
|
||||||
|
new URL('./fixtures/source-evidence-58e2204.json', import.meta.url),
|
||||||
|
);
|
||||||
const evidenceFixture = JSON.parse(await readFile(evidenceFixturePath, 'utf8'));
|
const evidenceFixture = JSON.parse(await readFile(evidenceFixturePath, 'utf8'));
|
||||||
const snapshotRoot = fileURLToPath(new URL('./fixtures/upstream-58e2204/', import.meta.url));
|
const snapshotRoot = fileURLToPath(new URL('./fixtures/upstream-58e2204/', import.meta.url));
|
||||||
const snapshotManifest = JSON.parse(await readFile(`${snapshotRoot}/manifest.json`, 'utf8'));
|
const snapshotManifest = JSON.parse(await readFile(`${snapshotRoot}/manifest.json`, 'utf8'));
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
'operationId', 'upstreamDomain', 'method', 'pathTemplate', 'handler', 'capability', 'riskLevel',
|
'operationId',
|
||||||
'requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'timeoutMs',
|
'upstreamDomain',
|
||||||
'idempotency', 'confirmationPolicy', 'batchable', 'syncSafe', 'sensitiveFields',
|
'method',
|
||||||
'auditedAtCommit', 'compatibilityAdapter', 'uiOwner', 'sourceEvidence', 'responseFixtureStatus',
|
'pathTemplate',
|
||||||
|
'handler',
|
||||||
|
'capability',
|
||||||
|
'riskLevel',
|
||||||
|
'requestSchemaEvidence',
|
||||||
|
'responseSchemaEvidence',
|
||||||
|
'requestContentType',
|
||||||
|
'timeoutMs',
|
||||||
|
'idempotency',
|
||||||
|
'confirmationPolicy',
|
||||||
|
'batchable',
|
||||||
|
'syncSafe',
|
||||||
|
'sensitiveFields',
|
||||||
|
'auditedAtCommit',
|
||||||
|
'compatibilityAdapter',
|
||||||
|
'uiOwner',
|
||||||
|
'sourceEvidence',
|
||||||
|
'responseFixtureStatus',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const expectedDomains = new Set([
|
const expectedDomains = new Set([
|
||||||
'instances-auth', 'device-system', 'sim', 'cellular', 'radio-lock', 'data-connection',
|
'instances-auth',
|
||||||
'device-network', 'workmode-esim', 'messages', 'calls', 'notifications', 'automation', 'ota',
|
'device-system',
|
||||||
|
'sim',
|
||||||
|
'cellular',
|
||||||
|
'radio-lock',
|
||||||
|
'data-connection',
|
||||||
|
'device-network',
|
||||||
|
'workmode-esim',
|
||||||
|
'messages',
|
||||||
|
'calls',
|
||||||
|
'notifications',
|
||||||
|
'automation',
|
||||||
|
'ota',
|
||||||
]);
|
]);
|
||||||
const expectedOwners = new Set([
|
const expectedOwners = new Set([
|
||||||
'instances-new', 'settings-instance', 'fleet', 'overview', 'cellular', 'device-network',
|
'instances-new',
|
||||||
'messages', 'calls', 'esim', 'notifications', 'automation', 'ota',
|
'settings-instance',
|
||||||
|
'fleet',
|
||||||
|
'overview',
|
||||||
|
'cellular',
|
||||||
|
'device-network',
|
||||||
|
'messages',
|
||||||
|
'calls',
|
||||||
|
'esim',
|
||||||
|
'notifications',
|
||||||
|
'automation',
|
||||||
|
'ota',
|
||||||
]);
|
]);
|
||||||
const operationKey = (operation: { method: string; pathTemplate: string; handler: string }) =>
|
const operationKey = (operation: { method: string; pathTemplate: string; handler: string }) =>
|
||||||
`${operation.method} ${operation.pathTemplate} ${operation.handler}`;
|
`${operation.method} ${operation.pathTemplate} ${operation.handler}`;
|
||||||
|
|
||||||
test('sensitive fields are independently snapshot-rebuildable model-chain anchors', async () => {
|
test('sensitive fields are independently snapshot-rebuildable model-chain anchors', async () => {
|
||||||
const directionClaim = (operation: any, direction: string) =>
|
const directionClaim = (operation: any, direction: string) =>
|
||||||
['request', 'path', 'query'].includes(direction) ? operation.requestEvidence : operation.responseEvidence;
|
['request', 'path', 'query'].includes(direction)
|
||||||
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
|
? operation.requestEvidence
|
||||||
const evidence = field.evidence;
|
: operation.responseEvidence;
|
||||||
assert.ok(evidence, `${operation.operationId}: ${field.path} missing evidence`);
|
for (const operation of upstream58e2204Operations as any[])
|
||||||
assert.match(evidence.sourceSha256, /^[a-f0-9]{64}$/);
|
for (const field of operation.sensitiveFields) {
|
||||||
assert.ok(evidence.sourceFile && evidence.symbol && (evidence.fieldPath || evidence.modelField || evidence.dynamicKey));
|
const evidence = field.evidence;
|
||||||
const source = await readFile(`${snapshotRoot}/${evidence.sourceFile}`, 'utf8');
|
assert.ok(evidence, `${operation.operationId}: ${field.path} missing evidence`);
|
||||||
const slice = source.split('\n').slice(evidence.startLine - 1, evidence.endLine).join('\n');
|
assert.match(evidence.sourceSha256, /^[a-f0-9]{64}$/);
|
||||||
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sourceSha256, `${operation.operationId}: ${field.path}`);
|
assert.ok(
|
||||||
assert.ok(directionClaim(operation, field.direction), `${operation.operationId}: direction`);
|
evidence.sourceFile &&
|
||||||
if (evidence.handlerToken) assert.ok(slice.includes(evidence.handlerToken));
|
evidence.symbol &&
|
||||||
else if (evidence.dynamicKey) assert.match(slice, new RegExp(`["']${evidence.dynamicKey}["']`));
|
(evidence.fieldPath || evidence.modelField || evidence.dynamicKey),
|
||||||
else {
|
);
|
||||||
assert.match(slice, new RegExp(`(?:struct|enum)\\s+${evidence.symbol}\\b`));
|
const source = await readFile(`${snapshotRoot}/${evidence.sourceFile}`, 'utf8');
|
||||||
assert.match(slice, new RegExp(`\\b${evidence.modelField}\\s*:`));
|
const slice = source
|
||||||
assert.equal(evidence.modelField, field.path.replace(/\[\*\]/g, '').split('.').at(-1));
|
.split('\n')
|
||||||
for (const link of evidence.containerPath ?? []) {
|
.slice(evidence.startLine - 1, evidence.endLine)
|
||||||
const linkSource = await readFile(`${snapshotRoot}/${link.sourceFile}`, 'utf8');
|
.join('\n');
|
||||||
const linkSlice = linkSource.split('\n').slice(link.startLine - 1, link.endLine).join('\n');
|
assert.equal(
|
||||||
assert.equal(createHash('sha256').update(linkSlice).digest('hex'), link.sourceSha256);
|
createHash('sha256').update(slice).digest('hex'),
|
||||||
assert.match(linkSlice, new RegExp(`struct\\s+${link.symbol}\\b`));
|
evidence.sourceSha256,
|
||||||
assert.match(linkSlice, new RegExp(`\\b${link.field}\\s*:\\s*(?:Vec<)?${link.targetSymbol}`));
|
`${operation.operationId}: ${field.path}`,
|
||||||
|
);
|
||||||
|
assert.ok(directionClaim(operation, field.direction), `${operation.operationId}: direction`);
|
||||||
|
if (evidence.handlerToken) assert.ok(slice.includes(evidence.handlerToken));
|
||||||
|
else if (evidence.dynamicKey)
|
||||||
|
assert.match(slice, new RegExp(`["']${evidence.dynamicKey}["']`));
|
||||||
|
else {
|
||||||
|
assert.match(slice, new RegExp(`(?:struct|enum)\\s+${evidence.symbol}\\b`));
|
||||||
|
assert.match(slice, new RegExp(`\\b${evidence.modelField}\\s*:`));
|
||||||
|
assert.equal(
|
||||||
|
evidence.modelField,
|
||||||
|
field.path
|
||||||
|
.replace(/\[\*\]/g, '')
|
||||||
|
.split('.')
|
||||||
|
.at(-1),
|
||||||
|
);
|
||||||
|
for (const link of evidence.containerPath ?? []) {
|
||||||
|
const linkSource = await readFile(`${snapshotRoot}/${link.sourceFile}`, 'utf8');
|
||||||
|
const linkSlice = linkSource
|
||||||
|
.split('\n')
|
||||||
|
.slice(link.startLine - 1, link.endLine)
|
||||||
|
.join('\n');
|
||||||
|
assert.equal(createHash('sha256').update(linkSlice).digest('hex'), link.sourceSha256);
|
||||||
|
assert.match(linkSlice, new RegExp(`struct\\s+${link.symbol}\\b`));
|
||||||
|
assert.match(
|
||||||
|
linkSlice,
|
||||||
|
new RegExp(`\\b${link.field}\\s*:\\s*(?:Vec<)?${link.targetSymbol}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
const get = (method: string, path: string) =>
|
||||||
const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path);
|
(upstream58e2204Operations as any[]).find(
|
||||||
assert.ok(!get('GET','/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei')));
|
(o) => o.method === method && o.pathTemplate === path,
|
||||||
assert.deepEqual(get('GET','/api/device-network/wlan/status').sensitiveFields.map((f: any) => f.path), [
|
);
|
||||||
'$.response.data.ssid', '$.response.data.ipv4_addresses', '$.response.data.ipv6_addresses']);
|
assert.ok(!get('GET', '/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei')));
|
||||||
assert.deepEqual(get('GET','/api/device-network/wlan/profiles').sensitiveFields.map((f: any) => f.path), ['$.response.data.profiles[*].ssid']);
|
assert.deepEqual(
|
||||||
|
get('GET', '/api/device-network/wlan/status').sensitiveFields.map((f: any) => f.path),
|
||||||
|
['$.response.data.ssid', '$.response.data.ipv4_addresses', '$.response.data.ipv6_addresses'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
get('GET', '/api/device-network/wlan/profiles').sensitiveFields.map((f: any) => f.path),
|
||||||
|
['$.response.data.profiles[*].ssid'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional redaction contracts', () => {
|
test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional redaction contracts', () => {
|
||||||
@@ -71,9 +146,14 @@ test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional r
|
|||||||
for (const name of ['requestEvidence', 'responseEvidence']) {
|
for (const name of ['requestEvidence', 'responseEvidence']) {
|
||||||
const claim = operation[name];
|
const claim = operation[name];
|
||||||
assert.ok(claim && typeof claim === 'object', `${operation.operationId}: ${name}`);
|
assert.ok(claim && typeof claim === 'object', `${operation.operationId}: ${name}`);
|
||||||
assert.match(claim.kind, /^(none|path|query|json|multipart|bytes|explicit-return|api-response|dynamic-json|opaque)$/);
|
assert.match(
|
||||||
|
claim.kind,
|
||||||
|
/^(none|path|query|json|multipart|bytes|explicit-return|api-response|dynamic-json|opaque)$/,
|
||||||
|
);
|
||||||
assert.match(claim.sourceSha256, /^[a-f0-9]{64}$/);
|
assert.match(claim.sourceSha256, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(claim.sourceFile && Number.isInteger(claim.startLine) && Number.isInteger(claim.endLine));
|
assert.ok(
|
||||||
|
claim.sourceFile && Number.isInteger(claim.startLine) && Number.isInteger(claim.endLine),
|
||||||
|
);
|
||||||
assert.ok(Array.isArray(claim.symbols));
|
assert.ok(Array.isArray(claim.symbols));
|
||||||
assert.ok(Array.isArray(claim.modelEvidence));
|
assert.ok(Array.isArray(claim.modelEvidence));
|
||||||
}
|
}
|
||||||
@@ -85,56 +165,148 @@ test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional r
|
|||||||
assert.ok(field.sourceAnchor?.symbol || field.sourceAnchor?.token);
|
assert.ok(field.sourceAnchor?.symbol || field.sourceAnchor?.token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path);
|
const get = (method: string, path: string) =>
|
||||||
assert.ok(get('GET', '/api/sms/list').responseEvidence.symbols.some((s: any) => s.symbol === 'SmsListResponse'));
|
(upstream58e2204Operations as any[]).find(
|
||||||
assert.ok(get('GET', '/api/device').responseEvidence.symbols.some((s: any) => s.symbol === 'DeviceInfoResponse'));
|
(o) => o.method === method && o.pathTemplate === path,
|
||||||
assert.ok(get('GET', '/api/network').responseEvidence.symbols.some((s: any) => s.symbol === 'NetworkInfoResponse'));
|
);
|
||||||
assert.ok(get('POST', '/api/ota/upload').responseEvidence.symbols.some((s: any) => s.symbol === 'OtaUploadResponse'));
|
assert.ok(
|
||||||
|
get('GET', '/api/sms/list').responseEvidence.symbols.some(
|
||||||
|
(s: any) => s.symbol === 'SmsListResponse',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
get('GET', '/api/device').responseEvidence.symbols.some(
|
||||||
|
(s: any) => s.symbol === 'DeviceInfoResponse',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
get('GET', '/api/network').responseEvidence.symbols.some(
|
||||||
|
(s: any) => s.symbol === 'NetworkInfoResponse',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
get('POST', '/api/ota/upload').responseEvidence.symbols.some(
|
||||||
|
(s: any) => s.symbol === 'OtaUploadResponse',
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('structured claims are rebuilt from the independent snapshot, with exact braces and symbols', async () => {
|
test('structured claims are rebuilt from the independent snapshot, with exact braces and symbols', async () => {
|
||||||
for (const operation of upstream58e2204Operations as any[]) for (const claim of [operation.requestEvidence, operation.responseEvidence]) {
|
for (const operation of upstream58e2204Operations as any[])
|
||||||
const source = await readFile(`${snapshotRoot}/${claim.sourceFile}`, 'utf8');
|
for (const claim of [operation.requestEvidence, operation.responseEvidence]) {
|
||||||
const slice = source.split('\n').slice(claim.startLine - 1, claim.endLine).join('\n');
|
const source = await readFile(`${snapshotRoot}/${claim.sourceFile}`, 'utf8');
|
||||||
assert.equal(createHash('sha256').update(slice).digest('hex'), claim.sourceSha256, operation.operationId);
|
const slice = source
|
||||||
const fn = operation.handler.split('::').at(-1); const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source);
|
.split('\n')
|
||||||
assert.ok(signature); const open = source.indexOf('{', signature!.index); let depth = 0, close = -1;
|
.slice(claim.startLine - 1, claim.endLine)
|
||||||
for (let i = open; i < source.length; i++) { if (source[i] === '{') depth++; else if (source[i] === '}' && --depth === 0) { close = i; break; } }
|
.join('\n');
|
||||||
assert.equal(source.slice(0, close + 1).split('\n').length, claim.endLine, `${operation.operationId}: exact end`);
|
assert.equal(
|
||||||
for (const symbol of claim.symbols) assert.ok(slice.includes(symbol.symbol) || claim.modelEvidence.some((m: any) => m.symbol === symbol.symbol), `${operation.operationId}: ${symbol.symbol}`);
|
createHash('sha256').update(slice).digest('hex'),
|
||||||
for (const model of claim.modelEvidence) {
|
claim.sourceSha256,
|
||||||
const modelSource = await readFile(`${snapshotRoot}/${model.sourceFile}`, 'utf8');
|
operation.operationId,
|
||||||
const modelSlice = modelSource.split('\n').slice(model.startLine - 1, model.endLine).join('\n');
|
);
|
||||||
assert.equal(createHash('sha256').update(modelSlice).digest('hex'), model.sourceSha256, model.symbol);
|
const fn = operation.handler.split('::').at(-1);
|
||||||
assert.match(modelSlice, new RegExp(`(?:struct|enum)\\s+${model.symbol}\\b`));
|
const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source);
|
||||||
for (const field of model.fields) assert.match(modelSlice, new RegExp(`\\b${field}\\s*:`));
|
assert.ok(signature);
|
||||||
|
const open = source.indexOf('{', signature!.index);
|
||||||
|
let depth = 0,
|
||||||
|
close = -1;
|
||||||
|
for (let i = open; i < source.length; i++) {
|
||||||
|
if (source[i] === '{') depth++;
|
||||||
|
else if (source[i] === '}' && --depth === 0) {
|
||||||
|
close = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
source.slice(0, close + 1).split('\n').length,
|
||||||
|
claim.endLine,
|
||||||
|
`${operation.operationId}: exact end`,
|
||||||
|
);
|
||||||
|
for (const symbol of claim.symbols)
|
||||||
|
assert.ok(
|
||||||
|
slice.includes(symbol.symbol) ||
|
||||||
|
claim.modelEvidence.some((m: any) => m.symbol === symbol.symbol),
|
||||||
|
`${operation.operationId}: ${symbol.symbol}`,
|
||||||
|
);
|
||||||
|
for (const model of claim.modelEvidence) {
|
||||||
|
const modelSource = await readFile(`${snapshotRoot}/${model.sourceFile}`, 'utf8');
|
||||||
|
const modelSlice = modelSource
|
||||||
|
.split('\n')
|
||||||
|
.slice(model.startLine - 1, model.endLine)
|
||||||
|
.join('\n');
|
||||||
|
assert.equal(
|
||||||
|
createHash('sha256').update(modelSlice).digest('hex'),
|
||||||
|
model.sourceSha256,
|
||||||
|
model.symbol,
|
||||||
|
);
|
||||||
|
assert.match(modelSlice, new RegExp(`(?:struct|enum)\\s+${model.symbol}\\b`));
|
||||||
|
for (const field of model.fields) assert.match(modelSlice, new RegExp(`\\b${field}\\s*:`));
|
||||||
|
}
|
||||||
|
if (claim.kind === 'dynamic-json') assert.match(slice, /json!|Json\s*\(|ApiResponse::/);
|
||||||
}
|
}
|
||||||
if (claim.kind === 'dynamic-json') assert.match(slice, /json!|Json\s*\(|ApiResponse::/);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Axum route chains are independently parsed from frozen main.rs', async () => {
|
test('Axum route chains are independently parsed from frozen main.rs', async () => {
|
||||||
const source = await readFile(`${snapshotRoot}/backend/src/main.rs`, 'utf8'); const parsed: string[] = [];
|
const source = await readFile(`${snapshotRoot}/backend/src/main.rs`, 'utf8');
|
||||||
for (let from = 0; ;) {
|
const parsed: string[] = [];
|
||||||
const start = source.indexOf('.route(', from); if (start < 0) break; let depth = 0, quote = false, end = -1;
|
for (let from = 0; ; ) {
|
||||||
for (let i = start + 6; i < source.length; i++) { const c = source[i]; if (c === '"' && source[i - 1] !== '\\') quote = !quote; if (!quote && c === '(') depth++; else if (!quote && c === ')' && --depth === 0) { end = i; break; } }
|
const start = source.indexOf('.route(', from);
|
||||||
const chain = source.slice(start, end + 1); from = end + 1; const path = /\.route\(\s*"([^"]+)"/.exec(chain)?.[1]; if (!path) continue;
|
if (start < 0) break;
|
||||||
for (const match of chain.matchAll(/\.(get|post|delete)\(([\w:]+)\)|\b(get|post|delete)\(([\w:]+)\)/g)) { const method = (match[1] ?? match[3]).toUpperCase(); const handler = match[2] ?? match[4]; if (handler !== 'options_handler') parsed.push(`${method} ${path} ${handler}`); }
|
let depth = 0,
|
||||||
|
quote = false,
|
||||||
|
end = -1;
|
||||||
|
for (let i = start + 6; i < source.length; i++) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '"' && source[i - 1] !== '\\') quote = !quote;
|
||||||
|
if (!quote && c === '(') depth++;
|
||||||
|
else if (!quote && c === ')' && --depth === 0) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chain = source.slice(start, end + 1);
|
||||||
|
from = end + 1;
|
||||||
|
const path = /\.route\(\s*"([^"]+)"/.exec(chain)?.[1];
|
||||||
|
if (!path) continue;
|
||||||
|
for (const match of chain.matchAll(
|
||||||
|
/\.(get|post|delete)\(([\w:]+)\)|\b(get|post|delete)\(([\w:]+)\)/g,
|
||||||
|
)) {
|
||||||
|
const method = (match[1] ?? match[3]).toUpperCase();
|
||||||
|
const handler = match[2] ?? match[4];
|
||||||
|
if (handler !== 'options_handler') parsed.push(`${method} ${path} ${handler}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const expected = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort();
|
const expected = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort();
|
||||||
assert.deepEqual(parsed.sort(), expected); assert.deepEqual(parsed.sort(), (upstream58e2204Operations as any[]).map(operationKey).sort());
|
assert.deepEqual(parsed.sort(), expected);
|
||||||
|
assert.deepEqual(parsed.sort(), (upstream58e2204Operations as any[]).map(operationKey).sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Bruno files are independently parsed and agree with structured request claims', async () => {
|
test('Bruno files are independently parsed and agree with structured request claims', async () => {
|
||||||
for (const evidence of evidenceFixture.operations) for (const bruno of evidence.brunoEvidence ?? []) {
|
for (const evidence of evidenceFixture.operations)
|
||||||
const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8'); const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase();
|
for (const bruno of evidence.brunoEvidence ?? []) {
|
||||||
const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1]; assert.equal(method, bruno.method); assert.equal(url, bruno.path);
|
const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8');
|
||||||
const bodyMode = /body:(json|multipart|text)/.exec(text)?.[1] ?? 'none'; assert.equal(bodyMode, bruno.bodyMode, bruno.file);
|
const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase();
|
||||||
const operation: any = (upstream58e2204Operations as any[]).find(o => o.method === method && (o.pathTemplate === url || new RegExp(`^${o.pathTemplate.replace(/\{[^}]+\}/g, '[^/]+')}$`).test(url!))); assert.ok(operation, bruno.file);
|
const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1];
|
||||||
if (bodyMode === 'json' && bruno.bodyFields.length) assert.equal(operation.requestEvidence.kind, 'json', bruno.file);
|
assert.equal(method, bruno.method);
|
||||||
if (bodyMode === 'json' && !bruno.bodyFields.length) assert.ok(['none','path','query','json'].includes(operation.requestEvidence.kind), `${bruno.file}: empty Bruno JSON has no contract fields`);
|
assert.equal(url, bruno.path);
|
||||||
for (const field of bruno.bodyFields) assert.match(text, new RegExp(`\\b${field}\\b`));
|
const bodyMode = /body:(json|multipart|text)/.exec(text)?.[1] ?? 'none';
|
||||||
}
|
assert.equal(bodyMode, bruno.bodyMode, bruno.file);
|
||||||
|
const operation: any = (upstream58e2204Operations as any[]).find(
|
||||||
|
(o) =>
|
||||||
|
o.method === method &&
|
||||||
|
(o.pathTemplate === url ||
|
||||||
|
new RegExp(`^${o.pathTemplate.replace(/\{[^}]+\}/g, '[^/]+')}$`).test(url!)),
|
||||||
|
);
|
||||||
|
assert.ok(operation, bruno.file);
|
||||||
|
if (bodyMode === 'json' && bruno.bodyFields.length)
|
||||||
|
assert.equal(operation.requestEvidence.kind, 'json', bruno.file);
|
||||||
|
if (bodyMode === 'json' && !bruno.bodyFields.length)
|
||||||
|
assert.ok(
|
||||||
|
['none', 'path', 'query', 'json'].includes(operation.requestEvidence.kind),
|
||||||
|
`${bruno.file}: empty Bruno JSON has no contract fields`,
|
||||||
|
);
|
||||||
|
for (const field of bruno.bodyFields) assert.match(text, new RegExp(`\\b${field}\\b`));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('frozen fixture has the audited upstream route totals', () => {
|
test('frozen fixture has the audited upstream route totals', () => {
|
||||||
@@ -143,31 +315,54 @@ test('frozen fixture has the audited upstream route totals', () => {
|
|||||||
assert.equal(new Set(fixture.routes.map(key)).size, 117);
|
assert.equal(new Set(fixture.routes.map(key)).size, 117);
|
||||||
assert.equal(new Set(fixture.routes.map((route: { path: string }) => route.path)).size, 100);
|
assert.equal(new Set(fixture.routes.map((route: { path: string }) => route.path)).size, 100);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.fromEntries(['GET', 'POST', 'DELETE'].map(method => [method, fixture.routes.filter((r: { method: string }) => r.method === method).length])),
|
Object.fromEntries(
|
||||||
|
['GET', 'POST', 'DELETE'].map((method) => [
|
||||||
|
method,
|
||||||
|
fixture.routes.filter((r: { method: string }) => r.method === method).length,
|
||||||
|
]),
|
||||||
|
),
|
||||||
{ GET: 50, POST: 62, DELETE: 5 },
|
{ GET: 50, POST: 62, DELETE: 5 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registry is exactly route-parity complete with independent fixture', () => {
|
test('registry is exactly route-parity complete with independent fixture', () => {
|
||||||
assert.equal(upstream58e2204Operations.length, 117);
|
assert.equal(upstream58e2204Operations.length, 117);
|
||||||
const fixtureTriples = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort();
|
const fixtureTriples = fixture.routes
|
||||||
|
.map((r: any) => `${r.method} ${r.path} ${r.handler}`)
|
||||||
|
.sort();
|
||||||
assert.deepEqual(upstream58e2204Operations.map(operationKey).sort(), fixtureTriples);
|
assert.deepEqual(upstream58e2204Operations.map(operationKey).sort(), fixtureTriples);
|
||||||
assert.equal(new Set(upstream58e2204Operations.map(operation => operation.pathTemplate)).size, 100);
|
assert.equal(
|
||||||
assert.equal(new Set(upstream58e2204Operations.map(operation => operation.operationId)).size, 117);
|
new Set(upstream58e2204Operations.map((operation) => operation.pathTemplate)).size,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new Set(upstream58e2204Operations.map((operation) => operation.operationId)).size,
|
||||||
|
117,
|
||||||
|
);
|
||||||
for (const operation of upstream58e2204Operations) {
|
for (const operation of upstream58e2204Operations) {
|
||||||
const route = fixture.routes.find((r: any) => `${r.method} ${r.path} ${r.handler}` === operationKey(operation));
|
const route = fixture.routes.find(
|
||||||
|
(r: any) => `${r.method} ${r.path} ${r.handler}` === operationKey(operation),
|
||||||
|
);
|
||||||
assert.ok(route, operationKey(operation));
|
assert.ok(route, operationKey(operation));
|
||||||
assert.ok(operation.sourceEvidence.includes(route.source), `${operation.operationId}: exact main.rs evidence`);
|
assert.ok(
|
||||||
|
operation.sourceEvidence.includes(route.source),
|
||||||
|
`${operation.operationId}: exact main.rs evidence`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('frozen static-contract evidence is complete and bound to routes', () => {
|
test('frozen static-contract evidence is complete and bound to routes', () => {
|
||||||
assert.equal(evidenceFixture.upstreamCommit, fixture.upstreamCommit);
|
assert.equal(evidenceFixture.upstreamCommit, fixture.upstreamCommit);
|
||||||
assert.equal(evidenceFixture.operations.length, 117);
|
assert.equal(evidenceFixture.operations.length, 117);
|
||||||
assert.deepEqual(evidenceFixture.operations.map((e: any) => `${e.method} ${e.path} ${e.handler}`).sort(),
|
assert.deepEqual(
|
||||||
fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort());
|
evidenceFixture.operations.map((e: any) => `${e.method} ${e.path} ${e.handler}`).sort(),
|
||||||
|
fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort(),
|
||||||
|
);
|
||||||
for (const evidence of evidenceFixture.operations) {
|
for (const evidence of evidenceFixture.operations) {
|
||||||
assert.match(evidence.handlerEvidence, /^backend\/src\/(?:handlers|auth|notification_queue)\.rs:\d+-\d+$/);
|
assert.match(
|
||||||
|
evidence.handlerEvidence,
|
||||||
|
/^backend\/src\/(?:handlers|auth|notification_queue)\.rs:\d+-\d+$/,
|
||||||
|
);
|
||||||
assert.match(evidence.sha256, /^[a-f0-9]{64}$/);
|
assert.match(evidence.sha256, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(evidence.requestSummary && evidence.responseSummary);
|
assert.ok(evidence.requestSummary && evidence.responseSummary);
|
||||||
}
|
}
|
||||||
@@ -182,26 +377,63 @@ test('source evidence is independently reproducible from frozen whole-file snaps
|
|||||||
}
|
}
|
||||||
for (const evidence of evidenceFixture.operations) {
|
for (const evidence of evidenceFixture.operations) {
|
||||||
const range = evidence.handlerRange;
|
const range = evidence.handlerRange;
|
||||||
assert.ok(range && Number.isInteger(range.startLine) && Number.isInteger(range.endLine), evidence.handler);
|
assert.ok(
|
||||||
|
range && Number.isInteger(range.startLine) && Number.isInteger(range.endLine),
|
||||||
|
evidence.handler,
|
||||||
|
);
|
||||||
const source = await readFile(`${snapshotRoot}/${range.file}`, 'utf8');
|
const source = await readFile(`${snapshotRoot}/${range.file}`, 'utf8');
|
||||||
const slice = source.split('\n').slice(range.startLine - 1, range.endLine).join('\n');
|
const slice = source
|
||||||
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sha256, evidence.handler);
|
.split('\n')
|
||||||
|
.slice(range.startLine - 1, range.endLine)
|
||||||
|
.join('\n');
|
||||||
|
assert.equal(
|
||||||
|
createHash('sha256').update(slice).digest('hex'),
|
||||||
|
evidence.sha256,
|
||||||
|
evidence.handler,
|
||||||
|
);
|
||||||
assert.match(slice, new RegExp(`async\\s+fn\\s+${evidence.handler.split('::').at(-1)}\\b`));
|
assert.match(slice, new RegExp(`async\\s+fn\\s+${evidence.handler.split('::').at(-1)}\\b`));
|
||||||
assert.equal((slice.match(/{/g) ?? []).length, (slice.match(/}/g) ?? []).length, evidence.handler);
|
assert.equal(
|
||||||
assert.ok(evidence.extractorTokens.every((token: string) => slice.replace(/\\s+/g, '').includes(token.replace(/\\s+/g, ''))), evidence.handler);
|
(slice.match(/{/g) ?? []).length,
|
||||||
assert.ok(evidence.responseKind && evidence.responseTypeEvidence !== 'Rust return impl IntoResponse', evidence.handler);
|
(slice.match(/}/g) ?? []).length,
|
||||||
|
evidence.handler,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
evidence.extractorTokens.every((token: string) =>
|
||||||
|
slice.replace(/\\s+/g, '').includes(token.replace(/\\s+/g, '')),
|
||||||
|
),
|
||||||
|
evidence.handler,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
evidence.responseKind && evidence.responseTypeEvidence !== 'Rust return impl IntoResponse',
|
||||||
|
evidence.handler,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('high-value response, confirmation, sensitive-field and Bruno contracts are fixed', () => {
|
test('high-value response, confirmation, sensitive-field and Bruno contracts are fixed', () => {
|
||||||
const operations: any[] = upstream58e2204Operations as any;
|
const operations: any[] = upstream58e2204Operations as any;
|
||||||
const byPath = (method: string, path: string) => operations.find(o => o.method === method && o.pathTemplate === path)!;
|
const byPath = (method: string, path: string) =>
|
||||||
assert.match(byPath('GET', '/api/device').responseSchemaEvidence, /ApiResponse<DeviceInfoResponse>/);
|
operations.find((o) => o.method === method && o.pathTemplate === path)!;
|
||||||
assert.match(byPath('GET', '/api/network').responseSchemaEvidence, /ApiResponse<NetworkInfoResponse>/);
|
assert.match(
|
||||||
assert.match(byPath('POST', '/api/ota/upload').responseSchemaEvidence, /ApiResponse<OtaUploadResponse>/);
|
byPath('GET', '/api/device').responseSchemaEvidence,
|
||||||
|
/ApiResponse<DeviceInfoResponse>/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
byPath('GET', '/api/network').responseSchemaEvidence,
|
||||||
|
/ApiResponse<NetworkInfoResponse>/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
byPath('POST', '/api/ota/upload').responseSchemaEvidence,
|
||||||
|
/ApiResponse<OtaUploadResponse>/,
|
||||||
|
);
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (operation.riskLevel === 'R2') assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy), operation.operationId);
|
if (operation.riskLevel === 'R2')
|
||||||
if (operation.riskLevel === 'R3') assert.equal(operation.confirmationPolicy, 'strong', operation.operationId);
|
assert.ok(
|
||||||
|
['explicit', 'strong'].includes(operation.confirmationPolicy),
|
||||||
|
operation.operationId,
|
||||||
|
);
|
||||||
|
if (operation.riskLevel === 'R3')
|
||||||
|
assert.equal(operation.confirmationPolicy, 'strong', operation.operationId);
|
||||||
}
|
}
|
||||||
const expectedSensitive: Record<string, string[]> = {
|
const expectedSensitive: Record<string, string[]> = {
|
||||||
'POST /api/esim/profiles': ['$.body.matching_id', '$.body.confirmation_code', '$.body.imei'],
|
'POST /api/esim/profiles': ['$.body.matching_id', '$.body.confirmation_code', '$.body.imei'],
|
||||||
@@ -215,7 +447,11 @@ test('high-value response, confirmation, sensitive-field and Bruno contracts are
|
|||||||
};
|
};
|
||||||
for (const [route, fields] of Object.entries(expectedSensitive)) {
|
for (const [route, fields] of Object.entries(expectedSensitive)) {
|
||||||
const [method, path] = route.split(' ');
|
const [method, path] = route.split(' ');
|
||||||
for (const field of fields) assert.ok(byPath(method, path).sensitiveFields.some((claim: any) => claim.path === field), `${route}: ${field}`);
|
for (const field of fields)
|
||||||
|
assert.ok(
|
||||||
|
byPath(method, path).sensitiveFields.some((claim: any) => claim.path === field),
|
||||||
|
`${route}: ${field}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const evidence of evidenceFixture.operations) {
|
for (const evidence of evidenceFixture.operations) {
|
||||||
assert.match(evidence.evidenceLevel, /^route\+handler(?:\+model)?(?:\+bruno)?$/);
|
assert.match(evidence.evidenceLevel, /^route\+handler(?:\+model)?(?:\+bruno)?$/);
|
||||||
@@ -225,20 +461,41 @@ test('high-value response, confirmation, sensitive-field and Bruno contracts are
|
|||||||
|
|
||||||
test('every operation carries auditable orchestration metadata', () => {
|
test('every operation carries auditable orchestration metadata', () => {
|
||||||
for (const operation of upstream58e2204Operations) {
|
for (const operation of upstream58e2204Operations) {
|
||||||
for (const field of requiredFields) assert.ok(field in operation, `${operation.operationId}: missing ${field}`);
|
for (const field of requiredFields)
|
||||||
assert.ok(expectedDomains.has(operation.upstreamDomain), `${operation.operationId}: unknown domain`);
|
assert.ok(field in operation, `${operation.operationId}: missing ${field}`);
|
||||||
|
assert.ok(
|
||||||
|
expectedDomains.has(operation.upstreamDomain),
|
||||||
|
`${operation.operationId}: unknown domain`,
|
||||||
|
);
|
||||||
assert.ok(expectedOwners.has(operation.uiOwner), `${operation.operationId}: unknown owner`);
|
assert.ok(expectedOwners.has(operation.uiOwner), `${operation.operationId}: unknown owner`);
|
||||||
assert.match(operation.operationId, /^[a-z][A-Za-z0-9]*$/);
|
assert.match(operation.operationId, /^[a-z][A-Za-z0-9]*$/);
|
||||||
assert.ok(['R0', 'R1', 'R2', 'R3'].includes(operation.riskLevel));
|
assert.ok(['R0', 'R1', 'R2', 'R3'].includes(operation.riskLevel));
|
||||||
assert.ok(Number.isInteger(operation.timeoutMs) && operation.timeoutMs > 0);
|
assert.ok(Number.isInteger(operation.timeoutMs) && operation.timeoutMs > 0);
|
||||||
assert.ok(Array.isArray(operation.sensitiveFields));
|
assert.ok(Array.isArray(operation.sensitiveFields));
|
||||||
assert.ok(Array.isArray(operation.sourceEvidence) && operation.sourceEvidence.length > 0);
|
assert.ok(Array.isArray(operation.sourceEvidence) && operation.sourceEvidence.length > 0);
|
||||||
assert.ok(operation.sourceEvidence.some(evidence => /^backend\/src\/main\.rs:\d+-\d+$/.test(evidence)));
|
assert.ok(
|
||||||
assert.ok(operation.handler && operation.requestSchemaEvidence && operation.responseSchemaEvidence);
|
operation.sourceEvidence.some((evidence) =>
|
||||||
for (const field of ['requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'idempotency'] as const) {
|
/^backend\/src\/main\.rs:\d+-\d+$/.test(evidence),
|
||||||
assert.doesNotMatch(operation[field], /TODO_EVIDENCE|not frozen/i, `${operation.operationId}: ${field}`);
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
operation.handler && operation.requestSchemaEvidence && operation.responseSchemaEvidence,
|
||||||
|
);
|
||||||
|
for (const field of [
|
||||||
|
'requestSchemaEvidence',
|
||||||
|
'responseSchemaEvidence',
|
||||||
|
'requestContentType',
|
||||||
|
'idempotency',
|
||||||
|
] as const) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
operation[field],
|
||||||
|
/TODO_EVIDENCE|not frozen/i,
|
||||||
|
`${operation.operationId}: ${field}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.ok(['safe', 'idempotent', 'non-idempotent', 'conditional'].includes(operation.idempotency));
|
assert.ok(
|
||||||
|
['safe', 'idempotent', 'non-idempotent', 'conditional'].includes(operation.idempotency),
|
||||||
|
);
|
||||||
assert.equal(operation.auditedAtCommit, fixture.upstreamCommit);
|
assert.equal(operation.auditedAtCommit, fixture.upstreamCommit);
|
||||||
assert.equal(operation.compatibilityAdapter, 'none-pinned-commit');
|
assert.equal(operation.compatibilityAdapter, 'none-pinned-commit');
|
||||||
if (operation.riskLevel === 'R2') assert.equal(operation.syncSafe, false);
|
if (operation.riskLevel === 'R2') assert.equal(operation.syncSafe, false);
|
||||||
@@ -247,34 +504,49 @@ test('every operation carries auditable orchestration metadata', () => {
|
|||||||
assert.equal(operation.syncSafe, false);
|
assert.equal(operation.syncSafe, false);
|
||||||
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
|
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
|
||||||
}
|
}
|
||||||
if (operation.batchable && operation.riskLevel === 'R2') assert.equal(operation.capability, 'job');
|
if (operation.batchable && operation.riskLevel === 'R2')
|
||||||
|
assert.equal(operation.capability, 'job');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dangerous authentication endpoints use dedicated flows, never generic proxy', () => {
|
test('dangerous authentication endpoints use dedicated flows, never generic proxy', () => {
|
||||||
const dangerous = upstream58e2204Operations.filter(operation =>
|
const dangerous = upstream58e2204Operations.filter((operation) =>
|
||||||
['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate));
|
['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate),
|
||||||
|
);
|
||||||
assert.equal(dangerous.length, 2);
|
assert.equal(dangerous.length, 2);
|
||||||
for (const operation of dangerous) {
|
for (const operation of dangerous) {
|
||||||
assert.equal(operation.riskLevel, 'R3');
|
assert.equal(operation.riskLevel, 'R3');
|
||||||
assert.equal(operation.executionPolicy, 'dedicatedFlow');
|
assert.equal(operation.executionPolicy, 'dedicatedFlow');
|
||||||
}
|
}
|
||||||
const loginLogout = upstream58e2204Operations.filter(operation =>
|
const loginLogout = upstream58e2204Operations.filter((operation) =>
|
||||||
['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate));
|
['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate),
|
||||||
|
);
|
||||||
assert.equal(loginLogout.length, 2);
|
assert.equal(loginLogout.length, 2);
|
||||||
assert.ok(loginLogout.every(operation => operation.riskLevel === 'R1' && operation.sessionSensitive));
|
assert.ok(
|
||||||
const authSettingsPost = upstream58e2204Operations.find(operation => key(operation) === 'POST /api/auth/settings');
|
loginLogout.every((operation) => operation.riskLevel === 'R1' && operation.sessionSensitive),
|
||||||
|
);
|
||||||
|
const authSettingsPost = upstream58e2204Operations.find(
|
||||||
|
(operation) => key(operation) === 'POST /api/auth/settings',
|
||||||
|
);
|
||||||
assert.ok(authSettingsPost && ['R2', 'R3'].includes(authSettingsPost.riskLevel));
|
assert.ok(authSettingsPost && ['R2', 'R3'].includes(authSettingsPost.riskLevel));
|
||||||
assert.equal(authSettingsPost?.syncSafe, false);
|
assert.equal(authSettingsPost?.syncSafe, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('product risk floors and auth replay policy are enforced', () => {
|
test('product risk floors and auth replay policy are enforced', () => {
|
||||||
const byPath = (method: string, path: string) => upstream58e2204Operations.find(o => o.method === method && o.pathTemplate === path)!;
|
const byPath = (method: string, path: string) =>
|
||||||
|
upstream58e2204Operations.find((o) => o.method === method && o.pathTemplate === path)!;
|
||||||
const r3 = [
|
const r3 = [
|
||||||
['DELETE','/api/esim/profiles/{iccid}'], ['DELETE','/api/call/history/{id}'], ['POST','/api/call/history/clear'],
|
['DELETE', '/api/esim/profiles/{iccid}'],
|
||||||
['POST','/api/sms/batch-delete'], ['DELETE','/api/sms/conversation/{phone_number}'], ['DELETE','/api/sms/message/{id}'], ['POST','/api/sms/clear'],
|
['DELETE', '/api/call/history/{id}'],
|
||||||
['POST','/api/notifications/logs/clear'], ['POST','/api/notifications/queue/clear'], ['DELETE','/api/notifications/queue/{id}'],
|
['POST', '/api/call/history/clear'],
|
||||||
['POST','/api/automation/logs/clear'],
|
['POST', '/api/sms/batch-delete'],
|
||||||
|
['DELETE', '/api/sms/conversation/{phone_number}'],
|
||||||
|
['DELETE', '/api/sms/message/{id}'],
|
||||||
|
['POST', '/api/sms/clear'],
|
||||||
|
['POST', '/api/notifications/logs/clear'],
|
||||||
|
['POST', '/api/notifications/queue/clear'],
|
||||||
|
['DELETE', '/api/notifications/queue/{id}'],
|
||||||
|
['POST', '/api/automation/logs/clear'],
|
||||||
];
|
];
|
||||||
for (const [method, path] of r3) {
|
for (const [method, path] of r3) {
|
||||||
const operation = byPath(method, path);
|
const operation = byPath(method, path);
|
||||||
@@ -283,9 +555,11 @@ test('product risk floors and auth replay policy are enforced', () => {
|
|||||||
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
|
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
|
||||||
}
|
}
|
||||||
const r2 = [
|
const r2 = [
|
||||||
['POST','/api/sms/send'], ['POST','/api/notifications/test/{channel}'],
|
['POST', '/api/sms/send'],
|
||||||
['POST','/api/notifications/queue/retry-all'], ['POST','/api/notifications/queue/{id}/retry'],
|
['POST', '/api/notifications/test/{channel}'],
|
||||||
['POST','/api/automation/test/{task_id}'],
|
['POST', '/api/notifications/queue/retry-all'],
|
||||||
|
['POST', '/api/notifications/queue/{id}/retry'],
|
||||||
|
['POST', '/api/automation/test/{task_id}'],
|
||||||
];
|
];
|
||||||
for (const [method, path] of r2) {
|
for (const [method, path] of r2) {
|
||||||
const operation = byPath(method, path);
|
const operation = byPath(method, path);
|
||||||
@@ -303,17 +577,34 @@ test('product risk floors and auth replay policy are enforced', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('evidence matrix lists every registry operation', async () => {
|
test('evidence matrix lists every registry operation', async () => {
|
||||||
const documentPath = fileURLToPath(new URL('../../../docs/api/simadmin-upstream-58e2204.md', import.meta.url));
|
const documentPath = fileURLToPath(
|
||||||
|
new URL('../../../docs/api/simadmin-upstream-58e2204.md', import.meta.url),
|
||||||
|
);
|
||||||
const document = await readFile(documentPath, 'utf8');
|
const document = await readFile(documentPath, 'utf8');
|
||||||
const rows = document.split('\n').filter(line => /^\| `(?:GET|POST|DELETE)` \|/.test(line));
|
const rows = document.split('\n').filter((line) => /^\| `(?:GET|POST|DELETE)` \|/.test(line));
|
||||||
assert.equal(rows.length, 117);
|
assert.equal(rows.length, 117);
|
||||||
for (const operation of upstream58e2204Operations) {
|
for (const operation of upstream58e2204Operations) {
|
||||||
const row = rows.find(row => row.includes(`\`${operation.method}\``) && row.includes(`\`${operation.pathTemplate}\``) && row.includes(`\`${operation.operationId}\``));
|
const row = rows.find(
|
||||||
|
(row) =>
|
||||||
|
row.includes(`\`${operation.method}\``) &&
|
||||||
|
row.includes(`\`${operation.pathTemplate}\``) &&
|
||||||
|
row.includes(`\`${operation.operationId}\``),
|
||||||
|
);
|
||||||
assert.ok(row, key(operation));
|
assert.ok(row, key(operation));
|
||||||
const evidence = evidenceFixture.operations.find((e: any) => `${e.method} ${e.path} ${e.handler}` === operationKey(operation));
|
const evidence = evidenceFixture.operations.find(
|
||||||
|
(e: any) => `${e.method} ${e.path} ${e.handler}` === operationKey(operation),
|
||||||
|
);
|
||||||
assert.ok(evidence, operation.operationId);
|
assert.ok(evidence, operation.operationId);
|
||||||
for (const value of [operation.upstreamDomain, operation.uiOwner, operation.handler, operation.requestContentType,
|
for (const value of [
|
||||||
operation.idempotency, operation.riskLevel, operation.capability, evidence.sha256.slice(0, 16)]) {
|
operation.upstreamDomain,
|
||||||
|
operation.uiOwner,
|
||||||
|
operation.handler,
|
||||||
|
operation.requestContentType,
|
||||||
|
operation.idempotency,
|
||||||
|
operation.riskLevel,
|
||||||
|
operation.capability,
|
||||||
|
evidence.sha256.slice(0, 16),
|
||||||
|
]) {
|
||||||
assert.ok(row.includes(value), `${operation.operationId}: document drift for ${value}`);
|
assert.ok(row.includes(value), `${operation.operationId}: document drift for ${value}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,8 +612,12 @@ test('evidence matrix lists every registry operation', async () => {
|
|||||||
assert.match(document, /Phase 0\.3[^\n]*真实响应样本/);
|
assert.match(document, /Phase 0\.3[^\n]*真实响应样本/);
|
||||||
assert.match(document, /dynamic-json/);
|
assert.match(document, /dynamic-json/);
|
||||||
assert.match(document, /58e220411d6599609f0eeda01eb7016e9212f970/);
|
assert.match(document, /58e220411d6599609f0eeda01eb7016e9212f970/);
|
||||||
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
|
for (const operation of upstream58e2204Operations as any[])
|
||||||
const exactRow = `| ${operation.method} | \`${operation.pathTemplate}\` | ${field.direction} | \`${field.path}\` | ${field.redactionMode} | ${field.reason} |`;
|
for (const field of operation.sensitiveFields) {
|
||||||
assert.ok(document.includes(exactRow), `${operation.operationId}: sensitive ledger drift ${field.path}`);
|
const exactRow = `| ${operation.method} | \`${operation.pathTemplate}\` | ${field.direction} | \`${field.path}\` | ${field.redactionMode} | ${field.reason} |`;
|
||||||
}
|
assert.ok(
|
||||||
|
document.includes(exactRow),
|
||||||
|
`${operation.operationId}: sensitive ledger drift ${field.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,35 +6,123 @@ import { upstream58e2204Operations } from '../../operation-registry/src/upstream
|
|||||||
import { normalizeConfig } from '../../../server/config/schema.js';
|
import { normalizeConfig } from '../../../server/config/schema.js';
|
||||||
import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
|
import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
|
||||||
|
|
||||||
const here=path.dirname(fileURLToPath(import.meta.url));const pkg=path.resolve(here,'..');const repo=path.resolve(pkg,'../..');
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export function loadInstances(config:any){
|
const pkg = path.resolve(here, '..');
|
||||||
if(!config||!Array.isArray(config.instances)||config.instances.length!==2)throw new Error('config must contain exactly two instances');
|
const repo = path.resolve(pkg, '../..');
|
||||||
for(const raw of config.instances){
|
export function loadInstances(config: any) {
|
||||||
if(!raw||typeof raw!=='object'||typeof raw.url!=='string')throw new Error('invalid collector instance configuration');
|
if (!config || !Array.isArray(config.instances) || config.instances.length !== 2)
|
||||||
validateInstanceOrigin(raw.url);
|
throw new Error('config must contain exactly two instances');
|
||||||
if(raw.password||raw.auth?.password||!['none',undefined].includes(raw.auth?.mode))throw new Error('collector instances must be password-free');
|
for (const raw of config.instances) {
|
||||||
}
|
if (!raw || typeof raw !== 'object' || typeof raw.url !== 'string')
|
||||||
const normalized=normalizeConfig(config,{HOST:'127.0.0.1',PORT:'8788'});
|
throw new Error('invalid collector instance configuration');
|
||||||
if(normalized.instances.length!==2)throw new Error('config must contain exactly two instances');
|
validateInstanceOrigin(raw.url);
|
||||||
return normalized.instances.map((raw:any,i:number)=>{
|
if (raw.password || raw.auth?.password || !['none', undefined].includes(raw.auth?.mode))
|
||||||
if(raw.auth.mode!=='none'||raw.auth.password)throw new Error(`instance-${i+1} must be password-free`);
|
throw new Error('collector instances must be password-free');
|
||||||
return {origin:validateInstanceOrigin(raw.url),alias:`instance-${i+1}`};
|
}
|
||||||
});
|
const normalized = normalizeConfig(config, { HOST: '127.0.0.1', PORT: '8788' });
|
||||||
|
if (normalized.instances.length !== 2)
|
||||||
|
throw new Error('config must contain exactly two instances');
|
||||||
|
return normalized.instances.map((raw: any, i: number) => {
|
||||||
|
if (raw.auth.mode !== 'none' || raw.auth.password)
|
||||||
|
throw new Error(`instance-${i + 1} must be password-free`);
|
||||||
|
return { origin: validateInstanceOrigin(raw.url), alias: `instance-${i + 1}` };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function mapLimit<T,R>(xs:T[],limit:number,fn:(x:T)=>Promise<R>){const out:R[]=[];let n=0;async function worker(){while(n<xs.length){const i=n++;out[i]=await fn(xs[i]);}}await Promise.all(Array.from({length:Math.min(limit,xs.length)},worker));return out;}
|
async function mapLimit<T, R>(xs: T[], limit: number, fn: (x: T) => Promise<R>) {
|
||||||
async function allJson(dir:string):Promise<string[]>{const out:string[]=[];for(const e of await readdir(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())out.push(...await allJson(p));else if(e.name.endsWith('.json'))out.push(p);}return out;}
|
const out: R[] = [];
|
||||||
function safeName(s:string){return s.replace(/[^a-zA-Z0-9_-]/g,'-');}
|
let n = 0;
|
||||||
export async function main(argv=process.argv.slice(2)){
|
async function worker() {
|
||||||
if(argv.length!==1||!['--dry-run','--capture'].includes(argv[0]))throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
|
while (n < xs.length) {
|
||||||
const config=JSON.parse(await readFile(path.join(repo,'config.json'),'utf8'));const instances=loadInstances(config);const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
const i = n++;
|
||||||
if(argv[0]==='--dry-run'){for(const op of selected)console.log(`registry ${op.operationId}`);console.log(`selected=${selected.length} denied=${denied.length}`);return;}
|
out[i] = await fn(xs[i]);
|
||||||
const shapePath=path.join(pkg,'src/response-shapes-58e2204.json');
|
}
|
||||||
// Separately reviewed: capture attests this baseline but never creates it.
|
}
|
||||||
const shapeBytes=await readFile(shapePath);
|
await Promise.all(Array.from({ length: Math.min(limit, xs.length) }, worker));
|
||||||
const root=path.join(pkg,'src/simadmin');await rm(root,{recursive:true,force:true});await rm(path.join(pkg,'src/manifest.json'),{force:true});await mkdir(root,{recursive:true});
|
return out;
|
||||||
const jobs=instances.flatMap(instance=>selected.map(op=>({instance,op})));const fixtures=await mapLimit(jobs,2,async({instance,op})=>{const f=await collectOne(instance,op);console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);return {f,domain:op.upstreamDomain};});
|
|
||||||
for(const {f,domain} of fixtures){const dir=path.join(root,safeName(domain));await mkdir(dir,{recursive:true});await writeFile(path.join(dir,`${f.sourceInstanceAlias}--${f.operationId}.json`),JSON.stringify(f,null,2)+'\n',{flag:'wx'});}
|
|
||||||
const files=[];const coverage:Record<string,number>={};for(const full of await allJson(root)){const text=await readFile(full);const rel=path.relative(pkg,full);const domain=path.basename(path.dirname(full));coverage[domain]=(coverage[domain]||0)+1;files.push({path:rel,size:(await stat(full)).size,sha256:createHash('sha256').update(text).digest('hex')});}
|
|
||||||
files.sort((a,b)=>a.path.localeCompare(b.path));const shapeBaseline={path:'src/response-shapes-58e2204.json',size:shapeBytes.length,sha256:createHash('sha256').update(shapeBytes).digest('hex')};await writeFile(path.join(pkg,'src/manifest.json'),JSON.stringify({schemaVersion:1,upstreamBaseline:'58e2204',realFixtureCount:files.length,syntheticFixtureCount:0,domainCoverage:Object.fromEntries(Object.entries(coverage).sort()),shapeBaseline,files},null,2)+'\n');
|
|
||||||
}
|
}
|
||||||
if(process.argv[1]===fileURLToPath(import.meta.url))main().catch(()=>{console.error('collector failed');process.exitCode=1;});
|
async function allJson(dir: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...(await allJson(p)));
|
||||||
|
else if (e.name.endsWith('.json')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function safeName(s: string) {
|
||||||
|
return s.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
}
|
||||||
|
export async function main(argv = process.argv.slice(2)) {
|
||||||
|
if (argv.length !== 1 || !['--dry-run', '--capture'].includes(argv[0]))
|
||||||
|
throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
|
||||||
|
const config = JSON.parse(await readFile(path.join(repo, 'config.json'), 'utf8'));
|
||||||
|
const instances = loadInstances(config);
|
||||||
|
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||||
|
if (argv[0] === '--dry-run') {
|
||||||
|
for (const op of selected) console.log(`registry ${op.operationId}`);
|
||||||
|
console.log(`selected=${selected.length} denied=${denied.length}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shapePath = path.join(pkg, 'src/response-shapes-58e2204.json');
|
||||||
|
// Separately reviewed: capture attests this baseline but never creates it.
|
||||||
|
const shapeBytes = await readFile(shapePath);
|
||||||
|
const root = path.join(pkg, 'src/simadmin');
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
await rm(path.join(pkg, 'src/manifest.json'), { force: true });
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const jobs = instances.flatMap((instance) => selected.map((op) => ({ instance, op })));
|
||||||
|
const fixtures = await mapLimit(jobs, 2, async ({ instance, op }) => {
|
||||||
|
const f = await collectOne(instance, op);
|
||||||
|
console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);
|
||||||
|
return { f, domain: op.upstreamDomain };
|
||||||
|
});
|
||||||
|
for (const { f, domain } of fixtures) {
|
||||||
|
const dir = path.join(root, safeName(domain));
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(dir, `${f.sourceInstanceAlias}--${f.operationId}.json`),
|
||||||
|
JSON.stringify(f, null, 2) + '\n',
|
||||||
|
{ flag: 'wx' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const files = [];
|
||||||
|
const coverage: Record<string, number> = {};
|
||||||
|
for (const full of await allJson(root)) {
|
||||||
|
const text = await readFile(full);
|
||||||
|
const rel = path.relative(pkg, full);
|
||||||
|
const domain = path.basename(path.dirname(full));
|
||||||
|
coverage[domain] = (coverage[domain] || 0) + 1;
|
||||||
|
files.push({
|
||||||
|
path: rel,
|
||||||
|
size: (await stat(full)).size,
|
||||||
|
sha256: createHash('sha256').update(text).digest('hex'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
const shapeBaseline = {
|
||||||
|
path: 'src/response-shapes-58e2204.json',
|
||||||
|
size: shapeBytes.length,
|
||||||
|
sha256: createHash('sha256').update(shapeBytes).digest('hex'),
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(pkg, 'src/manifest.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
upstreamBaseline: '58e2204',
|
||||||
|
realFixtureCount: files.length,
|
||||||
|
syntheticFixtureCount: 0,
|
||||||
|
domainCoverage: Object.fromEntries(Object.entries(coverage).sort()),
|
||||||
|
shapeBaseline,
|
||||||
|
files,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url))
|
||||||
|
main().catch(() => {
|
||||||
|
console.error('collector failed');
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,64 +1,190 @@
|
|||||||
import { redactOperationPayload } from '../src/redactor.ts';
|
import { redactOperationPayload } from '../src/redactor.ts';
|
||||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||||
|
|
||||||
export const UPSTREAM_BASELINE='58e2204';
|
export const UPSTREAM_BASELINE = '58e2204';
|
||||||
export const DENY_REASONS:Record<string,string>={
|
export const DENY_REASONS: Record<string, string> = {
|
||||||
'/api/network/operators/scan':'active radio/network scan',
|
'/api/network/operators/scan': 'active radio/network scan',
|
||||||
'/api/connectivity':'handler performs active connectivity ping',
|
'/api/connectivity': 'handler performs active connectivity ping',
|
||||||
'/api/sms/list':'message list can expose body; intentionally not collected',
|
'/api/sms/list': 'message list can expose body; intentionally not collected',
|
||||||
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
|
'/api/sms/conversation': 'requires correspondent query and exposes message bodies',
|
||||||
'/api/call/history':'query limit is not encoded in Registry path contract',
|
'/api/call/history': 'query limit is not encoded in Registry path contract',
|
||||||
'/api/notifications/logs':'query limit is not encoded in Registry path contract',
|
'/api/notifications/logs': 'query limit is not encoded in Registry path contract',
|
||||||
'/api/notifications/queue':'query limit is not encoded in Registry path contract',
|
'/api/notifications/queue': 'query limit is not encoded in Registry path contract',
|
||||||
'/api/automation/logs':'query limit is not encoded in Registry path contract',
|
'/api/automation/logs': 'query limit is not encoded in Registry path contract',
|
||||||
'/api/device-network/ddns/logs':'no safe limit contract',
|
'/api/device-network/ddns/logs': 'no safe limit contract',
|
||||||
'/api/esim/euicc':'query-bearing endpoint omitted',
|
'/api/esim/euicc': 'query-bearing endpoint omitted',
|
||||||
'/api/esim/profiles':'query-bearing endpoint omitted',
|
'/api/esim/profiles': 'query-bearing endpoint omitted',
|
||||||
};
|
};
|
||||||
const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op]));
|
const registryById = new Map(upstream58e2204Operations.map((op: any) => [op.operationId, op]));
|
||||||
// Capture the complete imported JSON contract before any caller can mutate it.
|
// Capture the complete imported JSON contract before any caller can mutate it.
|
||||||
// Exact identity alone does not protect mutable Registry objects.
|
// Exact identity alone does not protect mutable Registry objects.
|
||||||
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
|
const registrySignatures = new Map(
|
||||||
function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));}
|
upstream58e2204Operations.map((op: any) => [op.operationId, JSON.stringify(op)]),
|
||||||
export function validateInstanceOrigin(origin:string){
|
);
|
||||||
let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');}
|
function isPrivateLan(host: string) {
|
||||||
if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin');
|
const p = host.split('.').map(Number);
|
||||||
return u.origin;
|
return (
|
||||||
|
p.length === 4 &&
|
||||||
|
p.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) &&
|
||||||
|
(p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function validatePath(path:string){
|
export function validateInstanceOrigin(origin: string) {
|
||||||
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path');
|
let u: URL;
|
||||||
let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;}
|
try {
|
||||||
if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path');
|
u = new URL(origin);
|
||||||
return path;
|
} catch {
|
||||||
|
throw new Error('invalid collector instance origin');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['http:', 'https:'].includes(u.protocol) ||
|
||||||
|
!isPrivateLan(u.hostname) ||
|
||||||
|
u.pathname !== '/' ||
|
||||||
|
u.search ||
|
||||||
|
u.hash ||
|
||||||
|
u.username ||
|
||||||
|
u.password
|
||||||
|
)
|
||||||
|
throw new Error('invalid collector instance origin');
|
||||||
|
return u.origin;
|
||||||
}
|
}
|
||||||
export function validateReadonlyOperation(op:any){
|
function validatePath(path: string) {
|
||||||
const exact=registryById.get(op?.operationId);
|
if (
|
||||||
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
|
typeof path !== 'string' ||
|
||||||
validatePath(op.pathTemplate);
|
!path.startsWith('/api/') ||
|
||||||
if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0');
|
path.startsWith('//') ||
|
||||||
if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied');
|
path.includes('\\') ||
|
||||||
return op;
|
/[?#%]/.test(path) ||
|
||||||
|
path.includes('//')
|
||||||
|
)
|
||||||
|
throw new Error('unsafe operation path');
|
||||||
|
let decoded = path;
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const next = decodeURIComponent(decoded);
|
||||||
|
if (next !== decoded) throw new Error('encoded operation path');
|
||||||
|
decoded = next;
|
||||||
|
}
|
||||||
|
if (path.split('/').some((x) => x === '.' || x === '..'))
|
||||||
|
throw new Error('dot segment operation path');
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
export function selectReadonlyOperations(registry:any[]){
|
export function validateReadonlyOperation(op: any) {
|
||||||
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
|
const exact = registryById.get(op?.operationId);
|
||||||
for(const op of candidates) validatePath(op.pathTemplate);
|
if (!exact || exact !== op || registrySignatures.get(op?.operationId) !== JSON.stringify(op))
|
||||||
return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))};
|
throw new Error('operation violates Registry integrity');
|
||||||
|
validatePath(op.pathTemplate);
|
||||||
|
if (op.method !== 'GET' || op.riskLevel !== 'R0' || /[{}]/.test(op.pathTemplate))
|
||||||
|
throw new Error('operation is not readonly R0');
|
||||||
|
if (DENY_REASONS[op.pathTemplate]) throw new Error('operation is explicitly denied');
|
||||||
|
return op;
|
||||||
}
|
}
|
||||||
function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';}
|
export function selectReadonlyOperations(registry: any[]) {
|
||||||
function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';}
|
const candidates = registry.filter(
|
||||||
export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){
|
(o) => o.method === 'GET' && o.riskLevel === 'R0' && !/[{}]/.test(o.pathTemplate),
|
||||||
validateReadonlyOperation(op);
|
);
|
||||||
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
|
for (const op of candidates) validatePath(op.pathTemplate);
|
||||||
const origin=validateInstanceOrigin(instance?.origin);
|
return {
|
||||||
const requestUrl=new URL(op.pathTemplate,origin);
|
selected: candidates
|
||||||
if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL');
|
.filter((o) => !DENY_REASONS[o.pathTemplate])
|
||||||
const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000);
|
.map(validateReadonlyOperation),
|
||||||
const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true};
|
denied: candidates
|
||||||
try{
|
.filter((o) => DENY_REASONS[o.pathTemplate])
|
||||||
|
.map((o) => ({ ...o, denyReason: DENY_REASONS[o.pathTemplate] })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function latency(ms: number, timedOut = false) {
|
||||||
|
if (timedOut) return 'timeout';
|
||||||
|
if (ms < 250) return '<250ms';
|
||||||
|
if (ms < 1000) return '250ms-1s';
|
||||||
|
if (ms < 3000) return '1-3s';
|
||||||
|
return '>3s';
|
||||||
|
}
|
||||||
|
function statusCategory(status: number, isJson: boolean) {
|
||||||
|
if (status === 401 || status === 403) return 'auth-required';
|
||||||
|
if (status === 404 || status === 405 || status === 501) return 'unsupported';
|
||||||
|
if (status >= 200 && status < 300) return isJson ? 'success' : 'non-json';
|
||||||
|
return 'http-error';
|
||||||
|
}
|
||||||
|
export async function collectOne(
|
||||||
|
instance: { origin: string; alias: string },
|
||||||
|
op: any,
|
||||||
|
transport: any = fetch,
|
||||||
|
) {
|
||||||
validateReadonlyOperation(op);
|
validateReadonlyOperation(op);
|
||||||
const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal});
|
if (!['instance-1', 'instance-2'].includes(instance?.alias))
|
||||||
const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json');
|
throw new Error('invalid instance alias');
|
||||||
let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};}
|
const origin = validateInstanceOrigin(instance?.origin);
|
||||||
return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)};
|
const requestUrl = new URL(op.pathTemplate, origin);
|
||||||
}catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);}
|
if (
|
||||||
|
requestUrl.origin !== origin ||
|
||||||
|
requestUrl.pathname !== op.pathTemplate ||
|
||||||
|
requestUrl.search ||
|
||||||
|
requestUrl.hash
|
||||||
|
)
|
||||||
|
throw new Error('unsafe collector request URL');
|
||||||
|
const started = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 6000);
|
||||||
|
const base = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
upstreamBaseline: UPSTREAM_BASELINE,
|
||||||
|
capturedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
sourceInstanceAlias: instance.alias,
|
||||||
|
operationId: op.operationId,
|
||||||
|
method: 'GET',
|
||||||
|
pathTemplate: op.pathTemplate,
|
||||||
|
redacted: true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
validateReadonlyOperation(op);
|
||||||
|
const response = await transport(requestUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
body: undefined,
|
||||||
|
credentials: 'omit',
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const contentType =
|
||||||
|
(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() || null;
|
||||||
|
const claimsJson = contentType === 'application/json' || contentType?.endsWith('+json');
|
||||||
|
let parsedJson = false;
|
||||||
|
let payload: any;
|
||||||
|
if (claimsJson) {
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
parsedJson = true;
|
||||||
|
} catch {
|
||||||
|
payload = { error: 'invalid-json' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await response.text();
|
||||||
|
payload = { text: '[REDACTED]' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
statusCategory: statusCategory(response.status, parsedJson),
|
||||||
|
httpStatus: response.status,
|
||||||
|
contentType,
|
||||||
|
latencyBucket: latency(Date.now() - started),
|
||||||
|
payload: redactOperationPayload(payload, op),
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
String(error?.message || '').includes('Registry') ||
|
||||||
|
String(error?.message || '').includes('operation')
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
const timeout = error?.name === 'AbortError';
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
statusCategory: timeout ? 'timeout' : 'network-error',
|
||||||
|
httpStatus: null,
|
||||||
|
contentType: null,
|
||||||
|
latencyBucket: latency(Date.now() - started, timeout),
|
||||||
|
payload: { error: timeout ? 'timeout' : 'network-error' },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,113 @@
|
|||||||
const SECRET_KEY = /(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
const SECRET_KEY =
|
||||||
|
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
||||||
const STRUCTURED_KEY = /^(?:config)$/i;
|
const STRUCTURED_KEY = /^(?:config)$/i;
|
||||||
const LOCATION_KEY = /^(?:lat(?:itude)?|lon(?:gitude)?|cell_?id|cid|lac|tac|pci|e?nb|gnb)$/i;
|
const LOCATION_KEY = /^(?:lat(?:itude)?|lon(?:gitude)?|cell_?id|cid|lac|tac|pci|e?nb|gnb)$/i;
|
||||||
const FINGERPRINT_KEY = /^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
const FINGERPRINT_KEY =
|
||||||
|
/^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
||||||
const SAFE_BOOLEAN_KEY = /(?:phone_number|sms_center)_is_manual$/i;
|
const SAFE_BOOLEAN_KEY = /(?:phone_number|sms_center)_is_manual$/i;
|
||||||
const PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i;
|
const PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i;
|
||||||
const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i;
|
const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i;
|
||||||
const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i;
|
const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i;
|
||||||
|
|
||||||
export function typedRedact(value:any):any {
|
export function typedRedact(value: any): any {
|
||||||
if(Array.isArray(value)) return value.map(typedRedact);
|
if (Array.isArray(value)) return value.map(typedRedact);
|
||||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,typedRedact(v)]));
|
if (value && typeof value === 'object')
|
||||||
if(typeof value==='string') return '[REDACTED]';
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, typedRedact(v)]));
|
||||||
if(typeof value==='number') return 0;
|
if (typeof value === 'string') return '[REDACTED]';
|
||||||
if(typeof value==='boolean') return false;
|
if (typeof value === 'number') return 0;
|
||||||
return value;
|
if (typeof value === 'boolean') return false;
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksHighEntropy(s:string){
|
function looksHighEntropy(s: string) {
|
||||||
if(s.length<16 || /\s/.test(s)) return false;
|
if (s.length < 16 || /\s/.test(s)) return false;
|
||||||
const classes=[/[a-z]/,/[A-Z]/,/\d/,/[^A-Za-z0-9]/].filter(r=>r.test(s)).length;
|
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(s)).length;
|
||||||
return classes>=3 || (classes>=2 && new Set(s).size>=12);
|
return classes >= 3 || (classes >= 2 && new Set(s).size >= 12);
|
||||||
}
|
}
|
||||||
function unsafeString(s:string){
|
function unsafeString(s: string) {
|
||||||
return /https?:\/\//i.test(s) || /(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
return (
|
||||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) || /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
/https?:\/\//i.test(s) ||
|
||||||
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) || /\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
/(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||||
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) || /(?:\d[ -]){6,}\d/.test(s) ||
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) ||
|
||||||
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
/(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
||||||
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) || /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) || looksHighEntropy(s);
|
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) ||
|
||||||
|
/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
||||||
|
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) ||
|
||||||
|
/(?:\d[ -]){6,}\d/.test(s) ||
|
||||||
|
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
||||||
|
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) ||
|
||||||
|
/\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) ||
|
||||||
|
looksHighEntropy(s)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Context {
|
class Context {
|
||||||
value(value:any,key='',force=false):any {
|
value(value: any, key = '', force = false): any {
|
||||||
if(SAFE_BOOLEAN_KEY.test(key) && typeof value==='boolean') return value;
|
if (SAFE_BOOLEAN_KEY.test(key) && typeof value === 'boolean') return value;
|
||||||
if(SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key)) return typedRedact(value);
|
if (SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key))
|
||||||
if(STRUCTURED_KEY.test(key)) force=true;
|
return typedRedact(value);
|
||||||
if(PHONE_KEY.test(key)||IDENTIFIER_KEY.test(key)||ADDRESS_KEY.test(key)) return typedRedact(value);
|
if (STRUCTURED_KEY.test(key)) force = true;
|
||||||
if(Array.isArray(value)) return value.map(v=>this.value(v,'',force));
|
if (PHONE_KEY.test(key) || IDENTIFIER_KEY.test(key) || ADDRESS_KEY.test(key))
|
||||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,this.value(v,k,force)]));
|
return typedRedact(value);
|
||||||
if(force) return typedRedact(value);
|
if (Array.isArray(value)) return value.map((v) => this.value(v, '', force));
|
||||||
if(typeof value==='string'){
|
if (value && typeof value === 'object')
|
||||||
const t=value.trim();
|
return Object.fromEntries(
|
||||||
if((t.startsWith('{')&&t.endsWith('}'))||(t.startsWith('[')&&t.endsWith(']'))){try{return JSON.stringify(this.value(JSON.parse(value)));}catch{}}
|
Object.entries(value).map(([k, v]) => [k, this.value(v, k, force)]),
|
||||||
return unsafeString(value)?'[REDACTED]':value;
|
);
|
||||||
|
if (force) return typedRedact(value);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const t = value.trim();
|
||||||
|
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(this.value(JSON.parse(value)));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return unsafeString(value) ? '[REDACTED]' : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && Math.abs(value) >= 100_000_000)
|
||||||
|
return 0;
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
if(typeof value==='number' && Number.isInteger(value) && Math.abs(value)>=100_000_000) return 0;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseResponseSensitivePath(path:string):string[] {
|
export function parseResponseSensitivePath(path: string): string[] {
|
||||||
if(typeof path!=='string'||!path.startsWith('$.response')) throw new Error('unsupported response sensitive path');
|
if (typeof path !== 'string' || !path.startsWith('$.response'))
|
||||||
const rest=path.slice('$.response'.length);
|
throw new Error('unsupported response sensitive path');
|
||||||
if(!rest) return [];
|
const rest = path.slice('$.response'.length);
|
||||||
if(!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest)) throw new Error('unsupported response sensitive path');
|
if (!rest) return [];
|
||||||
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map(m=>m[1]||'*');
|
if (!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest))
|
||||||
|
throw new Error('unsupported response sensitive path');
|
||||||
|
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map((m) => m[1] || '*');
|
||||||
}
|
}
|
||||||
export function applySensitivePath(node:any,parts:string[],index=0):number {
|
export function applySensitivePath(node: any, parts: string[], index = 0): number {
|
||||||
if(index===parts.length) return 0;
|
if (index === parts.length) return 0;
|
||||||
const part=parts[index];
|
const part = parts[index];
|
||||||
if(part==='*'){
|
if (part === '*') {
|
||||||
if(!Array.isArray(node)) return 0;
|
if (!Array.isArray(node)) return 0;
|
||||||
if(index===parts.length-1){for(let i=0;i<node.length;i++)node[i]=typedRedact(node[i]);return node.length;}
|
if (index === parts.length - 1) {
|
||||||
return node.reduce((n,item)=>n+applySensitivePath(item,parts,index+1),0);
|
for (let i = 0; i < node.length; i++) node[i] = typedRedact(node[i]);
|
||||||
}
|
return node.length;
|
||||||
if(!node||typeof node!=='object'||!(part in node)) return 0;
|
}
|
||||||
if(index===parts.length-1){node[part]=typedRedact(node[part]);return 1;}
|
return node.reduce((n, item) => n + applySensitivePath(item, parts, index + 1), 0);
|
||||||
return applySensitivePath(node[part],parts,index+1);
|
}
|
||||||
|
if (!node || typeof node !== 'object' || !(part in node)) return 0;
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
node[part] = typedRedact(node[part]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return applySensitivePath(node[part], parts, index + 1);
|
||||||
}
|
}
|
||||||
export function redactOperationPayload<T>(value:T,operation:any):T {
|
export function redactOperationPayload<T>(value: T, operation: any): T {
|
||||||
const clone=structuredClone(value);
|
const clone = structuredClone(value);
|
||||||
for(const field of operation?.sensitiveFields||[]){
|
for (const field of operation?.sensitiveFields || []) {
|
||||||
if(field?.direction!=='response') continue;
|
if (field?.direction !== 'response') continue;
|
||||||
const parts=parseResponseSensitivePath(field.path);
|
const parts = parseResponseSensitivePath(field.path);
|
||||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||||
// (normally beginning with `data`), so do not add another response wrapper.
|
// (normally beginning with `data`), so do not add another response wrapper.
|
||||||
applySensitivePath(clone,parts);
|
applySensitivePath(clone, parts);
|
||||||
}
|
}
|
||||||
return new Context().value(clone) as T;
|
return new Context().value(clone) as T;
|
||||||
|
}
|
||||||
|
export function redact<T>(value: T): T {
|
||||||
|
return new Context().value(value) as T;
|
||||||
}
|
}
|
||||||
export function redact<T>(value:T):T { return new Context().value(value) as T; }
|
|
||||||
|
|||||||
@@ -1,73 +1,144 @@
|
|||||||
import { parseResponseSensitivePath } from './redactor.ts';
|
import { parseResponseSensitivePath } from './redactor.ts';
|
||||||
|
|
||||||
export type Shape={type:string,keys?:Record<string,Shape>,items?:Shape[]};
|
export type Shape = { type: string; keys?: Record<string, Shape>; items?: Shape[] };
|
||||||
|
|
||||||
export function payloadShape(value:any):Shape {
|
export function payloadShape(value: any): Shape {
|
||||||
if(value===null)return {type:'null'};
|
if (value === null) return { type: 'null' };
|
||||||
if(Array.isArray(value))return {type:'array',items:value.map(payloadShape)};
|
if (Array.isArray(value)) return { type: 'array', items: value.map(payloadShape) };
|
||||||
if(typeof value==='object')return {type:'object',keys:Object.fromEntries(Object.keys(value).sort().map(k=>[k,payloadShape(value[k])]))};
|
if (typeof value === 'object')
|
||||||
return {type:typeof value};
|
return {
|
||||||
|
type: 'object',
|
||||||
|
keys: Object.fromEntries(
|
||||||
|
Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((k) => [k, payloadShape(value[k])]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { type: typeof value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateShapeNode(node:any):void {
|
export function validateShapeNode(node: any): void {
|
||||||
if(!node||typeof node!=='object'||Array.isArray(node))throw new Error('invalid shape node');
|
if (!node || typeof node !== 'object' || Array.isArray(node))
|
||||||
const allowed=node.type==='object'?['keys','type']:node.type==='array'?['items','type']:['type'];
|
throw new Error('invalid shape node');
|
||||||
if(!['object','array','string','number','boolean','null'].includes(node.type)||JSON.stringify(Object.keys(node).sort())!==JSON.stringify(allowed))throw new Error('invalid shape node');
|
const allowed =
|
||||||
if(node.type==='object'){
|
node.type === 'object'
|
||||||
if(!node.keys||typeof node.keys!=='object'||Array.isArray(node.keys))throw new Error('invalid shape keys');
|
? ['keys', 'type']
|
||||||
for(const child of Object.values(node.keys))validateShapeNode(child);
|
: node.type === 'array'
|
||||||
} else if(node.type==='array'){
|
? ['items', 'type']
|
||||||
if(!Array.isArray(node.items))throw new Error('invalid shape items');
|
: ['type'];
|
||||||
for(const child of node.items)validateShapeNode(child);
|
if (
|
||||||
}
|
!['object', 'array', 'string', 'number', 'boolean', 'null'].includes(node.type) ||
|
||||||
|
JSON.stringify(Object.keys(node).sort()) !== JSON.stringify(allowed)
|
||||||
|
)
|
||||||
|
throw new Error('invalid shape node');
|
||||||
|
if (node.type === 'object') {
|
||||||
|
if (!node.keys || typeof node.keys !== 'object' || Array.isArray(node.keys))
|
||||||
|
throw new Error('invalid shape keys');
|
||||||
|
for (const child of Object.values(node.keys)) validateShapeNode(child);
|
||||||
|
} else if (node.type === 'array') {
|
||||||
|
if (!Array.isArray(node.items)) throw new Error('invalid shape items');
|
||||||
|
for (const child of node.items) validateShapeNode(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalPrimitive(value:any){
|
function canonicalPrimitive(value: any) {
|
||||||
return value===null||value==='[REDACTED]'||(typeof value==='number'&&value===0)||(typeof value==='boolean'&&value===false);
|
return (
|
||||||
|
value === null ||
|
||||||
|
value === '[REDACTED]' ||
|
||||||
|
(typeof value === 'number' && value === 0) ||
|
||||||
|
(typeof value === 'boolean' && value === false)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function assertCanonicalTree(value:any):void {
|
function assertCanonicalTree(value: any): void {
|
||||||
if(Array.isArray(value)){for(const item of value)assertCanonicalTree(item);return;}
|
if (Array.isArray(value)) {
|
||||||
if(value&&typeof value==='object'){for(const item of Object.values(value))assertCanonicalTree(item);return;}
|
for (const item of value) assertCanonicalTree(item);
|
||||||
if(!canonicalPrimitive(value))throw new Error('non-canonical sensitive leaf');
|
return;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const item of Object.values(value)) assertCanonicalTree(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canonicalPrimitive(value)) throw new Error('non-canonical sensitive leaf');
|
||||||
}
|
}
|
||||||
function matchedNodes(node:any,parts:string[],i=0):any[]{
|
function matchedNodes(node: any, parts: string[], i = 0): any[] {
|
||||||
if(i===parts.length)return [node];
|
if (i === parts.length) return [node];
|
||||||
if(parts[i]==='*')return Array.isArray(node)?node.flatMap(x=>matchedNodes(x,parts,i+1)):[];
|
if (parts[i] === '*')
|
||||||
return node&&typeof node==='object'&&parts[i] in node?matchedNodes(node[parts[i]],parts,i+1):[];
|
return Array.isArray(node) ? node.flatMap((x) => matchedNodes(x, parts, i + 1)) : [];
|
||||||
|
return node && typeof node === 'object' && parts[i] in node
|
||||||
|
? matchedNodes(node[parts[i]], parts, i + 1)
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
// Deliberately excludes ordinary `message`; only credential-like generic keys belong here.
|
// Deliberately excludes ordinary `message`; only credential-like generic keys belong here.
|
||||||
const GENERIC_SENSITIVE_KEY=/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
const GENERIC_SENSITIVE_KEY =
|
||||||
export function validateSensitivePayload(payload:any,operation:any):void {
|
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
||||||
for(const field of operation?.sensitiveFields||[]){
|
export function validateSensitivePayload(payload: any, operation: any): void {
|
||||||
if(field?.direction!=='response')continue;
|
for (const field of operation?.sensitiveFields || []) {
|
||||||
for(const node of matchedNodes(payload,parseResponseSensitivePath(field.path)))assertCanonicalTree(node);
|
if (field?.direction !== 'response') continue;
|
||||||
}
|
for (const node of matchedNodes(payload, parseResponseSensitivePath(field.path)))
|
||||||
function walk(node:any):void{
|
assertCanonicalTree(node);
|
||||||
if(Array.isArray(node)){for(const x of node)walk(x);return;}
|
}
|
||||||
if(node&&typeof node==='object')for(const [key,value] of Object.entries(node)){if(GENERIC_SENSITIVE_KEY.test(key))assertCanonicalTree(value);else walk(value);}
|
function walk(node: any): void {
|
||||||
}
|
if (Array.isArray(node)) {
|
||||||
walk(payload);
|
for (const x of node) walk(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node && typeof node === 'object')
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
if (GENERIC_SENSITIVE_KEY.test(key)) assertCanonicalTree(value);
|
||||||
|
else walk(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isJson=(s:any)=>typeof s==='string'&&(s==='application/json'||s.endsWith('+json'));
|
const isJson = (s: any) =>
|
||||||
const exactPayload=(actual:any,expected:any)=>JSON.stringify(actual)===JSON.stringify(expected);
|
typeof s === 'string' && (s === 'application/json' || s.endsWith('+json'));
|
||||||
export function validateStatusContract(f:any):void {
|
const exactPayload = (actual: any, expected: any) =>
|
||||||
const status=f.httpStatus,category=f.statusCategory;
|
JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
if(category==='success'){
|
export function validateStatusContract(f: any): void {
|
||||||
if(!(Number.isInteger(status)&&status>=200&&status<300&&isJson(f.contentType)))throw new Error('inconsistent success status');
|
const status = f.httpStatus,
|
||||||
}else if(category==='auth-required'){
|
category = f.statusCategory;
|
||||||
if(![401,403].includes(status))throw new Error('inconsistent auth status');
|
if (category === 'success') {
|
||||||
}else if(category==='unsupported'){
|
if (!(Number.isInteger(status) && status >= 200 && status < 300 && isJson(f.contentType)))
|
||||||
if(![404,405,501].includes(status))throw new Error('inconsistent unsupported status');
|
throw new Error('inconsistent success status');
|
||||||
}else if(category==='non-json'){
|
} else if (category === 'auth-required') {
|
||||||
const valid=Number.isInteger(status)&&status>=200&&status<300&&((!isJson(f.contentType)&&exactPayload(f.payload,{text:'[REDACTED]'}))||(isJson(f.contentType)&&exactPayload(f.payload,{error:'invalid-json'})));
|
if (![401, 403].includes(status)) throw new Error('inconsistent auth status');
|
||||||
if(!valid)throw new Error('inconsistent non-json status');
|
} else if (category === 'unsupported') {
|
||||||
}else if(category==='http-error'){
|
if (![404, 405, 501].includes(status)) throw new Error('inconsistent unsupported status');
|
||||||
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
} else if (category === 'non-json') {
|
||||||
if(!(Number.isInteger(status)&&status>=300&&status<600&&![401,403,404,405,501].includes(status)))throw new Error('inconsistent http error status');
|
const valid =
|
||||||
}else if(category==='timeout'){
|
Number.isInteger(status) &&
|
||||||
if(status!==null||f.contentType!==null||f.latencyBucket!=='timeout'||!exactPayload(f.payload,{error:'timeout'}))throw new Error('inconsistent timeout status');
|
status >= 200 &&
|
||||||
}else if(category==='network-error'){
|
status < 300 &&
|
||||||
if(status!==null||f.contentType!==null||f.latencyBucket==='timeout'||!exactPayload(f.payload,{error:'network-error'}))throw new Error('inconsistent network status');
|
((!isJson(f.contentType) && exactPayload(f.payload, { text: '[REDACTED]' })) ||
|
||||||
}else throw new Error('unknown status category');
|
(isJson(f.contentType) && exactPayload(f.payload, { error: 'invalid-json' })));
|
||||||
|
if (!valid) throw new Error('inconsistent non-json status');
|
||||||
|
} else if (category === 'http-error') {
|
||||||
|
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
Number.isInteger(status) &&
|
||||||
|
status >= 300 &&
|
||||||
|
status < 600 &&
|
||||||
|
![401, 403, 404, 405, 501].includes(status)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('inconsistent http error status');
|
||||||
|
} else if (category === 'timeout') {
|
||||||
|
if (
|
||||||
|
status !== null ||
|
||||||
|
f.contentType !== null ||
|
||||||
|
f.latencyBucket !== 'timeout' ||
|
||||||
|
!exactPayload(f.payload, { error: 'timeout' })
|
||||||
|
)
|
||||||
|
throw new Error('inconsistent timeout status');
|
||||||
|
} else if (category === 'network-error') {
|
||||||
|
if (
|
||||||
|
status !== null ||
|
||||||
|
f.contentType !== null ||
|
||||||
|
f.latencyBucket === 'timeout' ||
|
||||||
|
!exactPayload(f.payload, { error: 'network-error' })
|
||||||
|
)
|
||||||
|
throw new Error('inconsistent network status');
|
||||||
|
} else throw new Error('unknown status category');
|
||||||
}
|
}
|
||||||
@@ -4,98 +4,316 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||||
import { collectOne, selectReadonlyOperations, validateReadonlyOperation, DENY_REASONS } from '../scripts/collector.ts';
|
import {
|
||||||
|
collectOne,
|
||||||
|
selectReadonlyOperations,
|
||||||
|
validateReadonlyOperation,
|
||||||
|
DENY_REASONS,
|
||||||
|
} from '../scripts/collector.ts';
|
||||||
import { redact } from '../src/redactor.ts';
|
import { redact } from '../src/redactor.ts';
|
||||||
import { payloadShape, validateShapeNode, validateSensitivePayload, validateStatusContract } from '../src/verifier.ts';
|
import {
|
||||||
|
payloadShape,
|
||||||
|
validateShapeNode,
|
||||||
|
validateSensitivePayload,
|
||||||
|
validateStatusContract,
|
||||||
|
} from '../src/verifier.ts';
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '..');
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
const fixtureRoot = path.join(root, 'src/simadmin');
|
const fixtureRoot = path.join(root, 'src/simadmin');
|
||||||
const allowedEnvelope = ['schemaVersion','upstreamBaseline','capturedAt','sourceInstanceAlias','operationId','method','pathTemplate','statusCategory','httpStatus','contentType','latencyBucket','redacted','payload'];
|
const allowedEnvelope = [
|
||||||
|
'schemaVersion',
|
||||||
|
'upstreamBaseline',
|
||||||
|
'capturedAt',
|
||||||
|
'sourceInstanceAlias',
|
||||||
|
'operationId',
|
||||||
|
'method',
|
||||||
|
'pathTemplate',
|
||||||
|
'statusCategory',
|
||||||
|
'httpStatus',
|
||||||
|
'contentType',
|
||||||
|
'latencyBucket',
|
||||||
|
'redacted',
|
||||||
|
'payload',
|
||||||
|
];
|
||||||
// Preserve business-schema keys such as `url` and channel `headers`; sensitive values
|
// Preserve business-schema keys such as `url` and channel `headers`; sensitive values
|
||||||
// are typed-redacted. The exact top-level envelope already excludes transport metadata.
|
// are typed-redacted. The exact top-level envelope already excludes transport metadata.
|
||||||
const forbiddenKeys = /^(raw(response)?|instance(id|name))$/i;
|
const forbiddenKeys = /^(raw(response)?|instance(id|name))$/i;
|
||||||
const DENIED_PATHS = new Set(Object.keys(DENY_REASONS));
|
const DENIED_PATHS = new Set(Object.keys(DENY_REASONS));
|
||||||
const leakPatterns = [
|
const leakPatterns = [
|
||||||
/https?:\/\//i, /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
/https?:\/\//i,
|
||||||
/\b(?:\d{1,3}\.){3}\d{1,3}\b/, /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i,
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
||||||
/\+?\d(?:[ ()-]*\d){9,14}/, /\b\d{14,22}\b/,
|
/\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
||||||
|
/(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i,
|
||||||
|
/\+?\d(?:[ ()-]*\d){9,14}/,
|
||||||
|
/\b\d{14,22}\b/,
|
||||||
/\b(?:bearer\s+)?[A-Za-z0-9_-]{32,}\b/i,
|
/\b(?:bearer\s+)?[A-Za-z0-9_-]{32,}\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function jsonFiles(dir:string):Promise<string[]> { const out:string[]=[]; for(const e of await readdir(dir,{withFileTypes:true})){const p=path.join(dir,e.name); if(e.isDirectory()) out.push(...await jsonFiles(p)); else if(e.name.endsWith('.json')) out.push(p);} return out; }
|
async function jsonFiles(dir: string): Promise<string[]> {
|
||||||
function walkKeys(v:any, cb:(k:string)=>void){ if(Array.isArray(v)) return v.forEach(x=>walkKeys(x,cb)); if(v&&typeof v==='object') for(const [k,x] of Object.entries(v)){cb(k);walkKeys(x,cb);} }
|
const out: string[] = [];
|
||||||
function stringLeaves(v:any,out:string[]=[]){if(Array.isArray(v))v.forEach(x=>stringLeaves(x,out));else if(v&&typeof v==='object')Object.values(v).forEach(x=>stringLeaves(x,out));else if(typeof v==='string'&&v.length>=3)out.push(v);return out;}
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...(await jsonFiles(p)));
|
||||||
|
else if (e.name.endsWith('.json')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function walkKeys(v: any, cb: (k: string) => void) {
|
||||||
|
if (Array.isArray(v)) return v.forEach((x) => walkKeys(x, cb));
|
||||||
|
if (v && typeof v === 'object')
|
||||||
|
for (const [k, x] of Object.entries(v)) {
|
||||||
|
cb(k);
|
||||||
|
walkKeys(x, cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stringLeaves(v: any, out: string[] = []) {
|
||||||
|
if (Array.isArray(v)) v.forEach((x) => stringLeaves(x, out));
|
||||||
|
else if (v && typeof v === 'object') Object.values(v).forEach((x) => stringLeaves(x, out));
|
||||||
|
else if (typeof v === 'string' && v.length >= 3) out.push(v);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
test('selector is registry-derived passive GET R0 only with explicit denials', () => {
|
test('selector is registry-derived passive GET R0 only with explicit denials', () => {
|
||||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||||
assert.ok(selected.length > 20);
|
assert.ok(selected.length > 20);
|
||||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan' && x.denyReason));
|
assert.ok(
|
||||||
for(const op of selected){ assert.equal(op.method,'GET'); assert.equal(op.riskLevel,'R0'); assert.doesNotMatch(op.pathTemplate,/[{}]/); assert.notEqual(op.pathTemplate,'/api/network/operators/scan'); }
|
denied.some((x: any) => x.pathTemplate === '/api/network/operators/scan' && x.denyReason),
|
||||||
|
);
|
||||||
|
for (const op of selected) {
|
||||||
|
assert.equal(op.method, 'GET');
|
||||||
|
assert.equal(op.riskLevel, 'R0');
|
||||||
|
assert.doesNotMatch(op.pathTemplate, /[{}]/);
|
||||||
|
assert.notEqual(op.pathTemplate, '/api/network/operators/scan');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collector source has no write-method or authentication escape hatch', async () => {
|
test('collector source has no write-method or authentication escape hatch', async () => {
|
||||||
const source = await readFile(path.join(root,'scripts/collect-readonly-fixtures.ts'),'utf8');
|
const source = await readFile(path.join(root, 'scripts/collect-readonly-fixtures.ts'), 'utf8');
|
||||||
assert.doesNotMatch(source, /ensureAuthenticated|login\s*\(|--method|--path|--origin/i);
|
assert.doesNotMatch(source, /ensureAuthenticated|login\s*\(|--method|--path|--origin/i);
|
||||||
assert.doesNotMatch(source, /['"](?:POST|PUT|PATCH|DELETE)['"]/);
|
assert.doesNotMatch(source, /['"](?:POST|PUT|PATCH|DELETE)['"]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('transport can only receive credential-free GET, manual redirect and timeout', async () => {
|
test('transport can only receive credential-free GET, manual redirect and timeout', async () => {
|
||||||
let init:any; const fake=async (_url:any, i:any)=>{init=i; return new Response(JSON.stringify({phone:'+15551234567',message:'private',ip:'10.1.2.3'}),{status:200,headers:{'content-type':'application/json','set-cookie':'bad=1'}})};
|
let init: any;
|
||||||
const op=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
const fake = async (_url: any, i: any) => {
|
||||||
const f=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},op,fake as any);
|
init = i;
|
||||||
assert.deepEqual({method:init.method,body:init.body,redirect:init.redirect,credentials:init.credentials},{method:'GET',body:undefined,redirect:'manual',credentials:'omit'});
|
return new Response(
|
||||||
assert.deepEqual(init.headers, { accept: 'application/json' }); assert.ok(init.signal);
|
JSON.stringify({ phone: '+15551234567', message: 'private', ip: '10.1.2.3' }),
|
||||||
assert.equal(f.payload.phone,'[REDACTED]'); assert.equal(f.payload.message,'private'); assert.equal(f.payload.ip,'[REDACTED]');
|
{ status: 200, headers: { 'content-type': 'application/json', 'set-cookie': 'bad=1' } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const op = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
||||||
|
const f = await collectOne(
|
||||||
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||||
|
op,
|
||||||
|
fake as any,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
method: init.method,
|
||||||
|
body: init.body,
|
||||||
|
redirect: init.redirect,
|
||||||
|
credentials: init.credentials,
|
||||||
|
},
|
||||||
|
{ method: 'GET', body: undefined, redirect: 'manual', credentials: 'omit' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(init.headers, { accept: 'application/json' });
|
||||||
|
assert.ok(init.signal);
|
||||||
|
assert.equal(f.payload.phone, '[REDACTED]');
|
||||||
|
assert.equal(f.payload.message, 'private');
|
||||||
|
assert.equal(f.payload.ip, '[REDACTED]');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('redactor covers sensitive keys and value patterns deterministically',()=>{
|
test('redactor covers sensitive keys and value patterns deterministically', () => {
|
||||||
const x=redact({password:'p',TOKEN:'t',content:'body',phone:'x',other:['+155****4567','+155****4567','a@b.example','https://private.example/x','aa:bb:cc:dd:ee:ff','2001:db8::1']});
|
const x = redact({
|
||||||
assert.equal(x.password,'[REDACTED]'); assert.equal(x.TOKEN,'[REDACTED]'); assert.equal(x.content,'[REDACTED]'); assert.equal(x.phone,'[REDACTED]');
|
password: 'p',
|
||||||
for(const p of leakPatterns) assert.doesNotMatch(JSON.stringify(x),p);
|
TOKEN: 't',
|
||||||
|
content: 'body',
|
||||||
|
phone: 'x',
|
||||||
|
other: [
|
||||||
|
'+155****4567',
|
||||||
|
'+155****4567',
|
||||||
|
'a@b.example',
|
||||||
|
'https://private.example/x',
|
||||||
|
'aa:bb:cc:dd:ee:ff',
|
||||||
|
'2001:db8::1',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(x.password, '[REDACTED]');
|
||||||
|
assert.equal(x.TOKEN, '[REDACTED]');
|
||||||
|
assert.equal(x.content, '[REDACTED]');
|
||||||
|
assert.equal(x.phone, '[REDACTED]');
|
||||||
|
for (const p of leakPatterns) assert.doesNotMatch(JSON.stringify(x), p);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Registry operation identity also rejects in-place structural mutation',()=>{
|
test('Registry operation identity also rejects in-place structural mutation', () => {
|
||||||
const op:any=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
const op: any = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
||||||
const original=structuredClone(op);
|
const original = structuredClone(op);
|
||||||
const mutations=[
|
const mutations = [
|
||||||
(x:any)=>x.sensitiveFields.push({direction:'response',path:'$.response.data.secret'}),
|
(x: any) => x.sensitiveFields.push({ direction: 'response', path: '$.response.data.secret' }),
|
||||||
(x:any)=>{delete x.pathTemplate;},
|
(x: any) => {
|
||||||
(x:any)=>{x.method='POST';},
|
delete x.pathTemplate;
|
||||||
(x:any)=>{x.sensitiveFields='changed';},
|
},
|
||||||
];
|
(x: any) => {
|
||||||
for(const mutate of mutations){
|
x.method = 'POST';
|
||||||
try{mutate(op);assert.throws(()=>validateReadonlyOperation(op),/Registry integrity/);}
|
},
|
||||||
finally{for(const key of Object.keys(op))delete op[key];Object.assign(op,structuredClone(original));}
|
(x: any) => {
|
||||||
}
|
x.sensitiveFields = 'changed';
|
||||||
validateReadonlyOperation(op);
|
},
|
||||||
|
];
|
||||||
|
for (const mutate of mutations) {
|
||||||
|
try {
|
||||||
|
mutate(op);
|
||||||
|
assert.throws(() => validateReadonlyOperation(op), /Registry integrity/);
|
||||||
|
} finally {
|
||||||
|
for (const key of Object.keys(op)) delete op[key];
|
||||||
|
Object.assign(op, structuredClone(original));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateReadonlyOperation(op);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collector categorizes status and JSON parse outcomes consistently',async()=>{
|
test('collector categorizes status and JSON parse outcomes consistently', async () => {
|
||||||
const op=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
const op = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
||||||
const run=(status:number,type:string,body:string|null)=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},op,async()=>new Response(body,{status,headers:{'content-type':type}}));
|
const run = (status: number, type: string, body: string | null) =>
|
||||||
for(const [status,type,body,category] of [[200,'application/problem+json','{}','success'],[200,'application/json','bad','non-json'],[204,'text/plain',null,'non-json'],[401,'application/json','{}','auth-required'],[405,'application/json','{}','unsupported'],[500,'application/json','{}','http-error']] as const){
|
collectOne(
|
||||||
const f=await run(status,type,body);assert.equal(f.statusCategory,category);validateStatusContract(f);
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||||
}
|
op,
|
||||||
const redirect=await run(302,'application/json','{}');assert.equal(redirect.statusCategory,'http-error');validateStatusContract(redirect);
|
async () => new Response(body, { status, headers: { 'content-type': type } }),
|
||||||
assert.throws(()=>validateStatusContract({statusCategory:'success',httpStatus:401,contentType:'application/json',payload:{},latencyBucket:'<250ms'}));
|
);
|
||||||
|
for (const [status, type, body, category] of [
|
||||||
|
[200, 'application/problem+json', '{}', 'success'],
|
||||||
|
[200, 'application/json', 'bad', 'non-json'],
|
||||||
|
[204, 'text/plain', null, 'non-json'],
|
||||||
|
[401, 'application/json', '{}', 'auth-required'],
|
||||||
|
[405, 'application/json', '{}', 'unsupported'],
|
||||||
|
[500, 'application/json', '{}', 'http-error'],
|
||||||
|
] as const) {
|
||||||
|
const f = await run(status, type, body);
|
||||||
|
assert.equal(f.statusCategory, category);
|
||||||
|
validateStatusContract(f);
|
||||||
|
}
|
||||||
|
const redirect = await run(302, 'application/json', '{}');
|
||||||
|
assert.equal(redirect.statusCategory, 'http-error');
|
||||||
|
validateStatusContract(redirect);
|
||||||
|
assert.throws(() =>
|
||||||
|
validateStatusContract({
|
||||||
|
statusCategory: 'success',
|
||||||
|
httpStatus: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
payload: {},
|
||||||
|
latencyBucket: '<250ms',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real fixtures satisfy envelope, registry, leak scan, and manifest integrity', async()=>{
|
test('real fixtures satisfy envelope, registry, leak scan, and manifest integrity', async () => {
|
||||||
const manifest=JSON.parse(await readFile(path.join(root,'src/manifest.json'),'utf8'));assert.deepEqual(Object.keys(manifest).sort(),['domainCoverage','files','realFixtureCount','schemaVersion','shapeBaseline','syntheticFixtureCount','upstreamBaseline']); const files=(await jsonFiles(fixtureRoot)).filter(x=>!x.includes('/synthetic-errors/'));
|
const manifest = JSON.parse(await readFile(path.join(root, 'src/manifest.json'), 'utf8'));
|
||||||
const srcEntries=await readdir(path.join(root,'src'));assert.ok(!srcEntries.includes('synthetic-errors'));
|
assert.deepEqual(Object.keys(manifest).sort(), [
|
||||||
const config=JSON.parse(await readFile(path.resolve(root,'../../config.json'),'utf8'));
|
'domainCoverage',
|
||||||
const privateValues=stringLeaves(config);
|
'files',
|
||||||
const expected=selectReadonlyOperations(upstream58e2204Operations).selected;
|
'realFixtureCount',
|
||||||
assert.equal(files.length,expected.length*2); assert.equal(manifest.realFixtureCount,files.length);assert.equal(manifest.syntheticFixtureCount,0);
|
'schemaVersion',
|
||||||
assert.equal(manifest.schemaVersion,1);assert.equal(manifest.upstreamBaseline,'58e2204');
|
'shapeBaseline',
|
||||||
const shapePath=path.join(root,'src/response-shapes-58e2204.json');const shapeText=await readFile(shapePath,'utf8');const shapeBaseline=JSON.parse(shapeText);
|
'syntheticFixtureCount',
|
||||||
assert.deepEqual(Object.keys(shapeBaseline).sort(),['schemaVersion','shapes','upstreamBaseline']);assert.equal(shapeBaseline.schemaVersion,1);assert.equal(shapeBaseline.upstreamBaseline,'58e2204');assert.equal(Object.keys(shapeBaseline.shapes).length,78);
|
'upstreamBaseline',
|
||||||
assert.deepEqual(Object.keys(manifest.shapeBaseline).sort(),['path','sha256','size']);assert.equal(manifest.shapeBaseline.path,'src/response-shapes-58e2204.json');assert.equal(manifest.shapeBaseline.size,(await stat(shapePath)).size);assert.equal(manifest.shapeBaseline.sha256,createHash('sha256').update(shapeText).digest('hex'));assert.ok(!manifest.files.some((x:any)=>x.path===manifest.shapeBaseline.path));
|
]);
|
||||||
for(const node of Object.values(shapeBaseline.shapes))validateShapeNode(node);
|
const files = (await jsonFiles(fixtureRoot)).filter((x) => !x.includes('/synthetic-errors/'));
|
||||||
const registry=new Map(upstream58e2204Operations.map(x=>[x.operationId,x])); const domains=new Map<string,number>();const pairs=new Set<string>();
|
const srcEntries = await readdir(path.join(root, 'src'));
|
||||||
const disk=files.map(file=>path.relative(root,file)).sort();const listed=manifest.files.map((x:any)=>x.path).sort();assert.deepEqual(listed,disk);assert.equal(new Set(listed).size,listed.length);
|
assert.ok(!srcEntries.includes('synthetic-errors'));
|
||||||
for(const file of files){const text=await readFile(file,'utf8'); const f=JSON.parse(text); assert.deepEqual(Object.keys(f).sort(),[...allowedEnvelope].sort()); assert.equal(f.schemaVersion,1);assert.equal(f.upstreamBaseline,'58e2204');assert.match(f.capturedAt,/^20(?:2[4-9]|[3-9]\d)-\d{2}-\d{2}$/);assert.ok(!Number.isNaN(Date.parse(`${f.capturedAt}T00:00:00Z`)));assert.ok(['instance-1','instance-2'].includes(f.sourceInstanceAlias));assert.equal(f.redacted,true); assert.equal(f.method,'GET'); assert.ok(!('synthetic' in f));assert.ok(['success','non-json','auth-required','unsupported','http-error','timeout','network-error'].includes(f.statusCategory));if(['timeout','network-error'].includes(f.statusCategory))assert.equal(f.httpStatus,null);else assert.ok(Number.isInteger(f.httpStatus)); const op:any=registry.get(f.operationId);assert.ok(op);validateStatusContract(f);validateSensitivePayload(f.payload,op);const shapeKey=`${f.sourceInstanceAlias}::${f.operationId}`;assert.deepEqual(payloadShape(f.payload),shapeBaseline.shapes[shapeKey]); assert.equal(op?.riskLevel,'R0');assert.equal(op?.method,'GET'); assert.equal(op?.pathTemplate,f.pathTemplate);assert.ok(!DENIED_PATHS.has(f.pathTemplate));domains.set(op.upstreamDomain,(domains.get(op.upstreamDomain)||0)+1);const pair=`${f.sourceInstanceAlias}:${f.operationId}`;assert.ok(!pairs.has(pair));pairs.add(pair); walkKeys(f,k=>assert.doesNotMatch(k,forbiddenKeys)); for(const p of leakPatterns) assert.doesNotMatch(text,p); for(const value of privateValues) assert.ok(!text.includes(value),'fixture contains configured value'); const rel=path.relative(root,file); const m=manifest.files.find((x:any)=>x.path===rel); assert.ok(m); assert.equal(m.size,(await stat(file)).size);assert.match(m.sha256,/^[a-f0-9]{64}$/); assert.equal(m.sha256,createHash('sha256').update(text).digest('hex')); }
|
const config = JSON.parse(await readFile(path.resolve(root, '../../config.json'), 'utf8'));
|
||||||
assert.deepEqual(Object.fromEntries([...domains].sort()),manifest.domainCoverage);
|
const privateValues = stringLeaves(config);
|
||||||
assert.deepEqual(Object.keys(shapeBaseline.shapes).sort(),[...pairs].map(x=>x.replace(':','::')).sort());
|
const expected = selectReadonlyOperations(upstream58e2204Operations).selected;
|
||||||
assert.deepEqual([...pairs].sort(),expected.flatMap((op:any)=>['instance-1','instance-2'].map(alias=>`${alias}:${op.operationId}`)).sort());
|
assert.equal(files.length, expected.length * 2);
|
||||||
|
assert.equal(manifest.realFixtureCount, files.length);
|
||||||
|
assert.equal(manifest.syntheticFixtureCount, 0);
|
||||||
|
assert.equal(manifest.schemaVersion, 1);
|
||||||
|
assert.equal(manifest.upstreamBaseline, '58e2204');
|
||||||
|
const shapePath = path.join(root, 'src/response-shapes-58e2204.json');
|
||||||
|
const shapeText = await readFile(shapePath, 'utf8');
|
||||||
|
const shapeBaseline = JSON.parse(shapeText);
|
||||||
|
assert.deepEqual(Object.keys(shapeBaseline).sort(), [
|
||||||
|
'schemaVersion',
|
||||||
|
'shapes',
|
||||||
|
'upstreamBaseline',
|
||||||
|
]);
|
||||||
|
assert.equal(shapeBaseline.schemaVersion, 1);
|
||||||
|
assert.equal(shapeBaseline.upstreamBaseline, '58e2204');
|
||||||
|
assert.equal(Object.keys(shapeBaseline.shapes).length, 78);
|
||||||
|
assert.deepEqual(Object.keys(manifest.shapeBaseline).sort(), ['path', 'sha256', 'size']);
|
||||||
|
assert.equal(manifest.shapeBaseline.path, 'src/response-shapes-58e2204.json');
|
||||||
|
assert.equal(manifest.shapeBaseline.size, (await stat(shapePath)).size);
|
||||||
|
assert.equal(manifest.shapeBaseline.sha256, createHash('sha256').update(shapeText).digest('hex'));
|
||||||
|
assert.ok(!manifest.files.some((x: any) => x.path === manifest.shapeBaseline.path));
|
||||||
|
for (const node of Object.values(shapeBaseline.shapes)) validateShapeNode(node);
|
||||||
|
const registry = new Map(upstream58e2204Operations.map((x) => [x.operationId, x]));
|
||||||
|
const domains = new Map<string, number>();
|
||||||
|
const pairs = new Set<string>();
|
||||||
|
const disk = files.map((file) => path.relative(root, file)).sort();
|
||||||
|
const listed = manifest.files.map((x: any) => x.path).sort();
|
||||||
|
assert.deepEqual(listed, disk);
|
||||||
|
assert.equal(new Set(listed).size, listed.length);
|
||||||
|
for (const file of files) {
|
||||||
|
const text = await readFile(file, 'utf8');
|
||||||
|
const f = JSON.parse(text);
|
||||||
|
assert.deepEqual(Object.keys(f).sort(), [...allowedEnvelope].sort());
|
||||||
|
assert.equal(f.schemaVersion, 1);
|
||||||
|
assert.equal(f.upstreamBaseline, '58e2204');
|
||||||
|
assert.match(f.capturedAt, /^20(?:2[4-9]|[3-9]\d)-\d{2}-\d{2}$/);
|
||||||
|
assert.ok(!Number.isNaN(Date.parse(`${f.capturedAt}T00:00:00Z`)));
|
||||||
|
assert.ok(['instance-1', 'instance-2'].includes(f.sourceInstanceAlias));
|
||||||
|
assert.equal(f.redacted, true);
|
||||||
|
assert.equal(f.method, 'GET');
|
||||||
|
assert.ok(!('synthetic' in f));
|
||||||
|
assert.ok(
|
||||||
|
[
|
||||||
|
'success',
|
||||||
|
'non-json',
|
||||||
|
'auth-required',
|
||||||
|
'unsupported',
|
||||||
|
'http-error',
|
||||||
|
'timeout',
|
||||||
|
'network-error',
|
||||||
|
].includes(f.statusCategory),
|
||||||
|
);
|
||||||
|
if (['timeout', 'network-error'].includes(f.statusCategory)) assert.equal(f.httpStatus, null);
|
||||||
|
else assert.ok(Number.isInteger(f.httpStatus));
|
||||||
|
const op: any = registry.get(f.operationId);
|
||||||
|
assert.ok(op);
|
||||||
|
validateStatusContract(f);
|
||||||
|
validateSensitivePayload(f.payload, op);
|
||||||
|
const shapeKey = `${f.sourceInstanceAlias}::${f.operationId}`;
|
||||||
|
assert.deepEqual(payloadShape(f.payload), shapeBaseline.shapes[shapeKey]);
|
||||||
|
assert.equal(op?.riskLevel, 'R0');
|
||||||
|
assert.equal(op?.method, 'GET');
|
||||||
|
assert.equal(op?.pathTemplate, f.pathTemplate);
|
||||||
|
assert.ok(!DENIED_PATHS.has(f.pathTemplate));
|
||||||
|
domains.set(op.upstreamDomain, (domains.get(op.upstreamDomain) || 0) + 1);
|
||||||
|
const pair = `${f.sourceInstanceAlias}:${f.operationId}`;
|
||||||
|
assert.ok(!pairs.has(pair));
|
||||||
|
pairs.add(pair);
|
||||||
|
walkKeys(f, (k) => assert.doesNotMatch(k, forbiddenKeys));
|
||||||
|
for (const p of leakPatterns) assert.doesNotMatch(text, p);
|
||||||
|
for (const value of privateValues)
|
||||||
|
assert.ok(!text.includes(value), 'fixture contains configured value');
|
||||||
|
const rel = path.relative(root, file);
|
||||||
|
const m = manifest.files.find((x: any) => x.path === rel);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.equal(m.size, (await stat(file)).size);
|
||||||
|
assert.match(m.sha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.equal(m.sha256, createHash('sha256').update(text).digest('hex'));
|
||||||
|
}
|
||||||
|
assert.deepEqual(Object.fromEntries([...domains].sort()), manifest.domainCoverage);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(shapeBaseline.shapes).sort(),
|
||||||
|
[...pairs].map((x) => x.replace(':', '::')).sort(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...pairs].sort(),
|
||||||
|
expected
|
||||||
|
.flatMap((op: any) =>
|
||||||
|
['instance-1', 'instance-2'].map((alias) => `${alias}:${op.operationId}`),
|
||||||
|
)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,90 +1,311 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||||
import { collectOne, selectReadonlyOperations, validateReadonlyOperation, validateInstanceOrigin } from '../scripts/collector.ts';
|
import {
|
||||||
|
collectOne,
|
||||||
|
selectReadonlyOperations,
|
||||||
|
validateReadonlyOperation,
|
||||||
|
validateInstanceOrigin,
|
||||||
|
} from '../scripts/collector.ts';
|
||||||
import { loadInstances } from '../scripts/collect-readonly-fixtures.ts';
|
import { loadInstances } from '../scripts/collect-readonly-fixtures.ts';
|
||||||
import { redact, redactOperationPayload, parseResponseSensitivePath } from '../src/redactor.ts';
|
import { redact, redactOperationPayload, parseResponseSensitivePath } from '../src/redactor.ts';
|
||||||
|
|
||||||
const health=upstream58e2204Operations.find((x:any)=>x.operationId==='getHealth')!;
|
const health = upstream58e2204Operations.find((x: any) => x.operationId === 'getHealth')!;
|
||||||
const fakeResponse=async()=>new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
|
const fakeResponse = async () =>
|
||||||
|
new Response('{"ok":true}', { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
test('operation validator rejects path and registry identity bypasses', async()=>{
|
test('operation validator rejects path and registry identity bypasses', async () => {
|
||||||
const attacks=['https://example.invalid/api/health','//example.invalid/api/health','/api/health?scan=1','/api/health#x','/api/%73can','/api/%252e%252e/health','/api/a/../health','/api\\health','/api//health'];
|
const attacks = [
|
||||||
for(const pathTemplate of attacks) assert.throws(()=>validateReadonlyOperation({...health,pathTemplate}));
|
'https://example.invalid/api/health',
|
||||||
assert.throws(()=>validateReadonlyOperation({...health}));
|
'//example.invalid/api/health',
|
||||||
assert.throws(()=>validateReadonlyOperation({operationId:health.operationId,method:'GET',riskLevel:'R0',pathTemplate:health.pathTemplate}));
|
'/api/health?scan=1',
|
||||||
await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health},fakeResponse));
|
'/api/health#x',
|
||||||
|
'/api/%73can',
|
||||||
|
'/api/%252e%252e/health',
|
||||||
|
'/api/a/../health',
|
||||||
|
'/api\\health',
|
||||||
|
'/api//health',
|
||||||
|
];
|
||||||
|
for (const pathTemplate of attacks)
|
||||||
|
assert.throws(() => validateReadonlyOperation({ ...health, pathTemplate }));
|
||||||
|
assert.throws(() => validateReadonlyOperation({ ...health }));
|
||||||
|
assert.throws(() =>
|
||||||
|
validateReadonlyOperation({
|
||||||
|
operationId: health.operationId,
|
||||||
|
method: 'GET',
|
||||||
|
riskLevel: 'R0',
|
||||||
|
pathTemplate: health.pathTemplate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await assert.rejects(() =>
|
||||||
|
collectOne({ origin: 'http://192.168.1.2', alias: 'instance-1' }, { ...health }, fakeResponse),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('all path bypass classes fail before transport',async()=>{
|
test('all path bypass classes fail before transport', async () => {
|
||||||
let calls=0;const transport=async()=>{calls++;return new Response('{}',{headers:{'content-type':'application/json'}})};
|
let calls = 0;
|
||||||
const attacks=['https://evil.invalid/api/health','//evil.invalid/api/health','/api/health?q=1','/api/health#x','/api/%68ealth','/api/%252e%252e/x','/api/a/../x','/api\\health','/api//health'];
|
const transport = async () => {
|
||||||
for(const pathTemplate of attacks) await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health,pathTemplate},transport));
|
calls++;
|
||||||
assert.equal(calls,0);
|
return new Response('{}', { headers: { 'content-type': 'application/json' } });
|
||||||
assert.throws(()=>selectReadonlyOperations([{...health}]));
|
};
|
||||||
|
const attacks = [
|
||||||
|
'https://evil.invalid/api/health',
|
||||||
|
'//evil.invalid/api/health',
|
||||||
|
'/api/health?q=1',
|
||||||
|
'/api/health#x',
|
||||||
|
'/api/%68ealth',
|
||||||
|
'/api/%252e%252e/x',
|
||||||
|
'/api/a/../x',
|
||||||
|
'/api\\health',
|
||||||
|
'/api//health',
|
||||||
|
];
|
||||||
|
for (const pathTemplate of attacks)
|
||||||
|
await assert.rejects(() =>
|
||||||
|
collectOne(
|
||||||
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||||
|
{ ...health, pathTemplate },
|
||||||
|
transport,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.throws(() => selectReadonlyOperations([{ ...health }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collectOne validates RFC1918 origin before transport',()=>{
|
test('collectOne validates RFC1918 origin before transport', () => {
|
||||||
for(const origin of ['http://127.0.0.1','http://169.254.1.1','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:p@192.168.1.2','http://192.168.1.2/x','http://192.168.1.2?q=1','ftp://192.168.1.2']) assert.throws(()=>validateInstanceOrigin(origin));
|
for (const origin of [
|
||||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'),'https://172.16.2.3:8443');
|
'http://127.0.0.1',
|
||||||
|
'http://169.254.1.1',
|
||||||
|
'http://100.100.100.200',
|
||||||
|
'http://8.8.8.8',
|
||||||
|
'http://router.local',
|
||||||
|
'http://u:p@192.168.1.2',
|
||||||
|
'http://192.168.1.2/x',
|
||||||
|
'http://192.168.1.2?q=1',
|
||||||
|
'ftp://192.168.1.2',
|
||||||
|
])
|
||||||
|
assert.throws(() => validateInstanceOrigin(origin));
|
||||||
|
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'), 'https://172.16.2.3:8443');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('passive selection explicitly denies connectivity and active scan',()=>{
|
test('passive selection explicitly denies connectivity and active scan', () => {
|
||||||
const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan'));
|
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/network/operators/scan'));
|
||||||
assert.ok(!selected.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
assert.ok(!selected.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('config uses formal validation plus collector LAN/auth/origin constraints',()=>{
|
test('config uses formal validation plus collector LAN/auth/origin constraints', () => {
|
||||||
const good={instances:[{id:'a',url:'http://192.168.1.2',auth:{mode:'none'}},{id:'b',url:'https://10.0.0.2',auth:{mode:'none'}}]};
|
const good = {
|
||||||
assert.deepEqual(loadInstances(good).map((x:any)=>x.alias),['instance-1','instance-2']);
|
instances: [
|
||||||
const bad=['http://127.0.0.1','http://169.254.1.2','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:***@192.168.1.2','http://192.168.1.2/path','http://192.168.1.2?q=1','http://192.168.1.2#x','http://[::1]'];
|
{ id: 'a', url: 'http://192.168.1.2', auth: { mode: 'none' } },
|
||||||
for(const url of bad) assert.throws(()=>loadInstances({instances:[{id:'a',url},{id:'b',url:'http://10.0.0.2'}]}));
|
{ id: 'b', url: 'https://10.0.0.2', auth: { mode: 'none' } },
|
||||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2',auth:{password:'x'}},{id:'b',url:'http://10.0.0.2'}]}));
|
],
|
||||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'}]}));
|
};
|
||||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'},{id:'b',url:'http://10.0.0.2'},{id:'c',url:'http://10.0.0.3'}]}));
|
assert.deepEqual(
|
||||||
|
loadInstances(good).map((x: any) => x.alias),
|
||||||
|
['instance-1', 'instance-2'],
|
||||||
|
);
|
||||||
|
const bad = [
|
||||||
|
'http://127.0.0.1',
|
||||||
|
'http://169.254.1.2',
|
||||||
|
'http://100.100.100.200',
|
||||||
|
'http://8.8.8.8',
|
||||||
|
'http://router.local',
|
||||||
|
'http://u:***@192.168.1.2',
|
||||||
|
'http://192.168.1.2/path',
|
||||||
|
'http://192.168.1.2?q=1',
|
||||||
|
'http://192.168.1.2#x',
|
||||||
|
'http://[::1]',
|
||||||
|
];
|
||||||
|
for (const url of bad)
|
||||||
|
assert.throws(() =>
|
||||||
|
loadInstances({
|
||||||
|
instances: [
|
||||||
|
{ id: 'a', url },
|
||||||
|
{ id: 'b', url: 'http://10.0.0.2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
loadInstances({
|
||||||
|
instances: [
|
||||||
|
{ id: 'a', url: 'http://192.168.1.2', auth: { password: 'x' } },
|
||||||
|
{ id: 'b', url: 'http://10.0.0.2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.throws(() => loadInstances({ instances: [{ id: 'a', url: 'http://192.168.1.2' }] }));
|
||||||
|
assert.throws(() =>
|
||||||
|
loadInstances({
|
||||||
|
instances: [
|
||||||
|
{ id: 'a', url: 'http://192.168.1.2' },
|
||||||
|
{ id: 'b', url: 'http://10.0.0.2' },
|
||||||
|
{ id: 'c', url: 'http://10.0.0.3' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('directional response JSONPaths redact every declared path and preserve shape/types',()=>{
|
test('directional response JSONPaths redact every declared path and preserve shape/types', () => {
|
||||||
const ops=upstream58e2204Operations.filter((op:any)=>op.sensitiveFields.some((f:any)=>f.direction==='response'));
|
const ops = upstream58e2204Operations.filter((op: any) =>
|
||||||
assert.ok(ops.length>0);
|
op.sensitiveFields.some((f: any) => f.direction === 'response'),
|
||||||
for(const op of ops) for(const field of op.sensitiveFields.filter((f:any)=>f.direction==='response')){
|
);
|
||||||
const parts=parseResponseSensitivePath(field.path); assert.ok(parts.length>0,`${op.operationId} unsupported response path`);
|
assert.ok(ops.length > 0);
|
||||||
let leaf:any={s:'private',n:42,b:true,z:null,a:['x',{q:9}]};
|
for (const op of ops)
|
||||||
let payload:any=leaf;
|
for (const field of op.sensitiveFields.filter((f: any) => f.direction === 'response')) {
|
||||||
for(let i=parts.length-1;i>=0;i--){const p=parts[i]; payload=p==='*'?[payload]:{[p]:payload};}
|
const parts = parseResponseSensitivePath(field.path);
|
||||||
const out:any=redactOperationPayload(payload,op);
|
assert.ok(parts.length > 0, `${op.operationId} unsupported response path`);
|
||||||
let hit=out; for(const p of parts) hit=p==='*'?hit[0]:hit[p];
|
let leaf: any = { s: 'private', n: 42, b: true, z: null, a: ['x', { q: 9 }] };
|
||||||
assert.deepEqual(hit,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{q:0}]});
|
let payload: any = leaf;
|
||||||
}
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
const p = parts[i];
|
||||||
|
payload = p === '*' ? [payload] : { [p]: payload };
|
||||||
|
}
|
||||||
|
const out: any = redactOperationPayload(payload, op);
|
||||||
|
let hit = out;
|
||||||
|
for (const p of parts) hit = p === '*' ? hit[0] : hit[p];
|
||||||
|
assert.deepEqual(hit, {
|
||||||
|
s: '[REDACTED]',
|
||||||
|
n: 0,
|
||||||
|
b: false,
|
||||||
|
z: null,
|
||||||
|
a: ['[REDACTED]', { q: 0 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deep sanitizer handles reviewed key/value bypasses without schema destruction',()=>{
|
test('deep sanitizer handles reviewed key/value bypasses without schema destruction', () => {
|
||||||
const input:any={credential:'x',api_key:'x',access_key:'x',device_key:'x',private_key:'x',pin:12,puk:34,username:'x',user:'x',account:'x',serial:'x',revision:'x',path:'/private/a',template:'user=x token=abc123 path=/private/a',config:{access_token:'tiny'},phone_number_is_manual:true,sms_center_is_manual:false,message:'API is healthy',nested:'{"username":"x","enabled":true}',host:'private.internal:8080',short_number:'12345678',split_id:'12-34-56-78-90-12-34',ipv6:'::1%lo0',latitude:1.234567,lon:2.345678,cell_id:99,lac:2,tac:3,pci:4,enb:5,gnb:6,uptime:777,traffic:888,timestamp:'2026-07-16T12:34:56Z',unknown:'AKIAIOSFODNN7EXAMPLE'};
|
const input: any = {
|
||||||
const x:any=redact(input);
|
credential: 'x',
|
||||||
for(const k of ['credential','api_key','access_key','device_key','private_key','username','user','account','serial','revision','path','template','host','short_number','split_id','ipv6','timestamp','unknown']) assert.equal(x[k],'[REDACTED]');
|
api_key: 'x',
|
||||||
for(const k of ['pin','puk','latitude','lon','cell_id','lac','tac','pci','enb','gnb','uptime','traffic']) assert.equal(x[k],0);
|
access_key: 'x',
|
||||||
assert.equal(typeof x.nested,'string'); assert.deepEqual(JSON.parse(x.nested),{username:'[REDACTED]',enabled:true});
|
device_key: 'x',
|
||||||
assert.deepEqual(x.config,{access_token:'[REDACTED]'}); assert.equal(x.phone_number_is_manual,true); assert.equal(x.sms_center_is_manual,false); assert.equal(x.message,'API is healthy');
|
private_key: 'x',
|
||||||
|
pin: 12,
|
||||||
|
puk: 34,
|
||||||
|
username: 'x',
|
||||||
|
user: 'x',
|
||||||
|
account: 'x',
|
||||||
|
serial: 'x',
|
||||||
|
revision: 'x',
|
||||||
|
path: '/private/a',
|
||||||
|
template: 'user=x token=abc123 path=/private/a',
|
||||||
|
config: { access_token: 'tiny' },
|
||||||
|
phone_number_is_manual: true,
|
||||||
|
sms_center_is_manual: false,
|
||||||
|
message: 'API is healthy',
|
||||||
|
nested: '{"username":"x","enabled":true}',
|
||||||
|
host: 'private.internal:8080',
|
||||||
|
short_number: '12345678',
|
||||||
|
split_id: '12-34-56-78-90-12-34',
|
||||||
|
ipv6: '::1%lo0',
|
||||||
|
latitude: 1.234567,
|
||||||
|
lon: 2.345678,
|
||||||
|
cell_id: 99,
|
||||||
|
lac: 2,
|
||||||
|
tac: 3,
|
||||||
|
pci: 4,
|
||||||
|
enb: 5,
|
||||||
|
gnb: 6,
|
||||||
|
uptime: 777,
|
||||||
|
traffic: 888,
|
||||||
|
timestamp: '2026-07-16T12:34:56Z',
|
||||||
|
unknown: 'AKIAIOSFODNN7EXAMPLE',
|
||||||
|
};
|
||||||
|
const x: any = redact(input);
|
||||||
|
for (const k of [
|
||||||
|
'credential',
|
||||||
|
'api_key',
|
||||||
|
'access_key',
|
||||||
|
'device_key',
|
||||||
|
'private_key',
|
||||||
|
'username',
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'serial',
|
||||||
|
'revision',
|
||||||
|
'path',
|
||||||
|
'template',
|
||||||
|
'host',
|
||||||
|
'short_number',
|
||||||
|
'split_id',
|
||||||
|
'ipv6',
|
||||||
|
'timestamp',
|
||||||
|
'unknown',
|
||||||
|
])
|
||||||
|
assert.equal(x[k], '[REDACTED]');
|
||||||
|
for (const k of [
|
||||||
|
'pin',
|
||||||
|
'puk',
|
||||||
|
'latitude',
|
||||||
|
'lon',
|
||||||
|
'cell_id',
|
||||||
|
'lac',
|
||||||
|
'tac',
|
||||||
|
'pci',
|
||||||
|
'enb',
|
||||||
|
'gnb',
|
||||||
|
'uptime',
|
||||||
|
'traffic',
|
||||||
|
])
|
||||||
|
assert.equal(x[k], 0);
|
||||||
|
assert.equal(typeof x.nested, 'string');
|
||||||
|
assert.deepEqual(JSON.parse(x.nested), { username: '[REDACTED]', enabled: true });
|
||||||
|
assert.deepEqual(x.config, { access_token: '[REDACTED]' });
|
||||||
|
assert.equal(x.phone_number_is_manual, true);
|
||||||
|
assert.equal(x.sms_center_is_manual, false);
|
||||||
|
assert.equal(x.message, 'API is healthy');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('schema shapes and primitive types survive conservative redaction',()=>{
|
test('schema shapes and primitive types survive conservative redaction', () => {
|
||||||
const x:any=redact({phone_numbers:['12345678','87654321'],ip_addresses:[{v4:'192.168.1.9',active:true}],urls:['private.example/x'],webhook:{url:'//private.example/h',enabled:true},config:{s:'secret',n:42,b:true,z:null,a:['x',{n:9}]}});
|
const x: any = redact({
|
||||||
assert.ok(Array.isArray(x.phone_numbers));assert.equal(x.phone_numbers.length,2);
|
phone_numbers: ['12345678', '87654321'],
|
||||||
assert.ok(Array.isArray(x.ip_addresses));assert.equal(typeof x.ip_addresses[0],'object');
|
ip_addresses: [{ v4: '192.168.1.9', active: true }],
|
||||||
assert.ok(Array.isArray(x.urls));assert.equal(typeof x.webhook,'object');
|
urls: ['private.example/x'],
|
||||||
assert.deepEqual(x.config,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{n:0}]});
|
webhook: { url: '//private.example/h', enabled: true },
|
||||||
|
config: { s: 'secret', n: 42, b: true, z: null, a: ['x', { n: 9 }] },
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(x.phone_numbers));
|
||||||
|
assert.equal(x.phone_numbers.length, 2);
|
||||||
|
assert.ok(Array.isArray(x.ip_addresses));
|
||||||
|
assert.equal(typeof x.ip_addresses[0], 'object');
|
||||||
|
assert.ok(Array.isArray(x.urls));
|
||||||
|
assert.equal(typeof x.webhook, 'object');
|
||||||
|
assert.deepEqual(x.config, {
|
||||||
|
s: '[REDACTED]',
|
||||||
|
n: 0,
|
||||||
|
b: false,
|
||||||
|
z: null,
|
||||||
|
a: ['[REDACTED]', { n: 0 }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid declared JSON and non-JSON bodies are non-json and discarded',async()=>{
|
test('invalid declared JSON and non-JSON bodies are non-json and discarded', async () => {
|
||||||
const invalid=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('{bad',{status:200,headers:{'content-type':'application/json'}}));
|
const invalid = await collectOne(
|
||||||
assert.equal(invalid.statusCategory,'non-json');assert.deepEqual(invalid.payload,{error:'invalid-json'});
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||||
const text=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('private body',{status:200,headers:{'content-type':'text/plain'}}));
|
health,
|
||||||
assert.equal(text.statusCategory,'non-json');assert.deepEqual(text.payload,{text:'[REDACTED]'});
|
async () =>
|
||||||
|
new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||||
|
);
|
||||||
|
assert.equal(invalid.statusCategory, 'non-json');
|
||||||
|
assert.deepEqual(invalid.payload, { error: 'invalid-json' });
|
||||||
|
const text = await collectOne(
|
||||||
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||||
|
health,
|
||||||
|
async () =>
|
||||||
|
new Response('private body', { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||||
|
);
|
||||||
|
assert.equal(text.statusCategory, 'non-json');
|
||||||
|
assert.deepEqual(text.payload, { text: '[REDACTED]' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collector errors never echo rejected origin values',async()=>{
|
test('collector errors never echo rejected origin values', async () => {
|
||||||
const secret='http://user:password@evil.invalid/private?token=x';
|
const secret = 'http://user:password@evil.invalid/private?token=x';
|
||||||
try{await collectOne({origin:secret,alias:'instance-1'},health,fakeResponse);assert.fail('expected rejection');}catch(error:any){assert.ok(!String(error.message).includes(secret));assert.ok(!String(error.message).includes('password'));}
|
try {
|
||||||
|
await collectOne({ origin: secret, alias: 'instance-1' }, health, fakeResponse);
|
||||||
|
assert.fail('expected rejection');
|
||||||
|
} catch (error: any) {
|
||||||
|
assert.ok(!String(error.message).includes(secret));
|
||||||
|
assert.ok(!String(error.message).includes('password'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user