fix: 收敛STUN消息源契约
This commit is contained in:
parent
3dbc37e500
commit
ce60b45575
@ -20,7 +20,7 @@ import { classifyStunEndpointSource } from './network-source-eligibility';
|
|||||||
const SOURCE_KEY = 'network.stun.mapping-port-changed';
|
const SOURCE_KEY = 'network.stun.mapping-port-changed';
|
||||||
const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
|
const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
|
||||||
const RFC3339_PATTERN =
|
const RFC3339_PATTERN =
|
||||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|([+-])(\d{2}):(\d{2}))$/;
|
||||||
|
|
||||||
type StunSubscriptionConfig = {
|
type StunSubscriptionConfig = {
|
||||||
ddnsRecordId: string;
|
ddnsRecordId: string;
|
||||||
@ -194,8 +194,9 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
valid: true,
|
valid: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!(error instanceof SystemMessageContractError)) throw error;
|
||||||
return {
|
return {
|
||||||
invalidReasonCode: messageSourceErrorCode(error),
|
invalidReasonCode: error.code,
|
||||||
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
valid: false,
|
valid: false,
|
||||||
};
|
};
|
||||||
@ -256,7 +257,7 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): Record<string, SystemMessageScalar> {
|
): Record<string, SystemMessageScalar> {
|
||||||
if (!isPlainRecord(payload)) {
|
if (!isPlainRecord(payload)) {
|
||||||
throw new SystemMessageContractError('invalid_message_event_payload');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
const changedAt = normalizeRfc3339(payload.changedAt);
|
const changedAt = normalizeRfc3339(payload.changedAt);
|
||||||
const currentPort = normalizePort(payload.currentPort);
|
const currentPort = normalizePort(payload.currentPort);
|
||||||
@ -267,7 +268,7 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
isIP(payload.publicIpv4) !== 4 ||
|
isIP(payload.publicIpv4) !== 4 ||
|
||||||
currentPort === previousPort
|
currentPort === previousPort
|
||||||
) {
|
) {
|
||||||
throw new SystemMessageContractError('invalid_message_event_payload');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
changedAt,
|
changedAt,
|
||||||
@ -291,25 +292,27 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
try {
|
try {
|
||||||
resolved = await this.resolveSubscription(input.subscriptionConfig);
|
resolved = await this.resolveSubscription(input.subscriptionConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
|
if (!(error instanceof SystemMessageContractError)) throw error;
|
||||||
|
return { reasonCode: error.code, status: 'cancelled' };
|
||||||
}
|
}
|
||||||
let event: StunEventPayload;
|
let event: StunEventPayload;
|
||||||
try {
|
try {
|
||||||
event = this.validateEventPayload(input.eventPayload) as StunEventPayload;
|
event = this.validateEventPayload(input.eventPayload) as StunEventPayload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
|
if (!(error instanceof SystemMessageContractError)) throw error;
|
||||||
|
return { reasonCode: error.code, status: 'cancelled' };
|
||||||
}
|
}
|
||||||
if (event.portForwardId !== resolved.config.portForwardId) {
|
if (event.portForwardId !== resolved.config.portForwardId) {
|
||||||
return { reasonCode: 'event_resource_mismatch', status: 'cancelled' };
|
return { reasonCode: 'invalid_source_config', status: 'cancelled' };
|
||||||
}
|
}
|
||||||
if (!hasCurrentEndpoint(resolved.mapping)) {
|
if (!hasCurrentEndpoint(resolved.mapping)) {
|
||||||
return { reasonCode: 'endpoint_unavailable', status: 'superseded' };
|
return { reasonCode: 'endpoint_superseded', status: 'superseded' };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
resolved.mapping.currentPublicPort !== event.currentPort ||
|
resolved.mapping.currentPublicPort !== event.currentPort ||
|
||||||
resolved.mapping.currentPublicIpv4 !== event.publicIpv4
|
resolved.mapping.currentPublicIpv4 !== event.publicIpv4
|
||||||
) {
|
) {
|
||||||
return { reasonCode: 'endpoint_changed', status: 'superseded' };
|
return { reasonCode: 'endpoint_superseded', status: 'superseded' };
|
||||||
}
|
}
|
||||||
const variables = deliveryVariables(resolved, event);
|
const variables = deliveryVariables(resolved, event);
|
||||||
if (
|
if (
|
||||||
@ -334,7 +337,7 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
input: unknown,
|
input: unknown,
|
||||||
): Promise<ResolvedSubscription> {
|
): Promise<ResolvedSubscription> {
|
||||||
if (!isPlainRecord(input)) {
|
if (!isPlainRecord(input)) {
|
||||||
throw new SystemMessageContractError('invalid_message_source_config');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
let config: StunSubscriptionConfig;
|
let config: StunSubscriptionConfig;
|
||||||
try {
|
try {
|
||||||
@ -343,24 +346,28 @@ export class NetworkStunMessageSourceAdapter
|
|||||||
portForwardId: normalizeSnowflakeId(input.portForwardId),
|
portForwardId: normalizeSnowflakeId(input.portForwardId),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw new SystemMessageContractError('invalid_message_source_config');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
const mapping = await this.mappingRepository.findOne({
|
const mapping = await this.mappingRepository.findOne({
|
||||||
where: { id: config.portForwardId },
|
where: { id: config.portForwardId },
|
||||||
});
|
});
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
throw new SystemMessageContractError('source_not_found');
|
throw new SystemMessageContractError('mapping_not_found');
|
||||||
}
|
}
|
||||||
const sourceEligibility = classifyStunEndpointSource(mapping);
|
const sourceEligibility = classifyStunEndpointSource(mapping);
|
||||||
if (!sourceEligibility.eligible) {
|
if (!sourceEligibility.eligible) {
|
||||||
throw new SystemMessageContractError(
|
throw new SystemMessageContractError(
|
||||||
sourceEligibility.disabledReasonCode as string,
|
messageSourceEligibilityReason(
|
||||||
|
sourceEligibility.disabledReasonCode as NonNullable<
|
||||||
|
typeof sourceEligibility.disabledReasonCode
|
||||||
|
>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const ddnsRecord = await this.ddnsRepository.findOne({
|
const ddnsRecord = await this.ddnsRepository.findOne({
|
||||||
where: { id: config.ddnsRecordId },
|
where: { id: config.ddnsRecordId },
|
||||||
});
|
});
|
||||||
const ddnsReason = ddnsOptionReason(ddnsRecord, mapping);
|
const ddnsReason = ddnsMessageSourceReason(ddnsRecord, mapping);
|
||||||
if (ddnsReason) {
|
if (ddnsReason) {
|
||||||
throw new SystemMessageContractError(ddnsReason);
|
throw new SystemMessageContractError(ddnsReason);
|
||||||
}
|
}
|
||||||
@ -389,7 +396,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
*/
|
*/
|
||||||
function normalizeSnowflakeId(value: unknown): string {
|
function normalizeSnowflakeId(value: unknown): string {
|
||||||
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
|
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
|
||||||
throw new SystemMessageContractError('invalid_message_source_config');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@ -406,7 +413,7 @@ function normalizePort(value: unknown): number {
|
|||||||
value < 1 ||
|
value < 1 ||
|
||||||
value > 65_535
|
value > 65_535
|
||||||
) {
|
) {
|
||||||
throw new SystemMessageContractError('invalid_message_event_payload');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@ -417,16 +424,74 @@ function normalizePort(value: unknown): number {
|
|||||||
* @returns Canonical ISO string safe for later Shanghai rendering.
|
* @returns Canonical ISO string safe for later Shanghai rendering.
|
||||||
*/
|
*/
|
||||||
function normalizeRfc3339(value: unknown): string {
|
function normalizeRfc3339(value: unknown): string {
|
||||||
if (typeof value !== 'string' || !RFC3339_PATTERN.test(value)) {
|
if (typeof value !== 'string') {
|
||||||
throw new SystemMessageContractError('invalid_message_event_payload');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
|
}
|
||||||
|
const match = RFC3339_PATTERN.exec(value);
|
||||||
|
if (!match || !isValidRfc3339Calendar(match)) {
|
||||||
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new SystemMessageContractError('invalid_message_event_payload');
|
throw new SystemMessageContractError('invalid_source_config');
|
||||||
}
|
}
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates RFC3339 calendar and offset components before JavaScript can normalize them.
|
||||||
|
* @param match - Capture groups produced by the source timestamp pattern.
|
||||||
|
* @returns Whether all calendar, wall-clock, and offset fields are in range.
|
||||||
|
*/
|
||||||
|
function isValidRfc3339Calendar(match: RegExpExecArray): boolean {
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
yearText,
|
||||||
|
monthText,
|
||||||
|
dayText,
|
||||||
|
hourText,
|
||||||
|
minuteText,
|
||||||
|
secondText,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
offsetHourText,
|
||||||
|
offsetMinuteText,
|
||||||
|
] = match;
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const day = Number(dayText);
|
||||||
|
const hour = Number(hourText);
|
||||||
|
const minute = Number(minuteText);
|
||||||
|
const second = Number(secondText);
|
||||||
|
const offsetHour = offsetHourText ? Number(offsetHourText) : 0;
|
||||||
|
const offsetMinute = offsetMinuteText ? Number(offsetMinuteText) : 0;
|
||||||
|
const daysInMonth = [
|
||||||
|
31,
|
||||||
|
year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12 &&
|
||||||
|
day >= 1 &&
|
||||||
|
day <= daysInMonth[month - 1] &&
|
||||||
|
hour <= 23 &&
|
||||||
|
minute <= 59 &&
|
||||||
|
second <= 59 &&
|
||||||
|
offsetHour <= 23 &&
|
||||||
|
offsetMinute <= 59
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether a persisted DDNS record is a usable structural subscription target.
|
* Determines whether a persisted DDNS record is a usable structural subscription target.
|
||||||
* @param record - Candidate A-record binding, possibly absent or deleted.
|
* @param record - Candidate A-record binding, possibly absent or deleted.
|
||||||
@ -450,6 +515,65 @@ function ddnsOptionReason(
|
|||||||
return source.disabledReasonCode;
|
return source.disabledReasonCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a shared Network structural reason to the locked message-source contract.
|
||||||
|
* @param reason - Existing uppercase Network eligibility reason.
|
||||||
|
* @returns Lowercase reason code safe for message subscription views and delivery state.
|
||||||
|
*/
|
||||||
|
function messageSourceEligibilityReason(
|
||||||
|
reason: NonNullable<
|
||||||
|
ReturnType<typeof classifyStunEndpointSource>['disabledReasonCode']
|
||||||
|
>,
|
||||||
|
):
|
||||||
|
| 'keeper_disabled'
|
||||||
|
| 'mapping_not_managed'
|
||||||
|
| 'mapping_not_udp'
|
||||||
|
| 'mapping_port_mismatch' {
|
||||||
|
switch (reason) {
|
||||||
|
case 'KEEPER_DISABLED':
|
||||||
|
return 'keeper_disabled';
|
||||||
|
case 'PORT_MISMATCH':
|
||||||
|
return 'mapping_port_mismatch';
|
||||||
|
case 'SOURCE_DELETING':
|
||||||
|
return 'mapping_not_managed';
|
||||||
|
case 'UDP_REQUIRED':
|
||||||
|
return 'mapping_not_udp';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the locked message-source reason for a DDNS record and its selected mapping.
|
||||||
|
* @param record - Candidate DDNS record, including deleted or absent state.
|
||||||
|
* @param mapping - Canonical selected mapping after its structural validation.
|
||||||
|
* @returns Null when valid or a locked lowercase message-source reason.
|
||||||
|
*/
|
||||||
|
function ddnsMessageSourceReason(
|
||||||
|
record: NetworkDdnsRecord | null | undefined,
|
||||||
|
mapping: NetworkPortForward,
|
||||||
|
):
|
||||||
|
| 'ddns_disabled'
|
||||||
|
| 'ddns_mapping_mismatch'
|
||||||
|
| 'ddns_not_found'
|
||||||
|
| 'ddns_not_ipv4'
|
||||||
|
| 'keeper_disabled'
|
||||||
|
| 'mapping_not_managed'
|
||||||
|
| 'mapping_not_udp'
|
||||||
|
| 'mapping_port_mismatch'
|
||||||
|
| null {
|
||||||
|
if (!record || record.isDeleted) return 'ddns_not_found';
|
||||||
|
if (!record.enabled) return 'ddns_disabled';
|
||||||
|
if (record.recordType !== 'A' || record.sourceType !== 'port_forward_ipv4') {
|
||||||
|
return 'ddns_not_ipv4';
|
||||||
|
}
|
||||||
|
if (String(record.portForwardId) !== String(mapping.id)) {
|
||||||
|
return 'ddns_mapping_mismatch';
|
||||||
|
}
|
||||||
|
const source = classifyStunEndpointSource(mapping);
|
||||||
|
return source.disabledReasonCode
|
||||||
|
? messageSourceEligibilityReason(source.disabledReasonCode)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forms the normalized fully-qualified hostname from persisted server-owned DDNS fields.
|
* Forms the normalized fully-qualified hostname from persisted server-owned DDNS fields.
|
||||||
* @param record - Canonical DDNS binding.
|
* @param record - Canonical DDNS binding.
|
||||||
@ -520,14 +644,3 @@ function formatShanghaiDateTime(value: string): string {
|
|||||||
parts.find((item) => item.type === type)?.value || '';
|
parts.find((item) => item.type === type)?.value || '';
|
||||||
return `${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')}`;
|
return `${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts adapter validation failures to one stable, non-sensitive reason code.
|
|
||||||
* @param error - Unknown caught adapter or repository error.
|
|
||||||
* @returns Domain code for expected contract failures or a generic source-unavailable code.
|
|
||||||
*/
|
|
||||||
function messageSourceErrorCode(error: unknown): string {
|
|
||||||
return error instanceof SystemMessageContractError
|
|
||||||
? error.code
|
|
||||||
: 'message_source_unavailable';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/app
|
|||||||
type Harness = {
|
type Harness = {
|
||||||
adapter: NetworkStunMessageSourceAdapter;
|
adapter: NetworkStunMessageSourceAdapter;
|
||||||
ddns: NetworkDdnsRecord;
|
ddns: NetworkDdnsRecord;
|
||||||
|
ddnsRepository: Repository<NetworkDdnsRecord>;
|
||||||
mapping: NetworkPortForward;
|
mapping: NetworkPortForward;
|
||||||
|
mappingRepository: Repository<NetworkPortForward>;
|
||||||
registry: SystemMessageSourceRegistry;
|
registry: SystemMessageSourceRegistry;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,7 +64,9 @@ function createHarness(): Harness {
|
|||||||
registry,
|
registry,
|
||||||
),
|
),
|
||||||
ddns,
|
ddns,
|
||||||
|
ddnsRepository: recordRepository,
|
||||||
mapping,
|
mapping,
|
||||||
|
mappingRepository,
|
||||||
registry,
|
registry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -81,6 +85,15 @@ function eventPayload(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('NetworkStunMessageSourceAdapter', () => {
|
describe('NetworkStunMessageSourceAdapter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-07-24T12:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('registers once and only unregisters its own source instance', () => {
|
it('registers once and only unregisters its own source instance', () => {
|
||||||
const { adapter, registry } = createHarness();
|
const { adapter, registry } = createHarness();
|
||||||
adapter.onModuleInit();
|
adapter.onModuleInit();
|
||||||
@ -111,43 +124,94 @@ describe('NetworkStunMessageSourceAdapter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['tcp', (harness: Harness) => (harness.mapping.protocol = 'tcp')],
|
[
|
||||||
['unequal ports', (harness: Harness) => (harness.mapping.internalPort = 1)],
|
'tcp',
|
||||||
|
'mapping_not_udp',
|
||||||
|
(harness: Harness) => (harness.mapping.protocol = 'tcp'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unequal ports',
|
||||||
|
'mapping_port_mismatch',
|
||||||
|
(harness: Harness) => (harness.mapping.internalPort = 1),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'disabled keeper',
|
'disabled keeper',
|
||||||
|
'keeper_disabled',
|
||||||
(harness: Harness) => (harness.mapping.keeperDesiredEnabled = false),
|
(harness: Harness) => (harness.mapping.keeperDesiredEnabled = false),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'deleted mapping',
|
'deleted mapping',
|
||||||
|
'mapping_not_managed',
|
||||||
(harness: Harness) => (harness.mapping.isDeleted = true),
|
(harness: Harness) => (harness.mapping.isDeleted = true),
|
||||||
],
|
],
|
||||||
['disabled DDNS', (harness: Harness) => (harness.ddns.enabled = false)],
|
[
|
||||||
['deleted DDNS', (harness: Harness) => (harness.ddns.isDeleted = true)],
|
'disabled DDNS',
|
||||||
['non-A DDNS', (harness: Harness) => (harness.ddns.recordType = 'AAAA')],
|
'ddns_disabled',
|
||||||
|
(harness: Harness) => (harness.ddns.enabled = false),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'deleted DDNS',
|
||||||
|
'ddns_not_found',
|
||||||
|
(harness: Harness) => (harness.ddns.isDeleted = true),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'non-A DDNS',
|
||||||
|
'ddns_not_ipv4',
|
||||||
|
(harness: Harness) => (harness.ddns.recordType = 'AAAA'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'non-port-forward DDNS',
|
||||||
|
'ddns_not_ipv4',
|
||||||
|
(harness: Harness) => (harness.ddns.sourceType = 'agent_ipv6'),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'mismatched DDNS mapping',
|
'mismatched DDNS mapping',
|
||||||
|
'ddns_mapping_mismatch',
|
||||||
(harness: Harness) =>
|
(harness: Harness) =>
|
||||||
(harness.ddns.portForwardId = '2041700000000000003'),
|
(harness.ddns.portForwardId = '2041700000000000003'),
|
||||||
],
|
],
|
||||||
])('rejects %s subscriptions', async (_name, mutate) => {
|
])(
|
||||||
|
'rejects %s subscriptions with the locked %s code',
|
||||||
|
async (_name, code, mutate) => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
mutate(harness);
|
mutate(harness);
|
||||||
await expect(
|
const config = {
|
||||||
harness.adapter.normalizeSubscriptionConfig({
|
|
||||||
ddnsRecordId: harness.ddns.id,
|
ddnsRecordId: harness.ddns.id,
|
||||||
portForwardId: harness.mapping.id,
|
portForwardId: harness.mapping.id,
|
||||||
}),
|
};
|
||||||
).rejects.toThrow();
|
await expect(
|
||||||
|
harness.adapter.normalizeSubscriptionConfig(config),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code,
|
||||||
});
|
});
|
||||||
|
await expect(
|
||||||
|
harness.adapter.inspectSubscription(config),
|
||||||
|
).resolves.toEqual({
|
||||||
|
invalidReasonCode: code,
|
||||||
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('rejects malformed Snowflake IDs and strips unknown event/config fields', async () => {
|
it('rejects malformed subscription/event payloads and strips unknown fields', async () => {
|
||||||
const { adapter } = createHarness();
|
const { adapter } = createHarness();
|
||||||
await expect(
|
await expect(
|
||||||
adapter.normalizeSubscriptionConfig({
|
adapter.normalizeSubscriptionConfig({
|
||||||
ddnsRecordId: 2041700000000000002,
|
ddnsRecordId: 2041700000000000002,
|
||||||
portForwardId: 'not-an-id',
|
portForwardId: 'not-an-id',
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('invalid_message_source_config');
|
).rejects.toMatchObject({ code: 'invalid_source_config' });
|
||||||
|
await expect(
|
||||||
|
adapter.inspectSubscription({
|
||||||
|
ddnsRecordId: 2041700000000000002,
|
||||||
|
portForwardId: 'not-an-id',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
invalidReasonCode: 'invalid_source_config',
|
||||||
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
expect(adapter.validateEventPayload(eventPayload())).toEqual({
|
expect(adapter.validateEventPayload(eventPayload())).toEqual({
|
||||||
changedAt: '2026-07-24T12:30:00.000Z',
|
changedAt: '2026-07-24T12:30:00.000Z',
|
||||||
currentPort: 38213,
|
currentPort: 38213,
|
||||||
@ -157,7 +221,88 @@ describe('NetworkStunMessageSourceAdapter', () => {
|
|||||||
});
|
});
|
||||||
expect(() =>
|
expect(() =>
|
||||||
adapter.validateEventPayload(eventPayload({ currentPort: '38213' })),
|
adapter.validateEventPayload(eventPayload({ currentPort: '38213' })),
|
||||||
).toThrow('invalid_message_event_payload');
|
).toThrow('invalid_source_config');
|
||||||
|
expect(() =>
|
||||||
|
adapter.validateEventPayload(eventPayload({ publicIpv4: '2001:db8::1' })),
|
||||||
|
).toThrow('invalid_source_config');
|
||||||
|
expect(() => adapter.validateEventPayload(null as never)).toThrow(
|
||||||
|
'invalid_source_config',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an absent mapping to the locked subscription and inspection code', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
const config = {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000003',
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
adapter.normalizeSubscriptionConfig(config),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'mapping_not_found',
|
||||||
|
});
|
||||||
|
await expect(adapter.inspectSubscription(config)).resolves.toEqual({
|
||||||
|
invalidReasonCode: 'mapping_not_found',
|
||||||
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an absent DDNS record to the locked subscription and inspection code', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
const config = {
|
||||||
|
ddnsRecordId: '2041700000000000003',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
adapter.normalizeSubscriptionConfig(config),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'ddns_not_found',
|
||||||
|
});
|
||||||
|
await expect(adapter.inspectSubscription(config)).resolves.toEqual({
|
||||||
|
invalidReasonCode: 'ddns_not_found',
|
||||||
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves uppercase Network eligibility reasons in subscription options', async () => {
|
||||||
|
const { adapter, mapping } = createHarness();
|
||||||
|
mapping.protocol = 'tcp';
|
||||||
|
await expect(adapter.listSubscriptionOptions()).resolves.toMatchObject({
|
||||||
|
portForwards: [
|
||||||
|
{
|
||||||
|
disabledReasonCode: 'UDP_REQUIRED',
|
||||||
|
eligible: false,
|
||||||
|
externalPort: 8213,
|
||||||
|
id: mapping.id,
|
||||||
|
internalPort: 8213,
|
||||||
|
name: '帕鲁新世界',
|
||||||
|
protocol: 'tcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['invalid month', '2026-13-01T12:00:00Z'],
|
||||||
|
['non-leap-day', '2026-02-29T12:00:00Z'],
|
||||||
|
['invalid February day', '2026-02-30T12:00:00Z'],
|
||||||
|
['invalid offset', '2026-02-28T12:00:00+24:00'],
|
||||||
|
])('rejects an RFC3339 timestamp with %s', (_name, changedAt) => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
expect(() =>
|
||||||
|
adapter.validateEventPayload(eventPayload({ changedAt })),
|
||||||
|
).toThrow('invalid_source_config');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a leap-day RFC3339 timestamp with an offset', () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
expect(
|
||||||
|
adapter.validateEventPayload(
|
||||||
|
eventPayload({ changedAt: '2024-02-29T23:59:59.123+08:00' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({ changedAt: '2024-02-29T15:59:59.123Z' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns ready variables derived from the server-owned DDNS FQDN and Shanghai time', async () => {
|
it('returns ready variables derived from the server-owned DDNS FQDN and Shanghai time', async () => {
|
||||||
@ -185,7 +330,7 @@ describe('NetworkStunMessageSourceAdapter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for DDNS, supersedes a replaced endpoint, and cancels a changed relationship', async () => {
|
it('waits for DDNS, supersedes replaced or expired endpoints, and cancels a changed relationship', async () => {
|
||||||
const waiting = createHarness();
|
const waiting = createHarness();
|
||||||
waiting.ddns.appliedAddress = null;
|
waiting.ddns.appliedAddress = null;
|
||||||
await expect(
|
await expect(
|
||||||
@ -211,7 +356,25 @@ describe('NetworkStunMessageSourceAdapter', () => {
|
|||||||
portForwardId: superseded.mapping.id,
|
portForwardId: superseded.mapping.id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({ status: 'superseded' });
|
).resolves.toMatchObject({
|
||||||
|
reasonCode: 'endpoint_superseded',
|
||||||
|
status: 'superseded',
|
||||||
|
});
|
||||||
|
|
||||||
|
const expired = createHarness();
|
||||||
|
expired.mapping.currentValidUntil = new Date('2026-07-24T11:59:59.999Z');
|
||||||
|
await expect(
|
||||||
|
expired.adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: expired.ddns.id,
|
||||||
|
portForwardId: expired.mapping.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
reasonCode: 'endpoint_superseded',
|
||||||
|
status: 'superseded',
|
||||||
|
});
|
||||||
|
|
||||||
const cancelled = createHarness();
|
const cancelled = createHarness();
|
||||||
cancelled.ddns.enabled = false;
|
cancelled.ddns.enabled = false;
|
||||||
@ -223,6 +386,62 @@ describe('NetworkStunMessageSourceAdapter', () => {
|
|||||||
portForwardId: cancelled.mapping.id,
|
portForwardId: cancelled.mapping.id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({ status: 'cancelled' });
|
).resolves.toMatchObject({
|
||||||
|
reasonCode: 'ddns_disabled',
|
||||||
|
status: 'cancelled',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels event mapping identity mismatches with invalid_source_config', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
await expect(
|
||||||
|
adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload({ portForwardId: '2041700000000000003' }),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
reasonCode: 'invalid_source_config',
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels malformed event payloads with invalid_source_config', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
await expect(
|
||||||
|
adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload({ currentPort: '38213' }),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
reasonCode: 'invalid_source_config',
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['mapping', 'mappingRepository'],
|
||||||
|
['DDNS', 'ddnsRepository'],
|
||||||
|
] as const)(
|
||||||
|
'rethrows an unexpected %s repository error for delivery retry',
|
||||||
|
async (_name, repository) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
const failure = new Error(`${repository} unavailable`);
|
||||||
|
jest.spyOn(harness[repository], 'findOne').mockRejectedValueOnce(failure);
|
||||||
|
await expect(
|
||||||
|
harness.adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: harness.ddns.id,
|
||||||
|
portForwardId: harness.mapping.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user