import { HttpException } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { Repository } from 'typeorm'; import { KtDateTime } from '../../../src/common'; import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity'; import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service'; import { NetworkDnsPodClient, NetworkDnsPodClientError, } from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client'; import type { NetworkManagementEventStreamService } from '../../../src/modules/admin/platform-config/network-management/network-management-event-stream.service'; import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; type Harness = { client: jest.Mocked>; deliveryCoordinator: { notifyDdnsSynced: jest.Mock }; mapping: NetworkPortForward; recordUpdate: jest.Mock; records: NetworkDdnsRecord[]; service: NetworkDdnsService; state: NetworkAgentState; }; function cloneRecord(record: NetworkDdnsRecord): NetworkDdnsRecord { return Object.assign(new NetworkDdnsRecord(), record); } function matchesUpdateCriterion(actual: unknown, expected: unknown): boolean { if ( expected && typeof expected === 'object' && (expected as { _type?: string })._type === 'isNull' ) { return actual === null || actual === undefined; } if (actual instanceof Date && expected instanceof Date) { return actual.getTime() === expected.getTime(); } return actual === expected; } function createHarness(): Harness { const records: NetworkDdnsRecord[] = []; const mapping = Object.assign(new NetworkPortForward(), { currentObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'), currentPublicIpv4: '8.8.8.8', currentPublicPort: 45_678, currentValidUntil: new KtDateTime('2026-07-23T02:00:00.000Z'), desiredPresence: 'present', externalPort: 45_678, id: '100', internalPort: 45_678, isDeleted: false, keeperDesiredEnabled: true, name: 'Public UDP', protocol: 'udp', }); const state = Object.assign(new NetworkAgentState(), { agentId: 'nas-main', currentIpv6ObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'), currentPublicIpv6: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e', lastHeartbeatAt: new KtDateTime('2026-07-23T01:00:00.000Z'), online: true, }); const recordUpdate = jest.fn( async ( criteria: Record, patch: Partial, ) => { const index = records.findIndex((record) => Object.entries(criteria).every(([key, expected]) => matchesUpdateCriterion( (record as unknown as Record)[key], expected, ), ), ); if (index < 0) { return { affected: 0, generatedMaps: [], raw: [] }; } records[index] = Object.assign( new NetworkDdnsRecord(), records[index], patch, ); return { affected: 1, generatedMaps: [], raw: [] }; }, ); const recordRepository = { count: async () => records.filter((record) => !record.isDeleted).length, create: (input) => Object.assign( new NetworkDdnsRecord(), { id: String(200 + records.length) }, input, ), createQueryBuilder: () => createListBuilder(records), find: async ({ where } = {} as any) => records .filter((record) => Object.entries(where || {}).every( ([key, value]) => record[key] === value, ), ) .map(cloneRecord), findOne: async ({ where }) => { const record = records.find((item) => Object.entries(where).every(([key, value]) => item[key] === value), ); return record ? cloneRecord(record) : null; }, save: async (record) => { const now = new KtDateTime(); record.createTime ||= now; record.updateTime = now; const index = records.findIndex((item) => item.id === record.id); if (index >= 0) records[index] = cloneRecord(record); else records.push(cloneRecord(record)); return record; }, update: recordUpdate, } as unknown as Repository; const mappingRepository = { find: async () => [mapping], findOne: async ({ where }) => where.id === mapping.id && !mapping.isDeleted ? mapping : null, } as unknown as Repository; const stateRepository = { findOne: async () => state, } as unknown as Repository; const config = { get: (key: string) => { const values = { NETWORK_AGENT_ID: 'nas-main', NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS: '60000', NETWORK_DDNS_RECONCILE_INTERVAL_MS: '60000', }; return values[key]; }, } as ConfigService; const client = { getStatus: jest.fn(() => ({ configured: true, enabled: true, provider: 'dnspod' as const, })), reconcile: jest.fn(), }; const eventStream = { publishCommitted: jest.fn(), } as unknown as NetworkManagementEventStreamService; const deliveryCoordinator = { notifyDdnsSynced: jest.fn().mockResolvedValue(undefined), requestDrain: jest.fn(), }; const service = new NetworkDdnsService( recordRepository, mappingRepository, stateRepository, config, client as unknown as NetworkDnsPodClient, eventStream, deliveryCoordinator, ); return { client, deliveryCoordinator, mapping, recordUpdate, records, service, state, }; } async function prepareEnabledA(harness: Harness): Promise { await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; } function createListBuilder(records: NetworkDdnsRecord[]) { const builder = { andWhere: () => builder, getManyAndCount: async () => [ records.filter((record) => !record.isDeleted), records.filter((record) => !record.isDeleted).length, ], orderBy: () => builder, skip: () => builder, take: () => builder, where: () => builder, }; return builder; } function errorStatus(error: unknown): number { return error instanceof HttpException ? error.getStatus() : 0; } describe('NetworkDdnsService', () => { beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-07-23T01:00:30.000Z')); }); afterEach(() => { jest.useRealTimers(); }); it('returns server-classified IPv4 and Agent IPv6 source options', async () => { await expect( createHarness().service.sourceOptions({ recordType: 'A' }), ).resolves.toEqual([ expect.objectContaining({ currentAddress: '8.8.8.8', eligible: true, id: '100', sourceType: 'port_forward_ipv4', }), ]); await expect( createHarness().service.sourceOptions({ recordType: 'AAAA' }), ).resolves.toEqual([ expect.objectContaining({ currentAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e', eligible: true, id: 'agent-ipv6', sourceType: 'agent_ipv6', }), ]); }); it('never exposes a residual lease from an ineligible IPv4 source', async () => { const harness = createHarness(); harness.mapping.keeperDesiredEnabled = false; await expect( harness.service.sourceOptions({ recordType: 'A' }), ).resolves.toEqual([ expect.objectContaining({ currentAddress: null, disabledReasonCode: 'KEEPER_DISABLED', eligible: false, observedAt: null, validUntil: null, }), ]); }); it('normalizes one disabled A binding without exposing provider identity input', async () => { const harness = createHarness(); await expect( harness.service.create({ domain: ' KWITSUKASA.TOP. ', enabled: false, name: ' Pal A ', portForwardId: '100', recordType: 'A', remark: ' game ', sourceType: 'port_forward_ipv4', subDomain: ' PAL ', }), ).resolves.toMatchObject({ domain: 'kwitsukasa.top', enabled: false, fqdn: 'pal.kwitsukasa.top', name: 'Pal A', recordType: 'A', sourceType: 'port_forward_ipv4', syncStatus: 'disabled', }); expect(harness.records[0].providerRecordId).toBeNull(); expect(harness.client.reconcile).not.toHaveBeenCalled(); }); it('rejects record/source family mismatches before persistence', async () => { const harness = createHarness(); await harness.service .create({ domain: 'kwitsukasa.top', enabled: false, name: 'bad', portForwardId: '100', recordType: 'AAAA', sourceType: 'port_forward_ipv4', subDomain: 'nas6', }) .catch((error) => expect(errorStatus(error)).toBe(400)); expect(harness.records).toHaveLength(0); }); it('reconciles A and AAAA addresses without ever using a port', async () => { const harness = createHarness(); harness.client.reconcile .mockResolvedValueOnce({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }) .mockResolvedValueOnce({ appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e', changed: false, providerRecordId: '301', }); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'NAS AAAA', recordType: 'AAAA', sourceType: 'agent_ipv6', subDomain: 'nas6', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; harness.records[1].enabled = true; harness.records[1].syncStatus = 'pending'; await harness.service.reconcileNow(undefined, true); expect(harness.client.reconcile).toHaveBeenNthCalledWith( 1, expect.objectContaining({ domain: 'kwitsukasa.top', recordType: 'A', subDomain: 'pal', targetAddress: '8.8.8.8', }), ); expect(harness.client.reconcile).toHaveBeenNthCalledWith( 2, expect.objectContaining({ recordType: 'AAAA', targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e', }), ); expect(JSON.stringify(harness.client.reconcile.mock.calls)).not.toContain( '45678', ); expect(harness.records.map((record) => record.syncStatus)).toEqual([ 'synced', 'synced', ]); expect(harness.deliveryCoordinator.notifyDdnsSynced).toHaveBeenCalledTimes( 2, ); expect(harness.deliveryCoordinator.notifyDdnsSynced).toHaveBeenCalledWith( expect.objectContaining({ appliedAddress: '8.8.8.8', ddnsRecordId: '200', }), ); }); it('notifies exactly once only after the final guarded synced update commits', async () => { const harness = createHarness(); let providerStarted!: () => void; let resolveProvider!: (result: { appliedAddress: string; changed: boolean; providerRecordId: string; }) => void; const started = new Promise((resolve) => { providerStarted = resolve; }); const providerResult = new Promise<{ appliedAddress: string; changed: boolean; providerRecordId: string; }>((resolve) => { resolveProvider = resolve; }); harness.client.reconcile.mockImplementation(async () => { providerStarted(); return providerResult; }); await prepareEnabledA(harness); const reconcile = harness.service.reconcileNow('200', true); await started; expect(harness.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); resolveProvider({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await reconcile; await Promise.resolve(); expect(harness.recordUpdate).toHaveBeenCalledTimes(2); expect(harness.records[0]).toMatchObject({ appliedAddress: '8.8.8.8', syncStatus: 'synced', }); expect(harness.deliveryCoordinator.notifyDdnsSynced).toHaveBeenCalledTimes( 1, ); expect(harness.deliveryCoordinator.notifyDdnsSynced).toHaveBeenCalledWith({ appliedAddress: '8.8.8.8', ddnsRecordId: '200', }); expect(harness.recordUpdate.mock.invocationCallOrder[1]).toBeLessThan( harness.deliveryCoordinator.notifyDdnsSynced.mock.invocationCallOrder[0], ); }); it('does not notify when the final guarded synced update affects zero rows', async () => { const harness = createHarness(); const update = harness.recordUpdate.getMockImplementation()!; harness.recordUpdate .mockImplementationOnce(update) .mockResolvedValueOnce({ affected: 0, generatedMaps: [], raw: [] }); harness.client.reconcile.mockResolvedValue({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await prepareEnabledA(harness); await harness.service.reconcileNow('200', true); await Promise.resolve(); expect(harness.recordUpdate).toHaveBeenCalledTimes(2); expect(harness.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); expect(harness.records[0].syncStatus).toBe('syncing'); }); it('does not notify on pre-provider stale CAS, provider failure, disabled reread, or synced no-op', async () => { const stale = createHarness(); stale.recordUpdate.mockResolvedValueOnce({ affected: 0, generatedMaps: [], raw: [], }); await prepareEnabledA(stale); await stale.service.reconcileNow('200', true); expect(stale.client.reconcile).not.toHaveBeenCalled(); expect(stale.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); const failed = createHarness(); failed.client.reconcile.mockRejectedValueOnce( new NetworkDnsPodClientError('DNSPOD_RATE_LIMITED', 'rate limited', true), ); await prepareEnabledA(failed); await failed.service.reconcileNow('200', true); expect(failed.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); const disabled = createHarness(); let providerStarted!: () => void; let resolveProvider!: (result: { appliedAddress: string; changed: boolean; providerRecordId: string; }) => void; const started = new Promise((resolve) => { providerStarted = resolve; }); disabled.client.reconcile.mockImplementation( () => new Promise((resolve) => { resolveProvider = resolve; providerStarted(); }), ); await prepareEnabledA(disabled); const disabledRun = disabled.service.reconcileNow('200', true); await started; disabled.records[0].enabled = false; resolveProvider({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await disabledRun; expect( disabled.deliveryCoordinator.notifyDdnsSynced, ).not.toHaveBeenCalled(); const synced = createHarness(); await prepareEnabledA(synced); synced.records[0].appliedAddress = '8.8.8.8'; synced.records[0].providerRecordId = '300'; synced.records[0].sourceAddress = '8.8.8.8'; synced.records[0].syncStatus = 'synced'; await synced.service.reconcileNow('200'); expect(synced.client.reconcile).not.toHaveBeenCalled(); expect(synced.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); }); it('logs notification rejection without changing authoritative DDNS success', async () => { const harness = createHarness(); const wakeFailure = new Error('wake unavailable'); harness.deliveryCoordinator.notifyDdnsSynced.mockRejectedValueOnce( wakeFailure, ); const loggerWarn = jest .spyOn((harness.service as any).logger, 'warn') .mockImplementation(); harness.client.reconcile.mockResolvedValue({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await prepareEnabledA(harness); await expect( harness.service.reconcileNow('200', true), ).resolves.toBeUndefined(); await Promise.resolve(); expect(harness.records[0]).toMatchObject({ appliedAddress: '8.8.8.8', providerRecordId: '300', syncStatus: 'synced', }); expect(loggerWarn).toHaveBeenCalledWith( 'System message delivery wake failed after DDNS sync', ); }); it('waits without calling DNSPod when the IPv6 source is stale', async () => { const harness = createHarness(); harness.state.currentIpv6ObservedAt = new KtDateTime( '2026-07-23T00:00:00.000Z', ); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'NAS AAAA', recordType: 'AAAA', sourceType: 'agent_ipv6', subDomain: 'nas6', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; await harness.service.reconcileNow('200', true); expect(harness.records[0]).toMatchObject({ appliedAddress: null, sourceAddress: null, syncStatus: 'waiting_source', }); expect(harness.client.reconcile).not.toHaveBeenCalled(); expect(harness.deliveryCoordinator.notifyDdnsSynced).not.toHaveBeenCalled(); }); it('waits without calling the provider when an IPv4 source keeps an ineligible residual lease', async () => { const harness = createHarness(); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; harness.mapping.keeperDesiredEnabled = false; await harness.service.reconcileNow('200', true); expect(harness.records[0]).toMatchObject({ sourceAddress: null, syncStatus: 'waiting_source', }); expect(harness.client.reconcile).not.toHaveBeenCalled(); }); it('queues a fresh reconcile when the source changes during provider I/O', async () => { const harness = createHarness(); let providerStarted!: () => void; let resolveFirst!: (result: { appliedAddress: string; changed: boolean; providerRecordId: string; }) => void; const started = new Promise((resolve) => { providerStarted = resolve; }); const firstResult = new Promise<{ appliedAddress: string; changed: boolean; providerRecordId: string; }>((resolve) => { resolveFirst = resolve; }); harness.client.reconcile .mockImplementationOnce(() => { providerStarted(); return firstResult; }) .mockResolvedValueOnce({ appliedAddress: '9.9.9.9', changed: true, providerRecordId: '300', }); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; const firstReconcile = harness.service.reconcileNow('200', true); await started; harness.mapping.currentPublicIpv4 = '9.9.9.9'; resolveFirst({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await firstReconcile; await harness.service.reconcileNow('200'); expect(harness.client.reconcile).toHaveBeenCalledTimes(2); expect(harness.client.reconcile.mock.calls.map(([input]) => input)).toEqual( [ expect.objectContaining({ targetAddress: '8.8.8.8' }), expect.objectContaining({ targetAddress: '9.9.9.9' }), ], ); expect(harness.records[0]).toMatchObject({ appliedAddress: '9.9.9.9', sourceAddress: '9.9.9.9', syncStatus: 'synced', }); }); it('drops a stale CAS write and reconciles only the newly committed DNS identity', async () => { const harness = createHarness(); harness.client.reconcile.mockResolvedValue({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; harness.recordUpdate.mockImplementationOnce(async () => { harness.records[0].activeKey = 'a:pal2.kwitsukasa.top'; harness.records[0].subDomain = 'pal2'; harness.records[0].syncStatus = 'pending'; harness.records[0].updateTime = new KtDateTime( harness.records[0].updateTime.getTime() + 10, ); return { affected: 0, generatedMaps: [], raw: [] }; }); await harness.service.reconcileNow('200', true); await harness.service.reconcileNow('200'); expect(harness.client.reconcile).toHaveBeenCalledTimes(1); expect(harness.client.reconcile).toHaveBeenCalledWith( expect.objectContaining({ subDomain: 'pal2' }), ); expect(harness.records[0]).toMatchObject({ appliedAddress: '8.8.8.8', subDomain: 'pal2', syncStatus: 'synced', }); }); it('serializes a concurrent delete behind provider I/O without resurrecting the row', async () => { const harness = createHarness(); let providerStarted!: () => void; let resolveProvider!: (result: { appliedAddress: string; changed: boolean; providerRecordId: string; }) => void; const started = new Promise((resolve) => { providerStarted = resolve; }); const providerResult = new Promise<{ appliedAddress: string; changed: boolean; providerRecordId: string; }>((resolve) => { resolveProvider = resolve; }); harness.client.reconcile.mockImplementation(() => { providerStarted(); return providerResult; }); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; const reconcile = harness.service.reconcileNow('200', true); await started; let deleteSettled = false; const remove = harness.service.remove('200').then((result) => { deleteSettled = true; return result; }); await Promise.resolve(); expect(deleteSettled).toBe(false); resolveProvider({ appliedAddress: '8.8.8.8', changed: true, providerRecordId: '300', }); await reconcile; await remove; expect(harness.records[0]).toMatchObject({ activeKey: null, enabled: false, isDeleted: true, syncStatus: 'disabled', }); expect(harness.client.reconcile).toHaveBeenCalledTimes(1); }); it('persists a safe bounded retry after a retryable provider failure', async () => { const harness = createHarness(); harness.client.reconcile.mockRejectedValue( new NetworkDnsPodClientError( 'DNSPOD_RATE_LIMITED', 'DNSPod 请求受限,请稍后重试', true, ), ); await harness.service.create({ domain: 'kwitsukasa.top', enabled: false, name: 'Pal A', portForwardId: '100', recordType: 'A', sourceType: 'port_forward_ipv4', subDomain: 'pal', }); harness.records[0].enabled = true; harness.records[0].syncStatus = 'pending'; await harness.service.reconcileNow('200', true); expect(harness.records[0]).toMatchObject({ lastErrorCode: 'provider_rate_limited', retryCount: 1, sourceAddress: '8.8.8.8', syncStatus: 'failed', }); expect(harness.records[0].nextRetryAt).toBeInstanceOf(Date); expect(JSON.stringify(harness.records[0])).not.toMatch( /secret|credential|raw provider/i, ); }); });