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';
|
||||
|
||||
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_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
|
||||
export const SCHEDULED_RUN_OUTCOMES = [
|
||||
@@ -13,16 +19,35 @@ export const SCHEDULED_RUN_OUTCOMES = [
|
||||
'needs-attention',
|
||||
] 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 ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
|
||||
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
|
||||
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[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 =
|
||||
| { 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 {
|
||||
readonly maxRetries: number;
|
||||
@@ -32,6 +57,8 @@ export interface ScheduleRetryPolicy {
|
||||
export interface ScheduledSmsInput {
|
||||
readonly recipients: readonly string[];
|
||||
readonly content: string;
|
||||
/** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */
|
||||
readonly randomDelaySeconds?: number;
|
||||
}
|
||||
|
||||
export interface ScheduledSmsSummary {
|
||||
@@ -42,10 +69,13 @@ export interface ScheduledSmsSummary {
|
||||
export interface CreateScheduledTaskRequest {
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
/** Optional so legacy cron-only callers keep working; parsers always fill it in. */
|
||||
readonly trigger?: ScheduleTrigger;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput;
|
||||
readonly delaySeconds?: number;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
@@ -58,10 +88,12 @@ export interface UpdateScheduledTaskRequest {
|
||||
readonly version: number;
|
||||
readonly name?: string;
|
||||
readonly operationType?: ScheduledOperationType;
|
||||
readonly trigger?: ScheduleTrigger;
|
||||
readonly cronExpression?: string;
|
||||
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector?: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput | null;
|
||||
readonly delaySeconds?: number | null;
|
||||
readonly effectiveStartAt?: string | null;
|
||||
readonly effectiveEndAt?: string | null;
|
||||
readonly misfirePolicy?: ScheduleMisfirePolicy;
|
||||
@@ -74,10 +106,12 @@ export interface ScheduledTask {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly trigger?: ScheduleTrigger;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsSummary;
|
||||
readonly delaySeconds?: number;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
@@ -130,10 +164,12 @@ export interface CronPreview {
|
||||
const CREATE_KEYS = new Set([
|
||||
'name',
|
||||
'operationType',
|
||||
'trigger',
|
||||
'cronExpression',
|
||||
'timezone',
|
||||
'targetSelector',
|
||||
'sms',
|
||||
'delaySeconds',
|
||||
'effectiveStartAt',
|
||||
'effectiveEndAt',
|
||||
'misfirePolicy',
|
||||
@@ -204,42 +240,147 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
|
||||
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');
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
|
||||
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
|
||||
return recipient;
|
||||
}),
|
||||
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 {
|
||||
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
|
||||
const source = object(value, 'retryPolicy');
|
||||
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
|
||||
const maxRetries = source.maxRetries;
|
||||
const intervalSeconds = source.intervalSeconds;
|
||||
if (
|
||||
!Number.isSafeInteger(maxRetries) ||
|
||||
(maxRetries as number) < 0 ||
|
||||
(maxRetries as number) > 10
|
||||
)
|
||||
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
|
||||
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)
|
||||
const maxRetries = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10);
|
||||
const intervalSeconds = numberInRange(
|
||||
source.intervalSeconds,
|
||||
'retryPolicy.intervalSeconds',
|
||||
1,
|
||||
86_400,
|
||||
);
|
||||
if (operationType === 'reboot-system' && maxRetries > 0)
|
||||
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 {
|
||||
@@ -248,6 +389,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
||||
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
|
||||
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);
|
||||
if (operationType === 'send-sms' && !smsValue)
|
||||
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');
|
||||
if (source.enabled !== undefined && typeof source.enabled !== '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 {
|
||||
name: string(source.name, 'name', 120),
|
||||
operationType,
|
||||
cronExpression: cronExpression(source.cronExpression),
|
||||
trigger,
|
||||
cronExpression: deriveCronExpression(trigger),
|
||||
timezone: AUTOMATION_TIMEZONE,
|
||||
targetSelector: targetSelector(source.targetSelector),
|
||||
...(smsValue ? { sms: smsValue } : {}),
|
||||
...(delaySeconds === undefined ? {} : { delaySeconds }),
|
||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||
misfirePolicy:
|
||||
@@ -299,8 +454,13 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
||||
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
|
||||
if (source.operationType !== undefined)
|
||||
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.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger;
|
||||
}
|
||||
if (source.timezone !== undefined) {
|
||||
if (source.timezone !== AUTOMATION_TIMEZONE)
|
||||
throw new TypeError('timezone must be Asia/Shanghai');
|
||||
@@ -309,6 +469,11 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
||||
if (source.targetSelector !== undefined)
|
||||
result.targetSelector = targetSelector(source.targetSelector);
|
||||
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)
|
||||
result.effectiveStartAt =
|
||||
source.effectiveStartAt === null
|
||||
|
||||
@@ -4,4 +4,6 @@ export * from './errors.js';
|
||||
export * from './instances.js';
|
||||
export * from './jobs.js';
|
||||
export * from './operations.js';
|
||||
export * from './organization.js';
|
||||
export * from './notifications.js';
|
||||
export const contractsWorkspaceReady = true;
|
||||
|
||||
@@ -41,12 +41,14 @@ export interface InstanceInput {
|
||||
readonly name: string;
|
||||
readonly origin: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly groupId?: string | null;
|
||||
readonly password?: PasswordUpdate;
|
||||
}
|
||||
export interface InstancePatch {
|
||||
readonly name?: string;
|
||||
readonly origin?: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly groupId?: string | null;
|
||||
readonly password?: PasswordUpdate;
|
||||
}
|
||||
export interface LoginInput {
|
||||
@@ -58,6 +60,7 @@ export interface Instance {
|
||||
readonly name: string;
|
||||
readonly origin: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly groupId: string | null;
|
||||
readonly revision: Revision;
|
||||
readonly capabilityStatus: CapabilityStatus;
|
||||
readonly freshness: SnapshotFreshness;
|
||||
@@ -68,6 +71,7 @@ export interface InstanceFilters {
|
||||
readonly capabilityStatus?: CapabilityStatus;
|
||||
readonly freshness?: SnapshotFreshness;
|
||||
readonly tag?: string;
|
||||
readonly groupId?: string;
|
||||
readonly credentialConfigured?: boolean;
|
||||
}
|
||||
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 { fileURLToPath } from 'node:url';
|
||||
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';
|
||||
|
||||
const fixtureManifestPath=fileURLToPath(new URL('../../test-fixtures/src/manifest.json',import.meta.url));
|
||||
const matrixPath=fileURLToPath(new URL('../../../docs/product/operation-acceptance-matrix.md',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 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());};
|
||||
const fixtureManifestPath = fileURLToPath(
|
||||
new URL('../../test-fixtures/src/manifest.json', import.meta.url),
|
||||
);
|
||||
const matrixPath = fileURLToPath(
|
||||
new URL('../../../docs/product/operation-acceptance-matrix.md', 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 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',()=>{
|
||||
for(const groups of [surfaceGroups58e2204,policyGroups58e2204,scenarioGroups58e2204,availabilityGroups58e2204])assertExactPartition(groups);
|
||||
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: surface/policy/scenario/availability are literal exact operation partitions', () => {
|
||||
for (const groups of [
|
||||
surfaceGroups58e2204,
|
||||
policyGroups58e2204,
|
||||
scenarioGroups58e2204,
|
||||
availabilityGroups58e2204,
|
||||
])
|
||||
assertExactPartition(groups);
|
||||
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',()=>{
|
||||
assert.equal(operationAcceptance58e2204.length,117);
|
||||
assert.equal(new Set(operationAcceptance58e2204.map(x=>x.operationId)).size,117);
|
||||
assert.deepEqual([...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: acceptance ledger is exact, Registry-bound and IA-owned', () => {
|
||||
assert.equal(operationAcceptance58e2204.length, 117);
|
||||
assert.equal(new Set(operationAcceptance58e2204.map((x) => x.operationId)).size, 117);
|
||||
assert.deepEqual(
|
||||
[...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',()=>{
|
||||
assert.deepEqual(Object.keys(operationAcceptanceOverrides58e2204).sort(),[...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('RED→GREEN: exact overrides and policy-bound acceptance are auditable', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(operationAcceptanceOverrides58e2204).sort(),
|
||||
[...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('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/);});
|
||||
|
||||
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('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('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('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('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/);
|
||||
});
|
||||
|
||||
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 { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
|
||||
|
||||
const PHASE_0_2_COMMIT='9dffadbec271227da7ffb192f31dc78c6ee3c2be';
|
||||
const BASELINE_PATH='packages/operation-registry/src/upstream-58e2204.ts';
|
||||
const repoRoot=fileURLToPath(new URL('../../../',import.meta.url));
|
||||
const safetyFields=['operationId','riskLevel','confirmationPolicy','capability','executionPolicy'] as const;
|
||||
type SafetyRow=Record<(typeof safetyFields)[number],string>;
|
||||
const PHASE_0_2_COMMIT = '9dffadbec271227da7ffb192f31dc78c6ee3c2be';
|
||||
const BASELINE_PATH = 'packages/operation-registry/src/upstream-58e2204.ts';
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const safetyFields = [
|
||||
'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. */
|
||||
export function parseFrozenSafetyBaseline(source:string):SafetyRow[]{
|
||||
const marker='const rawUpstream58e2204Operations = ';
|
||||
const start=source.indexOf('[',source.indexOf(marker)+marker.length);
|
||||
assert.ok(source.includes(marker)&&start>=0,'frozen operation array marker missing');
|
||||
let quoted=false,escaped=false,depth=0,end=-1;
|
||||
for(let i=start;i<source.length;i++){
|
||||
const ch=source[i];
|
||||
if(quoted){if(escaped)escaped=false;else if(ch==='\\')escaped=true;else if(ch==='"')quoted=false;continue;}
|
||||
if(ch==='"'){quoted=true;continue;}if(ch==='[')depth++;else if(ch===']'&&--depth===0){end=i+1;break;}
|
||||
export function parseFrozenSafetyBaseline(source: string): SafetyRow[] {
|
||||
const marker = 'const rawUpstream58e2204Operations = ';
|
||||
const start = source.indexOf('[', source.indexOf(marker) + marker.length);
|
||||
assert.ok(source.includes(marker) && start >= 0, 'frozen operation array marker missing');
|
||||
let quoted = false,
|
||||
escaped = false,
|
||||
depth = 0,
|
||||
end = -1;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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[]){
|
||||
assert.equal(expected.length,117,'independent baseline must contain exactly 117 operations');
|
||||
assert.equal(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');
|
||||
export function assertExactSafetyBaseline(
|
||||
actual: readonly SafetyRow[],
|
||||
expected: readonly SafetyRow[],
|
||||
) {
|
||||
assert.equal(expected.length, 117, 'independent baseline must contain exactly 117 operations');
|
||||
assert.equal(
|
||||
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',()=>{
|
||||
assert.equal(git('rev-parse','9dffadb'),PHASE_0_2_COMMIT);
|
||||
assert.equal(git('cat-file','-t',PHASE_0_2_COMMIT),'commit');
|
||||
const baseline=parseFrozenSafetyBaseline(git('show',`${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||
assertExactSafetyBaseline(project(upstream58e2204Operations),baseline);
|
||||
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('cat-file', '-t', PHASE_0_2_COMMIT), 'commit');
|
||||
const baseline = parseFrozenSafetyBaseline(git('show', `${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||
assertExactSafetyBaseline(project(upstream58e2204Operations), baseline);
|
||||
});
|
||||
|
||||
test('independent safety comparison rejects risk, confirmation, capability and execution-policy mutations',()=>{
|
||||
const baseline=parseFrozenSafetyBaseline(git('show',`${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||
const mutations:[string,string,string][]=[
|
||||
['postData','riskLevel','R0'],
|
||||
['postSmsSend','confirmationPolicy','none'],
|
||||
['postSmsSend','capability','query'],
|
||||
['postSmsSend','executionPolicy','dedicatedFlow']
|
||||
test('independent safety comparison rejects risk, confirmation, capability and execution-policy mutations', () => {
|
||||
const baseline = parseFrozenSafetyBaseline(git('show', `${PHASE_0_2_COMMIT}:${BASELINE_PATH}`));
|
||||
const mutations: [string, string, string][] = [
|
||||
['postData', 'riskLevel', 'R0'],
|
||||
['postSmsSend', 'confirmationPolicy', 'none'],
|
||||
['postSmsSend', 'capability', 'query'],
|
||||
['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 key = (operation: { method: string; path?: string; pathTemplate?: string }) =>
|
||||
`${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 snapshotRoot = fileURLToPath(new URL('./fixtures/upstream-58e2204/', import.meta.url));
|
||||
const snapshotManifest = JSON.parse(await readFile(`${snapshotRoot}/manifest.json`, 'utf8'));
|
||||
const requiredFields = [
|
||||
'operationId', 'upstreamDomain', 'method', 'pathTemplate', 'handler', 'capability', 'riskLevel',
|
||||
'requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'timeoutMs',
|
||||
'idempotency', 'confirmationPolicy', 'batchable', 'syncSafe', 'sensitiveFields',
|
||||
'auditedAtCommit', 'compatibilityAdapter', 'uiOwner', 'sourceEvidence', 'responseFixtureStatus',
|
||||
'operationId',
|
||||
'upstreamDomain',
|
||||
'method',
|
||||
'pathTemplate',
|
||||
'handler',
|
||||
'capability',
|
||||
'riskLevel',
|
||||
'requestSchemaEvidence',
|
||||
'responseSchemaEvidence',
|
||||
'requestContentType',
|
||||
'timeoutMs',
|
||||
'idempotency',
|
||||
'confirmationPolicy',
|
||||
'batchable',
|
||||
'syncSafe',
|
||||
'sensitiveFields',
|
||||
'auditedAtCommit',
|
||||
'compatibilityAdapter',
|
||||
'uiOwner',
|
||||
'sourceEvidence',
|
||||
'responseFixtureStatus',
|
||||
] as const;
|
||||
|
||||
const expectedDomains = new Set([
|
||||
'instances-auth', 'device-system', 'sim', 'cellular', 'radio-lock', 'data-connection',
|
||||
'device-network', 'workmode-esim', 'messages', 'calls', 'notifications', 'automation', 'ota',
|
||||
'instances-auth',
|
||||
'device-system',
|
||||
'sim',
|
||||
'cellular',
|
||||
'radio-lock',
|
||||
'data-connection',
|
||||
'device-network',
|
||||
'workmode-esim',
|
||||
'messages',
|
||||
'calls',
|
||||
'notifications',
|
||||
'automation',
|
||||
'ota',
|
||||
]);
|
||||
const expectedOwners = new Set([
|
||||
'instances-new', 'settings-instance', 'fleet', 'overview', 'cellular', 'device-network',
|
||||
'messages', 'calls', 'esim', 'notifications', 'automation', 'ota',
|
||||
'instances-new',
|
||||
'settings-instance',
|
||||
'fleet',
|
||||
'overview',
|
||||
'cellular',
|
||||
'device-network',
|
||||
'messages',
|
||||
'calls',
|
||||
'esim',
|
||||
'notifications',
|
||||
'automation',
|
||||
'ota',
|
||||
]);
|
||||
const operationKey = (operation: { method: string; pathTemplate: string; handler: string }) =>
|
||||
`${operation.method} ${operation.pathTemplate} ${operation.handler}`;
|
||||
|
||||
test('sensitive fields are independently snapshot-rebuildable model-chain anchors', async () => {
|
||||
const directionClaim = (operation: any, direction: string) =>
|
||||
['request', 'path', 'query'].includes(direction) ? operation.requestEvidence : operation.responseEvidence;
|
||||
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
|
||||
['request', 'path', 'query'].includes(direction)
|
||||
? operation.requestEvidence
|
||||
: operation.responseEvidence;
|
||||
for (const operation of upstream58e2204Operations as any[])
|
||||
for (const field of operation.sensitiveFields) {
|
||||
const evidence = field.evidence;
|
||||
assert.ok(evidence, `${operation.operationId}: ${field.path} missing evidence`);
|
||||
assert.match(evidence.sourceSha256, /^[a-f0-9]{64}$/);
|
||||
assert.ok(evidence.sourceFile && evidence.symbol && (evidence.fieldPath || evidence.modelField || evidence.dynamicKey));
|
||||
assert.ok(
|
||||
evidence.sourceFile &&
|
||||
evidence.symbol &&
|
||||
(evidence.fieldPath || evidence.modelField || evidence.dynamicKey),
|
||||
);
|
||||
const source = await readFile(`${snapshotRoot}/${evidence.sourceFile}`, 'utf8');
|
||||
const slice = source.split('\n').slice(evidence.startLine - 1, evidence.endLine).join('\n');
|
||||
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sourceSha256, `${operation.operationId}: ${field.path}`);
|
||||
const slice = source
|
||||
.split('\n')
|
||||
.slice(evidence.startLine - 1, evidence.endLine)
|
||||
.join('\n');
|
||||
assert.equal(
|
||||
createHash('sha256').update(slice).digest('hex'),
|
||||
evidence.sourceSha256,
|
||||
`${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 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));
|
||||
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');
|
||||
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}`));
|
||||
assert.match(
|
||||
linkSlice,
|
||||
new RegExp(`\\b${link.field}\\s*:\\s*(?:Vec<)?${link.targetSymbol}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path);
|
||||
assert.ok(!get('GET','/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei')));
|
||||
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']);
|
||||
const get = (method: string, path: string) =>
|
||||
(upstream58e2204Operations as any[]).find(
|
||||
(o) => o.method === method && o.pathTemplate === path,
|
||||
);
|
||||
assert.ok(!get('GET', '/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei')));
|
||||
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', () => {
|
||||
@@ -71,9 +146,14 @@ test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional r
|
||||
for (const name of ['requestEvidence', 'responseEvidence']) {
|
||||
const claim = operation[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.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.modelEvidence));
|
||||
}
|
||||
@@ -85,27 +165,80 @@ test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional r
|
||||
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);
|
||||
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'));
|
||||
const get = (method: string, path: string) =>
|
||||
(upstream58e2204Operations as any[]).find(
|
||||
(o) => o.method === method && o.pathTemplate === path,
|
||||
);
|
||||
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 () => {
|
||||
for (const operation of upstream58e2204Operations as any[]) for (const claim of [operation.requestEvidence, operation.responseEvidence]) {
|
||||
for (const operation of upstream58e2204Operations as any[])
|
||||
for (const claim of [operation.requestEvidence, operation.responseEvidence]) {
|
||||
const source = await readFile(`${snapshotRoot}/${claim.sourceFile}`, 'utf8');
|
||||
const slice = source.split('\n').slice(claim.startLine - 1, claim.endLine).join('\n');
|
||||
assert.equal(createHash('sha256').update(slice).digest('hex'), claim.sourceSha256, operation.operationId);
|
||||
const fn = operation.handler.split('::').at(-1); const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source);
|
||||
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}`);
|
||||
const slice = source
|
||||
.split('\n')
|
||||
.slice(claim.startLine - 1, claim.endLine)
|
||||
.join('\n');
|
||||
assert.equal(
|
||||
createHash('sha256').update(slice).digest('hex'),
|
||||
claim.sourceSha256,
|
||||
operation.operationId,
|
||||
);
|
||||
const fn = operation.handler.split('::').at(-1);
|
||||
const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source);
|
||||
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);
|
||||
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*:`));
|
||||
}
|
||||
@@ -114,25 +247,64 @@ test('structured claims are rebuilt from the independent snapshot, with exact br
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
for (let from = 0; ;) {
|
||||
const start = source.indexOf('.route(', from); if (start < 0) break; 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 source = await readFile(`${snapshotRoot}/backend/src/main.rs`, 'utf8');
|
||||
const parsed: string[] = [];
|
||||
for (let from = 0; ; ) {
|
||||
const start = source.indexOf('.route(', from);
|
||||
if (start < 0) break;
|
||||
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();
|
||||
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 () => {
|
||||
for (const evidence of evidenceFixture.operations) for (const bruno of evidence.brunoEvidence ?? []) {
|
||||
const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8'); const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase();
|
||||
const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1]; assert.equal(method, bruno.method); assert.equal(url, bruno.path);
|
||||
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 evidence of evidenceFixture.operations)
|
||||
for (const bruno of evidence.brunoEvidence ?? []) {
|
||||
const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8');
|
||||
const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase();
|
||||
const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1];
|
||||
assert.equal(method, bruno.method);
|
||||
assert.equal(url, bruno.path);
|
||||
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`));
|
||||
}
|
||||
});
|
||||
@@ -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((route: { path: string }) => route.path)).size, 100);
|
||||
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 },
|
||||
);
|
||||
});
|
||||
|
||||
test('registry is exactly route-parity complete with independent fixture', () => {
|
||||
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.equal(new Set(upstream58e2204Operations.map(operation => operation.pathTemplate)).size, 100);
|
||||
assert.equal(new Set(upstream58e2204Operations.map(operation => operation.operationId)).size, 117);
|
||||
assert.equal(
|
||||
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) {
|
||||
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(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', () => {
|
||||
assert.equal(evidenceFixture.upstreamCommit, fixture.upstreamCommit);
|
||||
assert.equal(evidenceFixture.operations.length, 117);
|
||||
assert.deepEqual(evidenceFixture.operations.map((e: any) => `${e.method} ${e.path} ${e.handler}`).sort(),
|
||||
fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort());
|
||||
assert.deepEqual(
|
||||
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) {
|
||||
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.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) {
|
||||
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 slice = source.split('\n').slice(range.startLine - 1, range.endLine).join('\n');
|
||||
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sha256, evidence.handler);
|
||||
const slice = source
|
||||
.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.equal((slice.match(/{/g) ?? []).length, (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);
|
||||
assert.equal(
|
||||
(slice.match(/{/g) ?? []).length,
|
||||
(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', () => {
|
||||
const operations: any[] = upstream58e2204Operations as any;
|
||||
const byPath = (method: string, path: string) => operations.find(o => o.method === method && o.pathTemplate === path)!;
|
||||
assert.match(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>/);
|
||||
const byPath = (method: string, path: string) =>
|
||||
operations.find((o) => o.method === method && o.pathTemplate === path)!;
|
||||
assert.match(
|
||||
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) {
|
||||
if (operation.riskLevel === 'R2') assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy), operation.operationId);
|
||||
if (operation.riskLevel === 'R3') assert.equal(operation.confirmationPolicy, 'strong', operation.operationId);
|
||||
if (operation.riskLevel === 'R2')
|
||||
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[]> = {
|
||||
'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)) {
|
||||
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) {
|
||||
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', () => {
|
||||
for (const operation of upstream58e2204Operations) {
|
||||
for (const field of requiredFields) assert.ok(field in operation, `${operation.operationId}: missing ${field}`);
|
||||
assert.ok(expectedDomains.has(operation.upstreamDomain), `${operation.operationId}: unknown domain`);
|
||||
for (const field of requiredFields)
|
||||
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.match(operation.operationId, /^[a-z][A-Za-z0-9]*$/);
|
||||
assert.ok(['R0', 'R1', 'R2', 'R3'].includes(operation.riskLevel));
|
||||
assert.ok(Number.isInteger(operation.timeoutMs) && operation.timeoutMs > 0);
|
||||
assert.ok(Array.isArray(operation.sensitiveFields));
|
||||
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(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(
|
||||
operation.sourceEvidence.some((evidence) =>
|
||||
/^backend\/src\/main\.rs:\d+-\d+$/.test(evidence),
|
||||
),
|
||||
);
|
||||
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.compatibilityAdapter, 'none-pinned-commit');
|
||||
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.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', () => {
|
||||
const dangerous = upstream58e2204Operations.filter(operation =>
|
||||
['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate));
|
||||
const dangerous = upstream58e2204Operations.filter((operation) =>
|
||||
['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate),
|
||||
);
|
||||
assert.equal(dangerous.length, 2);
|
||||
for (const operation of dangerous) {
|
||||
assert.equal(operation.riskLevel, 'R3');
|
||||
assert.equal(operation.executionPolicy, 'dedicatedFlow');
|
||||
}
|
||||
const loginLogout = upstream58e2204Operations.filter(operation =>
|
||||
['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate));
|
||||
const loginLogout = upstream58e2204Operations.filter((operation) =>
|
||||
['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate),
|
||||
);
|
||||
assert.equal(loginLogout.length, 2);
|
||||
assert.ok(loginLogout.every(operation => operation.riskLevel === 'R1' && operation.sessionSensitive));
|
||||
const authSettingsPost = upstream58e2204Operations.find(operation => key(operation) === 'POST /api/auth/settings');
|
||||
assert.ok(
|
||||
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.equal(authSettingsPost?.syncSafe, false);
|
||||
});
|
||||
|
||||
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 = [
|
||||
['DELETE','/api/esim/profiles/{iccid}'], ['DELETE','/api/call/history/{id}'], ['POST','/api/call/history/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'],
|
||||
['DELETE', '/api/esim/profiles/{iccid}'],
|
||||
['DELETE', '/api/call/history/{id}'],
|
||||
['POST', '/api/call/history/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) {
|
||||
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));
|
||||
}
|
||||
const r2 = [
|
||||
['POST','/api/sms/send'], ['POST','/api/notifications/test/{channel}'],
|
||||
['POST','/api/notifications/queue/retry-all'], ['POST','/api/notifications/queue/{id}/retry'],
|
||||
['POST','/api/automation/test/{task_id}'],
|
||||
['POST', '/api/sms/send'],
|
||||
['POST', '/api/notifications/test/{channel}'],
|
||||
['POST', '/api/notifications/queue/retry-all'],
|
||||
['POST', '/api/notifications/queue/{id}/retry'],
|
||||
['POST', '/api/automation/test/{task_id}'],
|
||||
];
|
||||
for (const [method, path] of r2) {
|
||||
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 () => {
|
||||
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 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);
|
||||
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));
|
||||
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);
|
||||
for (const value of [operation.upstreamDomain, operation.uiOwner, operation.handler, operation.requestContentType,
|
||||
operation.idempotency, operation.riskLevel, operation.capability, evidence.sha256.slice(0, 16)]) {
|
||||
for (const value of [
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -321,8 +612,12 @@ test('evidence matrix lists every registry operation', async () => {
|
||||
assert.match(document, /Phase 0\.3[^\n]*真实响应样本/);
|
||||
assert.match(document, /dynamic-json/);
|
||||
assert.match(document, /58e220411d6599609f0eeda01eb7016e9212f970/);
|
||||
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
|
||||
for (const operation of upstream58e2204Operations as any[])
|
||||
for (const field of operation.sensitiveFields) {
|
||||
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}`);
|
||||
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 { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
|
||||
|
||||
const here=path.dirname(fileURLToPath(import.meta.url));const pkg=path.resolve(here,'..');const repo=path.resolve(pkg,'../..');
|
||||
export function loadInstances(config:any){
|
||||
if(!config||!Array.isArray(config.instances)||config.instances.length!==2)throw new Error('config must contain exactly two instances');
|
||||
for(const raw of config.instances){
|
||||
if(!raw||typeof raw!=='object'||typeof raw.url!=='string')throw new Error('invalid collector instance configuration');
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = path.resolve(here, '..');
|
||||
const repo = path.resolve(pkg, '../..');
|
||||
export function loadInstances(config: any) {
|
||||
if (!config || !Array.isArray(config.instances) || config.instances.length !== 2)
|
||||
throw new Error('config must contain exactly two instances');
|
||||
for (const raw of config.instances) {
|
||||
if (!raw || typeof raw !== 'object' || typeof raw.url !== 'string')
|
||||
throw new Error('invalid collector instance configuration');
|
||||
validateInstanceOrigin(raw.url);
|
||||
if(raw.password||raw.auth?.password||!['none',undefined].includes(raw.auth?.mode))throw new Error('collector instances must be password-free');
|
||||
if (raw.password || raw.auth?.password || !['none', undefined].includes(raw.auth?.mode))
|
||||
throw new Error('collector instances must be password-free');
|
||||
}
|
||||
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}`};
|
||||
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 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');
|
||||
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;
|
||||
}
|
||||
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 { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||
|
||||
export const UPSTREAM_BASELINE='58e2204';
|
||||
export const DENY_REASONS:Record<string,string>={
|
||||
'/api/network/operators/scan':'active radio/network scan',
|
||||
'/api/connectivity':'handler performs active connectivity ping',
|
||||
'/api/sms/list':'message list can expose body; intentionally not collected',
|
||||
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
|
||||
'/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/queue':'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/esim/euicc':'query-bearing endpoint omitted',
|
||||
'/api/esim/profiles':'query-bearing endpoint omitted',
|
||||
export const UPSTREAM_BASELINE = '58e2204';
|
||||
export const DENY_REASONS: Record<string, string> = {
|
||||
'/api/network/operators/scan': 'active radio/network scan',
|
||||
'/api/connectivity': 'handler performs active connectivity ping',
|
||||
'/api/sms/list': 'message list can expose body; intentionally not collected',
|
||||
'/api/sms/conversation': 'requires correspondent query and exposes message bodies',
|
||||
'/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/queue': '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/esim/euicc': '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.
|
||||
// Exact identity alone does not protect mutable Registry objects.
|
||||
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
|
||||
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));}
|
||||
export function validateInstanceOrigin(origin:string){
|
||||
let u:URL;try{u=new URL(origin);}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');
|
||||
const registrySignatures = new Map(
|
||||
upstream58e2204Operations.map((op: any) => [op.operationId, JSON.stringify(op)]),
|
||||
);
|
||||
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))
|
||||
);
|
||||
}
|
||||
export function validateInstanceOrigin(origin: string) {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(origin);
|
||||
} 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;
|
||||
}
|
||||
function validatePath(path:string){
|
||||
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.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');
|
||||
function validatePath(path: string) {
|
||||
if (
|
||||
typeof path !== 'string' ||
|
||||
!path.startsWith('/api/') ||
|
||||
path.startsWith('//') ||
|
||||
path.includes('\\') ||
|
||||
/[?#%]/.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 validateReadonlyOperation(op:any){
|
||||
const exact=registryById.get(op?.operationId);
|
||||
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
|
||||
export function validateReadonlyOperation(op: any) {
|
||||
const exact = registryById.get(op?.operationId);
|
||||
if (!exact || exact !== op || registrySignatures.get(op?.operationId) !== JSON.stringify(op))
|
||||
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');
|
||||
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;
|
||||
}
|
||||
export function selectReadonlyOperations(registry:any[]){
|
||||
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
|
||||
for(const op of candidates) validatePath(op.pathTemplate);
|
||||
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]}))};
|
||||
export function selectReadonlyOperations(registry: any[]) {
|
||||
const candidates = registry.filter(
|
||||
(o) => o.method === 'GET' && o.riskLevel === 'R0' && !/[{}]/.test(o.pathTemplate),
|
||||
);
|
||||
for (const op of candidates) validatePath(op.pathTemplate);
|
||||
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] })),
|
||||
};
|
||||
}
|
||||
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){
|
||||
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);
|
||||
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
|
||||
const origin=validateInstanceOrigin(instance?.origin);
|
||||
const requestUrl=new URL(op.pathTemplate,origin);
|
||||
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{
|
||||
if (!['instance-1', 'instance-2'].includes(instance?.alias))
|
||||
throw new Error('invalid instance alias');
|
||||
const origin = validateInstanceOrigin(instance?.origin);
|
||||
const requestUrl = new URL(op.pathTemplate, origin);
|
||||
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);}
|
||||
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 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 PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i;
|
||||
const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i;
|
||||
const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i;
|
||||
|
||||
export function typedRedact(value:any):any {
|
||||
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(typeof value==='string') return '[REDACTED]';
|
||||
if(typeof value==='number') return 0;
|
||||
if(typeof value==='boolean') return false;
|
||||
export function typedRedact(value: any): any {
|
||||
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 (typeof value === 'string') return '[REDACTED]';
|
||||
if (typeof value === 'number') return 0;
|
||||
if (typeof value === 'boolean') return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function looksHighEntropy(s:string){
|
||||
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;
|
||||
return classes>=3 || (classes>=2 && new Set(s).size>=12);
|
||||
function looksHighEntropy(s: string) {
|
||||
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;
|
||||
return classes >= 3 || (classes >= 2 && new Set(s).size >= 12);
|
||||
}
|
||||
function unsafeString(s:string){
|
||||
return /https?:\/\//i.test(s) || /(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\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) ||
|
||||
/(?:^|[^\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) ||
|
||||
function unsafeString(s: string) {
|
||||
return (
|
||||
/https?:\/\//i.test(s) ||
|
||||
/(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\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) ||
|
||||
/(?:^|[^\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);
|
||||
/\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 {
|
||||
value(value:any,key='',force=false):any {
|
||||
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(STRUCTURED_KEY.test(key)) force=true;
|
||||
if(PHONE_KEY.test(key)||IDENTIFIER_KEY.test(key)||ADDRESS_KEY.test(key)) return typedRedact(value);
|
||||
if(Array.isArray(value)) return value.map(v=>this.value(v,'',force));
|
||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,this.value(v,k,force)]));
|
||||
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;
|
||||
value(value: any, key = '', force = false): any {
|
||||
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 (STRUCTURED_KEY.test(key)) force = true;
|
||||
if (PHONE_KEY.test(key) || IDENTIFIER_KEY.test(key) || ADDRESS_KEY.test(key))
|
||||
return typedRedact(value);
|
||||
if (Array.isArray(value)) return value.map((v) => this.value(v, '', force));
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, this.value(v, k, force)]),
|
||||
);
|
||||
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 {}
|
||||
}
|
||||
if(typeof value==='number' && Number.isInteger(value) && Math.abs(value)>=100_000_000) return 0;
|
||||
return unsafeString(value) ? '[REDACTED]' : value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isInteger(value) && Math.abs(value) >= 100_000_000)
|
||||
return 0;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResponseSensitivePath(path:string):string[] {
|
||||
if(typeof path!=='string'||!path.startsWith('$.response')) throw new Error('unsupported response sensitive path');
|
||||
const rest=path.slice('$.response'.length);
|
||||
if(!rest) return [];
|
||||
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 parseResponseSensitivePath(path: string): string[] {
|
||||
if (typeof path !== 'string' || !path.startsWith('$.response'))
|
||||
throw new Error('unsupported response sensitive path');
|
||||
const rest = path.slice('$.response'.length);
|
||||
if (!rest) return [];
|
||||
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 {
|
||||
if(index===parts.length) return 0;
|
||||
const part=parts[index];
|
||||
if(part==='*'){
|
||||
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;}
|
||||
return node.reduce((n,item)=>n+applySensitivePath(item,parts,index+1),0);
|
||||
export function applySensitivePath(node: any, parts: string[], index = 0): number {
|
||||
if (index === parts.length) return 0;
|
||||
const part = parts[index];
|
||||
if (part === '*') {
|
||||
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(!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);
|
||||
return node.reduce((n, item) => n + applySensitivePath(item, parts, index + 1), 0);
|
||||
}
|
||||
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 {
|
||||
const clone=structuredClone(value);
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response') continue;
|
||||
const parts=parseResponseSensitivePath(field.path);
|
||||
export function redactOperationPayload<T>(value: T, operation: any): T {
|
||||
const clone = structuredClone(value);
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
const parts = parseResponseSensitivePath(field.path);
|
||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||
// (normally beginning with `data`), so do not add another response wrapper.
|
||||
applySensitivePath(clone,parts);
|
||||
applySensitivePath(clone, parts);
|
||||
}
|
||||
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';
|
||||
|
||||
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 {
|
||||
if(value===null)return {type:'null'};
|
||||
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])]))};
|
||||
return {type:typeof value};
|
||||
export function payloadShape(value: any): Shape {
|
||||
if (value === null) return { type: 'null' };
|
||||
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])]),
|
||||
),
|
||||
};
|
||||
return { type: typeof value };
|
||||
}
|
||||
|
||||
export function validateShapeNode(node:any):void {
|
||||
if(!node||typeof node!=='object'||Array.isArray(node))throw new Error('invalid shape node');
|
||||
const allowed=node.type==='object'?['keys','type']:node.type==='array'?['items','type']:['type'];
|
||||
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);
|
||||
export function validateShapeNode(node: any): void {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node))
|
||||
throw new Error('invalid shape node');
|
||||
const allowed =
|
||||
node.type === 'object'
|
||||
? ['keys', 'type']
|
||||
: node.type === 'array'
|
||||
? ['items', 'type']
|
||||
: ['type'];
|
||||
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){
|
||||
return value===null||value==='[REDACTED]'||(typeof value==='number'&&value===0)||(typeof value==='boolean'&&value===false);
|
||||
function canonicalPrimitive(value: any) {
|
||||
return (
|
||||
value === null ||
|
||||
value === '[REDACTED]' ||
|
||||
(typeof value === 'number' && value === 0) ||
|
||||
(typeof value === 'boolean' && value === false)
|
||||
);
|
||||
}
|
||||
function assertCanonicalTree(value:any):void {
|
||||
if(Array.isArray(value)){for(const item of value)assertCanonicalTree(item);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 assertCanonicalTree(value: any): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertCanonicalTree(item);
|
||||
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[]{
|
||||
if(i===parts.length)return [node];
|
||||
if(parts[i]==='*')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):[];
|
||||
function matchedNodes(node: any, parts: string[], i = 0): any[] {
|
||||
if (i === parts.length) return [node];
|
||||
if (parts[i] === '*')
|
||||
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.
|
||||
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;
|
||||
export function validateSensitivePayload(payload:any,operation:any):void {
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response')continue;
|
||||
for(const node of matchedNodes(payload,parseResponseSensitivePath(field.path)))assertCanonicalTree(node);
|
||||
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;
|
||||
export function validateSensitivePayload(payload: any, operation: any): void {
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
for (const node of matchedNodes(payload, parseResponseSensitivePath(field.path)))
|
||||
assertCanonicalTree(node);
|
||||
}
|
||||
function walk(node: any): void {
|
||||
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)){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 exactPayload=(actual:any,expected:any)=>JSON.stringify(actual)===JSON.stringify(expected);
|
||||
export function validateStatusContract(f:any):void {
|
||||
const status=f.httpStatus,category=f.statusCategory;
|
||||
if(category==='success'){
|
||||
if(!(Number.isInteger(status)&&status>=200&&status<300&&isJson(f.contentType)))throw new Error('inconsistent success status');
|
||||
}else if(category==='auth-required'){
|
||||
if(![401,403].includes(status))throw new Error('inconsistent auth status');
|
||||
}else if(category==='unsupported'){
|
||||
if(![404,405,501].includes(status))throw new Error('inconsistent unsupported status');
|
||||
}else if(category==='non-json'){
|
||||
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(!valid)throw new Error('inconsistent non-json status');
|
||||
}else if(category==='http-error'){
|
||||
const isJson = (s: any) =>
|
||||
typeof s === 'string' && (s === 'application/json' || s.endsWith('+json'));
|
||||
const exactPayload = (actual: any, expected: any) =>
|
||||
JSON.stringify(actual) === JSON.stringify(expected);
|
||||
export function validateStatusContract(f: any): void {
|
||||
const status = f.httpStatus,
|
||||
category = f.statusCategory;
|
||||
if (category === 'success') {
|
||||
if (!(Number.isInteger(status) && status >= 200 && status < 300 && isJson(f.contentType)))
|
||||
throw new Error('inconsistent success status');
|
||||
} else if (category === 'auth-required') {
|
||||
if (![401, 403].includes(status)) throw new Error('inconsistent auth status');
|
||||
} else if (category === 'unsupported') {
|
||||
if (![404, 405, 501].includes(status)) throw new Error('inconsistent unsupported status');
|
||||
} else if (category === 'non-json') {
|
||||
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 (!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');
|
||||
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 path from 'node:path';
|
||||
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 { 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 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
|
||||
// are typed-redacted. The exact top-level envelope already excludes transport metadata.
|
||||
const forbiddenKeys = /^(raw(response)?|instance(id|name))$/i;
|
||||
const DENIED_PATHS = new Set(Object.keys(DENY_REASONS));
|
||||
const leakPatterns = [
|
||||
/https?:\/\//i, /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
||||
/\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/,
|
||||
/https?:\/\//i,
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
||||
/\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,
|
||||
];
|
||||
|
||||
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; }
|
||||
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;}
|
||||
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;
|
||||
}
|
||||
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', () => {
|
||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(selected.length > 20);
|
||||
assert.ok(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'); }
|
||||
assert.ok(
|
||||
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 () => {
|
||||
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, /['"](?:POST|PUT|PATCH|DELETE)['"]/);
|
||||
});
|
||||
|
||||
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'}})};
|
||||
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]');
|
||||
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' } },
|
||||
);
|
||||
};
|
||||
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',()=>{
|
||||
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']});
|
||||
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('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',
|
||||
],
|
||||
});
|
||||
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',()=>{
|
||||
const op:any=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
||||
const original=structuredClone(op);
|
||||
const mutations=[
|
||||
(x:any)=>x.sensitiveFields.push({direction:'response',path:'$.response.data.secret'}),
|
||||
(x:any)=>{delete x.pathTemplate;},
|
||||
(x:any)=>{x.method='POST';},
|
||||
(x:any)=>{x.sensitiveFields='changed';},
|
||||
test('Registry operation identity also rejects in-place structural mutation', () => {
|
||||
const op: any = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
||||
const original = structuredClone(op);
|
||||
const mutations = [
|
||||
(x: any) => x.sensitiveFields.push({ direction: 'response', path: '$.response.data.secret' }),
|
||||
(x: any) => {
|
||||
delete x.pathTemplate;
|
||||
},
|
||||
(x: any) => {
|
||||
x.method = 'POST';
|
||||
},
|
||||
(x: any) => {
|
||||
x.sensitiveFields = 'changed';
|
||||
},
|
||||
];
|
||||
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));}
|
||||
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()=>{
|
||||
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}}));
|
||||
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);
|
||||
test('collector categorizes status and JSON parse outcomes consistently', async () => {
|
||||
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 } }),
|
||||
);
|
||||
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'}));
|
||||
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()=>{
|
||||
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 srcEntries=await readdir(path.join(root,'src'));assert.ok(!srcEntries.includes('synthetic-errors'));
|
||||
const config=JSON.parse(await readFile(path.resolve(root,'../../config.json'),'utf8'));
|
||||
const privateValues=stringLeaves(config);
|
||||
const expected=selectReadonlyOperations(upstream58e2204Operations).selected;
|
||||
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());
|
||||
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 srcEntries = await readdir(path.join(root, 'src'));
|
||||
assert.ok(!srcEntries.includes('synthetic-errors'));
|
||||
const config = JSON.parse(await readFile(path.resolve(root, '../../config.json'), 'utf8'));
|
||||
const privateValues = stringLeaves(config);
|
||||
const expected = selectReadonlyOperations(upstream58e2204Operations).selected;
|
||||
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 assert from 'node:assert/strict';
|
||||
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 { redact, redactOperationPayload, parseResponseSensitivePath } from '../src/redactor.ts';
|
||||
|
||||
const health=upstream58e2204Operations.find((x:any)=>x.operationId==='getHealth')!;
|
||||
const fakeResponse=async()=>new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
|
||||
const health = upstream58e2204Operations.find((x: any) => x.operationId === 'getHealth')!;
|
||||
const fakeResponse = async () =>
|
||||
new Response('{"ok":true}', { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
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'];
|
||||
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('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',
|
||||
];
|
||||
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()=>{
|
||||
let calls=0;const transport=async()=>{calls++;return new Response('{}',{headers:{'content-type':'application/json'}})};
|
||||
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('all path bypass classes fail before transport', async () => {
|
||||
let calls = 0;
|
||||
const transport = async () => {
|
||||
calls++;
|
||||
return new Response('{}', { headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
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',()=>{
|
||||
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));
|
||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'),'https://172.16.2.3:8443');
|
||||
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));
|
||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'), 'https://172.16.2.3:8443');
|
||||
});
|
||||
|
||||
test('passive selection explicitly denies connectivity and active scan',()=>{
|
||||
const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan'));
|
||||
assert.ok(!selected.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
test('passive selection explicitly denies connectivity and active scan', () => {
|
||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/network/operators/scan'));
|
||||
assert.ok(!selected.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||
});
|
||||
|
||||
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'}}]};
|
||||
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('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' } },
|
||||
],
|
||||
};
|
||||
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',()=>{
|
||||
const ops=upstream58e2204Operations.filter((op:any)=>op.sensitiveFields.some((f:any)=>f.direction==='response'));
|
||||
assert.ok(ops.length>0);
|
||||
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`);
|
||||
let leaf:any={s:'private',n:42,b:true,z:null,a:['x',{q:9}]};
|
||||
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('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'),
|
||||
);
|
||||
assert.ok(ops.length > 0);
|
||||
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`);
|
||||
let leaf: any = { s: 'private', n: 42, b: true, z: null, a: ['x', { q: 9 }] };
|
||||
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',()=>{
|
||||
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 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('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 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',()=>{
|
||||
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}]}});
|
||||
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('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 }] },
|
||||
});
|
||||
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()=>{
|
||||
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'}}));
|
||||
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('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' } }),
|
||||
);
|
||||
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()=>{
|
||||
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'));}
|
||||
test('collector errors never echo rejected origin values', async () => {
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user