kt-template-online-api/test/admin/network-management/network-dnspod.client.spec.ts

564 lines
15 KiB
TypeScript

import type { ConfigService } from '@nestjs/config';
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
import {
NetworkDnsPodClient,
NetworkDnsPodClientError,
type NetworkDnsPodReconcileInput,
} from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
type DescribeRecordFilterListRequest = {
Domain: string;
IsExactSubDomain: boolean;
Limit: number;
Offset: number;
RecordLine: string[];
RecordType: string[];
SubDomain: string;
};
type ModifyDynamicDNSRequest = {
Domain: string;
RecordId: number;
RecordLine: string;
RecordLineId: string;
SubDomain: string;
Ttl: number;
Value: string;
};
type MockRecord = {
Line?: string;
LineId?: string;
Name?: string;
RecordId?: number;
Status?: string;
TTL?: number;
Type?: string;
Value?: string;
};
type MockDnsPodSdkClient = {
DescribeRecordFilterList: jest.Mock<
Promise<{ RecordList?: MockRecord[] }>,
[DescribeRecordFilterListRequest]
>;
ModifyDynamicDNS: jest.Mock<
Promise<Record<string, never>>,
[ModifyDynamicDNSRequest]
>;
};
const enabledConfig = {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: 'test-secret-id',
NETWORK_DDNS_DNSPOD_SECRET_KEY: 'test-secret-key',
};
function createConfig(
values: Record<string, string | undefined>,
): ConfigService {
return {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
}
function createSdkClient(): MockDnsPodSdkClient {
return {
DescribeRecordFilterList: jest.fn(),
ModifyDynamicDNS: jest.fn().mockResolvedValue({}),
};
}
function createRecord(overrides: MockRecord = {}): MockRecord {
return {
Line: '默认',
LineId: '0',
Name: 'nas',
RecordId: 123,
Status: 'ENABLE',
TTL: 600,
Type: 'A',
Value: '198.51.100.10',
...overrides,
};
}
function createClient(
sdkClient = createSdkClient(),
values: Record<string, string | undefined> = enabledConfig,
) {
const factory = jest.fn<MockDnsPodSdkClient, [ClientConfig]>(() => sdkClient);
return {
client: new NetworkDnsPodClient(createConfig(values), factory),
factory,
sdkClient,
};
}
const ipv4Input: NetworkDnsPodReconcileInput = {
domain: 'kwitsukasa.top',
recordType: 'A',
subDomain: 'nas',
targetAddress: '198.51.100.10',
};
describe('NetworkDnsPodClient', () => {
it('reports disabled or missing credentials without creating an SDK client', async () => {
const disabled = createClient(createSdkClient(), {
...enabledConfig,
NETWORK_DDNS_DNSPOD_ENABLED: 'false',
});
expect(disabled.client.getStatus()).toEqual({
configured: true,
enabled: false,
provider: 'dnspod',
});
await expect(disabled.client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_DISABLED',
retryable: false,
});
expect(disabled.factory).not.toHaveBeenCalled();
const missing = createClient(createSdkClient(), {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: '',
NETWORK_DDNS_DNSPOD_SECRET_KEY: undefined,
});
expect(missing.client.getStatus()).toEqual({
configured: false,
enabled: true,
provider: 'dnspod',
});
await expect(missing.client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_NOT_CONFIGURED',
retryable: false,
});
expect(missing.factory).not.toHaveBeenCalled();
});
it('uses the exact root-host IPv4 list request and a bounded SDK profile', async () => {
const { client, factory, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [
createRecord({
Name: '@',
Value: '198.51.100.10',
}),
],
});
await expect(
client.reconcile({
...ipv4Input,
subDomain: '@',
}),
).resolves.toEqual({
appliedAddress: '198.51.100.10',
changed: false,
providerRecordId: '123',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
IsExactSubDomain: true,
Limit: 2,
Offset: 0,
RecordLine: ['0'],
RecordType: ['A'],
SubDomain: '@',
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
expect(factory).toHaveBeenCalledTimes(1);
const config = factory.mock.calls[0][0];
expect(config.region).toBeUndefined();
expect(config.profile?.httpProfile).toEqual({
endpoint: 'dnspod.tencentcloudapi.com',
reqTimeout: 10,
});
});
it('uses the exact normal-host IPv6 list request and canonicalizes equality', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:05e1:6020:a5ea:838e:843f:be5e',
}),
],
});
await expect(
client.reconcile({
domain: 'kwitsukasa.top',
recordType: 'AAAA',
subDomain: 'nas6',
targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
}),
).resolves.toEqual({
appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
changed: false,
providerRecordId: '123',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
IsExactSubDomain: true,
Limit: 2,
Offset: 0,
RecordLine: ['0'],
RecordType: ['AAAA'],
SubDomain: 'nas6',
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
});
it('updates IPv4 without changing record metadata and verifies the read-back', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
RecordList: [
createRecord({
LineId: '10=1',
Value: '198.51.100.9',
}),
],
}).mockResolvedValueOnce({
RecordList: [
createRecord({
LineId: '10=1',
Value: '198.51.100.10',
}),
],
});
await expect(
client.reconcile({
...ipv4Input,
expectedRecordId: '123',
}),
).resolves.toEqual({
appliedAddress: '198.51.100.10',
changed: true,
providerRecordId: '123',
});
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledTimes(1);
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
RecordId: 123,
RecordLine: '默认',
RecordLineId: '10=1',
SubDomain: 'nas',
Ttl: 600,
Value: '198.51.100.10',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledTimes(2);
});
it('updates IPv6 without changing record metadata and verifies the read-back', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:5e1:6020::1',
}),
],
}).mockResolvedValueOnce({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:5e1:6020::2',
}),
],
});
await expect(
client.reconcile({
domain: 'kwitsukasa.top',
expectedRecordId: '123',
recordType: 'AAAA',
subDomain: 'nas6',
targetAddress: '2409:8a31:5e1:6020::2',
}),
).resolves.toEqual({
appliedAddress: '2409:8a31:5e1:6020::2',
changed: true,
providerRecordId: '123',
});
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
RecordId: 123,
RecordLine: '默认',
RecordLineId: '0',
SubDomain: 'nas6',
Ttl: 600,
Value: '2409:8a31:5e1:6020::2',
});
});
it.each([
['missing record', [], 'DNSPOD_RECORD_NOT_FOUND'],
[
'ambiguous records',
[createRecord(), createRecord({ RecordId: 124 })],
'DNSPOD_RECORD_AMBIGUOUS',
],
[
'disabled record',
[createRecord({ Status: 'DISABLE' })],
'DNSPOD_RECORD_DISABLED',
],
[
'wrong record type',
[createRecord({ Type: 'CNAME' })],
'DNSPOD_RECORD_INVALID',
],
['wrong line', [createRecord({ Line: '境外' })], 'DNSPOD_RECORD_INVALID'],
[
'unsafe record ID',
[createRecord({ RecordId: Number.MAX_SAFE_INTEGER + 1 })],
'DNSPOD_RECORD_INVALID',
],
[
'different expected record ID',
[createRecord({ RecordId: 124 })],
'DNSPOD_RECORD_MISMATCH',
],
[
'wrong address family',
[createRecord({ Value: '2409:8a31:5e1:6020::1' })],
'DNSPOD_RECORD_INVALID',
],
])('fails closed for %s', async (_name, records, code) => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: records,
});
await expect(
client.reconcile({
...ipv4Input,
expectedRecordId: code === 'DNSPOD_RECORD_MISMATCH' ? '123' : undefined,
}),
).rejects.toMatchObject({
code,
retryable: false,
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
});
it.each([
[{ ...ipv4Input, domain: 'https://kwitsukasa.top' }],
[{ ...ipv4Input, domain: 'kwitsukasa.top:53' }],
[{ ...ipv4Input, subDomain: '' }],
[{ ...ipv4Input, subDomain: '*.nas' }],
[{ ...ipv4Input, targetAddress: '198.51.100.10:8211' }],
[
{
...ipv4Input,
recordType: 'A' as const,
targetAddress: '2409:8a31:5e1:6020::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '198.51.100.10',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'fe80::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'fd00::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'ff02::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::ffff:198.51.100.10',
},
],
[{ ...ipv4Input, expectedRecordId: '9007199254740992' }],
[
{
...ipv4Input,
expectedRecordId: 123 as unknown as string,
},
],
])(
'rejects invalid input before creating the SDK client: %j',
async (input) => {
const { client, factory } = createClient();
await expect(client.reconcile(input)).rejects.toMatchObject({
code: 'DNSPOD_INVALID_INPUT',
retryable: false,
});
expect(factory).not.toHaveBeenCalled();
},
);
it.each([
[
{ code: 'RequestLimitExceeded', message: 'raw' },
'DNSPOD_RATE_LIMITED',
'DNSPod provider request was rate limited',
true,
],
[
{ code: 'InternalError', message: 'raw' },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ code: 'ETIMEDOUT', message: 'raw' },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ message: 'raw', statusCode: 503 },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ code: 'AuthFailure.SecretIdNotFound', message: 'raw' },
'DNSPOD_AUTH_FAILED',
'DNSPod provider authentication failed',
false,
],
[
{ code: 'UnauthorizedOperation', message: 'raw' },
'DNSPOD_PERMISSION_DENIED',
'DNSPod provider permission was denied',
false,
],
[
{ code: 'AuthFailure.UnauthorizedOperation', message: 'raw' },
'DNSPOD_PERMISSION_DENIED',
'DNSPod provider permission was denied',
false,
],
])(
'maps one SDK failure to redacted stable code %s',
async (error, code, message, retryable) => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockRejectedValue(error);
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
code,
message,
retryable,
});
},
);
it('maps uncategorized permanent SDK failures to one redacted stable error', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockRejectedValue({
code: 'InvalidParameter',
message: 'the raw provider rejection must stay private',
requestId: 'provider-request-id',
});
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_PROVIDER_REJECTED',
message: 'DNSPod provider request was rejected',
retryable: false,
});
});
it('keeps serialized failures and results free of credentials, provider details, FQDN, and ports', async () => {
const secretId = 'sensitive-secret-id';
const secretKey = 'sensitive-secret-key';
const domain = 'private.example.com';
const subDomain = 'hidden';
const rawMessage = `provider leaked ${secretId} ${secretKey} ${subDomain}.${domain} 198.51.100.10:8211`;
const failing = createClient(createSdkClient(), {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: secretId,
NETWORK_DDNS_DNSPOD_SECRET_KEY: secretKey,
});
failing.sdkClient.DescribeRecordFilterList.mockRejectedValue({
code: 'AuthFailure',
message: rawMessage,
});
let caught: unknown;
try {
await failing.client.reconcile({
domain,
recordType: 'A',
subDomain,
targetAddress: '198.51.100.10',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(NetworkDnsPodClientError);
const serializedError = JSON.stringify({
...(caught as NetworkDnsPodClientError),
message: (caught as NetworkDnsPodClientError).message,
});
for (const forbidden of [
secretId,
secretKey,
rawMessage,
`${subDomain}.${domain}`,
'198.51.100.10:8211',
]) {
expect(serializedError).not.toContain(forbidden);
}
const successful = createClient();
successful.sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [createRecord()],
});
const serializedResult = JSON.stringify(
await successful.client.reconcile(ipv4Input),
);
expect(serializedResult).not.toContain('test-secret-id');
expect(serializedResult).not.toContain('test-secret-key');
expect(serializedResult).not.toContain('nas.kwitsukasa.top');
expect(serializedResult).not.toContain('198.51.100.10:8211');
});
});