@aiti-ai/ddns-mocks 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ /** aliyun mock — in-memory alidns.aliyuncs.com RPC endpoint. Verifies the
2
+ * HMAC-SHA1 signature (when a secret is given) exactly like the real API,
3
+ * so signature bugs surface in tests. */
4
+ export interface AliyunMockOptions {
5
+ accessKeyId?: string;
6
+ /** Fixed listen port (default: kernel-assigned free port). */
7
+ port?: number;
8
+ accessKeySecret?: string;
9
+ records?: Array<{
10
+ rr: string;
11
+ type: string;
12
+ value: string;
13
+ ttl?: number;
14
+ }>;
15
+ zone: string;
16
+ }
17
+ export interface AliyunMockState {
18
+ records: Array<{
19
+ recordId: string;
20
+ rr: string;
21
+ type: string;
22
+ value: string;
23
+ ttl: number;
24
+ }>;
25
+ requests: Array<{
26
+ action: string;
27
+ params: URLSearchParams;
28
+ signatureValid: boolean | null;
29
+ }>;
30
+ }
31
+ export interface AliyunMockHandle {
32
+ url: string;
33
+ state: AliyunMockState;
34
+ stop(): Promise<void>;
35
+ }
36
+ export declare function startAliyunMock(opts: AliyunMockOptions): Promise<AliyunMockHandle>;
package/dist/aliyun.js ADDED
@@ -0,0 +1,103 @@
1
+ /** aliyun mock — in-memory alidns.aliyuncs.com RPC endpoint. Verifies the
2
+ * HMAC-SHA1 signature (when a secret is given) exactly like the real API,
3
+ * so signature bugs surface in tests. */
4
+ import { createServer } from "node:http";
5
+ import { randomUUID, createHmac } from "node:crypto";
6
+ // ── aliyun 签名链(内联自 ddns core,mock 钉死真实 wire 行为)──
7
+ function aliyunPercent(s) {
8
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
9
+ }
10
+ function aliyunCanonicalQuery(params) {
11
+ return Object.entries(params)
12
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
13
+ .map(([k, v]) => `${aliyunPercent(k)}=${aliyunPercent(v)}`)
14
+ .join("&");
15
+ }
16
+ function aliyunStringToSign(params) {
17
+ return `GET&%2F&${aliyunPercent(aliyunCanonicalQuery(params))}`;
18
+ }
19
+ function aliyunSign(accessKeySecret, params) {
20
+ return createHmac("sha1", `${accessKeySecret}&`).update(aliyunStringToSign(params)).digest("base64");
21
+ }
22
+ export async function startAliyunMock(opts) {
23
+ const keyId = opts.accessKeyId ?? "AKID";
24
+ const keySecret = opts.accessKeySecret;
25
+ const state = {
26
+ records: (opts.records ?? []).map((r) => ({ recordId: randomUUID(), ttl: r.ttl ?? 600, ...r })),
27
+ requests: [],
28
+ };
29
+ const json = (res, status, body) => {
30
+ res.writeHead(status, { "content-type": "application/json" });
31
+ res.end(JSON.stringify(body));
32
+ };
33
+ const fail = (res, code, message) => json(res, 400, { Code: code, Message: message, RequestId: randomUUID() });
34
+ const server = createServer((req, res) => {
35
+ const url = new URL(req.url ?? "/", "http://localhost");
36
+ const params = new URLSearchParams(url.search);
37
+ const action = params.get("Action") ?? "";
38
+ const signature = params.get("Signature") ?? "";
39
+ // signature verification (mirrors the real gateway): recompute over the
40
+ // canonical query of every param EXCEPT Signature
41
+ let signatureValid = null;
42
+ if (keySecret && signature) {
43
+ const rest = Object.fromEntries([...params.entries()].filter(([k]) => k !== "Signature"));
44
+ signatureValid = aliyunSign(keySecret, rest) === signature;
45
+ if (!signatureValid)
46
+ return fail(res, "SignatureDoesNotMatch", "Specified signature is not matched with our calculation.");
47
+ }
48
+ state.requests.push({ action, params, signatureValid });
49
+ switch (action) {
50
+ case "DescribeDomainRecords": {
51
+ const domain = params.get("DomainName") ?? opts.zone;
52
+ if (domain !== opts.zone)
53
+ return fail(res, "InvalidDomainName.NoExist", "The specified domain name does not exist.");
54
+ const records = state.records.map((r) => ({
55
+ RecordId: r.recordId,
56
+ RR: r.rr,
57
+ Type: r.type,
58
+ Value: r.value,
59
+ TTL: String(r.ttl),
60
+ }));
61
+ return json(res, 200, { RequestId: randomUUID(), TotalCount: records.length, DomainRecords: { Record: records } });
62
+ }
63
+ case "AddDomainRecord": {
64
+ const rec = {
65
+ recordId: randomUUID(),
66
+ rr: params.get("RR") ?? "@",
67
+ type: params.get("Type") ?? "A",
68
+ value: params.get("Value") ?? "",
69
+ ttl: Number(params.get("TTL") ?? 600),
70
+ };
71
+ state.records.push(rec);
72
+ return json(res, 200, { RequestId: randomUUID(), RecordId: rec.recordId });
73
+ }
74
+ case "UpdateDomainRecord": {
75
+ const rec = state.records.find((r) => r.recordId === params.get("RecordId"));
76
+ if (!rec)
77
+ return fail(res, "RecordNotExist", "The specified record does not exist.");
78
+ rec.rr = params.get("RR") ?? rec.rr;
79
+ rec.type = params.get("Type") ?? rec.type;
80
+ rec.value = params.get("Value") ?? rec.value;
81
+ rec.ttl = Number(params.get("TTL") ?? rec.ttl);
82
+ return json(res, 200, { RequestId: randomUUID(), RecordId: rec.recordId });
83
+ }
84
+ case "DeleteDomainRecord": {
85
+ const idx = state.records.findIndex((r) => r.recordId === params.get("RecordId"));
86
+ if (idx < 0)
87
+ return fail(res, "RecordNotExist", "The specified record does not exist.");
88
+ state.records.splice(idx, 1);
89
+ return json(res, 200, { RequestId: randomUUID(), RecordId: params.get("RecordId") });
90
+ }
91
+ default:
92
+ return fail(res, "InvalidAction", `Specified action is not supported: ${action}`);
93
+ }
94
+ });
95
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
96
+ const addr = server.address();
97
+ const port = typeof addr === "object" && addr ? addr.port : 0;
98
+ return {
99
+ url: `http://127.0.0.1:${port}`,
100
+ state,
101
+ stop: () => new Promise((resolve) => server.close(() => resolve())),
102
+ };
103
+ }
@@ -0,0 +1,28 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZNofmGlwQLAVn
3
+ Z9jxTTz3o32KhoWXtUNqrsIr46Nu78zuCaNZr+9B0tMXGFpIqjmxcaovei+9fY9H
4
+ AUBg/3tbW3ku8efQTPkD/EatXTEFrBM07T8UBATLhLfaN2xvzAskKp27jTnpjsvV
5
+ G3/9trCaO2OlUONXtvoQEnyn4jyCLnG9CLPhxoinNdJBUZsuZvYcNIKF1A4IRcW1
6
+ 5zVU5zduhZ7s+8yLs6uYEp5gE6jH6kmKL6Bo5ZKO4lTpbc6Xbl+/TUMPaqXP1ApN
7
+ UKzTDDRiMKqK4EiDyUl4uHSotHYx26c37gJAk4pbu0tSOQQXntKRR/3BknCOIURR
8
+ /fpjc8/NAgMBAAECggEACvd3RMYwPswX2u34/lSr/4HJU6gLWe+UksdNiLyzjxZF
9
+ EejIs3u/YXPIvkd09uyQUp4zM1DIqASbw9ZYGFlRKBnlE7InCKbp0Bo3qOMZxjEp
10
+ 8sjkkfczrKmkS09aSKpPRdhzD+Cs+4XIG0nroGp6O5/yovumOjoTriDUoWO1NxAl
11
+ kWzPnQfGzfeve/EwmuqYxFyLjMXcHFm2J2swQvp7m1nPA1rwea0LV4YXLWXsCQNI
12
+ tg9OyxO5qyN8jtGGNFVcbwswU3eIsHIoOXTxyMPqvOsnrrLinT5jkb8NJXJrwEvy
13
+ vgdLXMC8GRfxejExNDT69/os9zBw/CVgR9Gx+HiGtQKBgQDUsmguhVS9iWODlxyY
14
+ iaaJuxYDRwSo4ed0zqQ3KfqVHYAACr4ivpnqH/N/pbroGQAUEnA5DSURn9EfqT/8
15
+ XyjP2xPEpHNAQCsIH+a8fOo+BM7Zj0b6S7Ed9Tob1NF7rREV6E0mCshBaYEbZqlN
16
+ dj7OThKurVxBPFRqeZ3nRxAhlwKBgQC4Z97zK3i7r7jMF85+PqKk3UE1xfbNiSeN
17
+ XNjP4YnFam4bdspAY0+CPkBDC+1ifpnIr5MLU/lvzjTAVu5oFXpmqse+jzXRuaKI
18
+ SIb1UYKjNINYfpi+I/T7GbKwHdsp9SwyI0JDu1PxXdeZEvsXM7qLASrjfxjerwuI
19
+ FbdZAmK+OwKBgHde7WN2hvmc2tHmhDEDyAiyjAco22+g4VLICpe/Iph/pu/fF5jO
20
+ WflxFyrDq6NJLKcXNg5+1xgOFsLrLTMJgREuxeht1xTvLndv2AJfJOPLtP+bDt24
21
+ j13d3E/0X+FGXzlpV6sLEBrOCJyFQG1t+I+2d3u7ZfRi1ImxWbKMSnxfAoGAPJGx
22
+ g0GmGE+xbSfspT7BgVFDF/x8AiqomSAD/tPfwr4giFjOObPk66yywtLqF83piSdS
23
+ LwhYB8c9QGfNStazK4GwtgCn9sj4f7cCzRUGycB329tJJDr/PnLEZGCKGg/zgkxe
24
+ eYzQF5lxNVAstQRCo2GwyqR2cL4iFYK8hDnqYP0CgYEAucMoDpX96KjWuxJU52NG
25
+ kfpMI1Jaq1ViyXfQ4J6Ag037ySB40X4nkjuEuNPGPWcIcL4cKaLI8hXQCkXxXuuO
26
+ 9xrsqHztA1vCki5HRmhi/l4fGbP3tGxH6EsCiR4Hp5hODTlnEbVBo2z19OqHLQks
27
+ eeBTcR46lJU6yDbRDQfiqLw=
28
+ -----END PRIVATE KEY-----
@@ -0,0 +1,19 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDHzCCAgegAwIBAgIUa0F8AroOLrX2jEHC3B66CFkSB30wDQYJKoZIhvcNAQEL
3
+ BQAwFzEVMBMGA1UEAwwMZGRucyBtb2NrIENBMB4XDTI2MDgzMDA3NTAwNFoXDTM2
4
+ MDgyNzA3NTAwNFowFzEVMBMGA1UEAwwMZGRucyBtb2NrIENBMIIBIjANBgkqhkiG
5
+ 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmTaH5hpcECwFZ2fY8U0896N9ioaFl7VDaq7C
6
+ K+Ojbu/M7gmjWa/vQdLTFxhaSKo5sXGqL3ovvX2PRwFAYP97W1t5LvHn0Ez5A/xG
7
+ rV0xBawTNO0/FAQEy4S32jdsb8wLJCqdu4056Y7L1Rt//bawmjtjpVDjV7b6EBJ8
8
+ p+I8gi5xvQiz4caIpzXSQVGbLmb2HDSChdQOCEXFtec1VOc3boWe7PvMi7OrmBKe
9
+ YBOox+pJii+gaOWSjuJU6W3Ol25fv01DD2qlz9QKTVCs0ww0YjCqiuBIg8lJeLh0
10
+ qLR2MdunN+4CQJOKW7tLUjkEF57SkUf9wZJwjiFEUf36Y3PPzQIDAQABo2MwYTAd
11
+ BgNVHQ4EFgQUqhYXNe0bvTC3Pl7Lcw2dwJf0C54wHwYDVR0jBBgwFoAUqhYXNe0b
12
+ vTC3Pl7Lcw2dwJf0C54wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
13
+ DQYJKoZIhvcNAQELBQADggEBAFBHsTniggP0c3TbXattz518eXggQmCgSCpU3Rw9
14
+ BQvgjxAhO9fSmunutrjRRHo4etX/w0wCnsUXVDLberyBZKgOFfK7PIuYcNKxpKbU
15
+ 6goiU914diRDIKdLmRl7bjwWoI65FWZZgLqvEbzByWsY9qLceChENDNRCdzakctU
16
+ qXi3tiYFPrQ2EJdZRU3vigQkxDS8uqrv2IAMmiM9UDAAqdYX92VOXnqzWWpkG2Ek
17
+ fg680qFmY5niT3Av7dBtvA93VZpfVmzFv/ls/M8oSkfqhffSpMUfjCFYqBv3XZsD
18
+ 9e20JpqkOELlUGNok20fhCe59ZA8bdMyn9XkAiyKEoM4NLE=
19
+ -----END CERTIFICATE-----
@@ -0,0 +1,28 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsVCMXqgAl3ro0
3
+ QEg8d3u9cdb/wxwDNm5rnOhdD9jd+FVdIRfEjfABbVbW7nDHJ0wA/Fvvs0fPKNoz
4
+ Yf8ts78EpAJhhIXscieUCFnuTTJvlW8R23HwHaZt+iF5/SH/eUHlg2Zmq3GGDq5U
5
+ 0REUAYPGHPOzrfY/hLXd3Qk/SCNTfboU3Kg8AU1eb0SucmJIax7+IXh4wT8SGJdB
6
+ 8cUhRq1frfNF0MZHeX4wcidRD+aW3I5pDNUKh7h4Ncmvc9boc4XMYzt9hIUB5ljS
7
+ yWfgrCSXE1iTARhhDb90czJq+HtwWw32BAkfAzkxDxtmiC8hI26m8YKsDauaObC2
8
+ qHpg3xgPAgMBAAECggEAN1YTKLOltYNmToX/zC7f9K7cB6fq1FtDrNmnQc7naCuo
9
+ ma2ZOLwNQCAQnRbW7xR+0ug2S2vJ2Ms4+fvzWYUZBw/1VcQczxa+/8mIuz1dvzgA
10
+ gP7zANQ8j58c5dOzeZpB30XI+Zn+67nGICU8MyzjWBgO53kqlC30ymgCYqgy88lp
11
+ 9bxElMlXaZqecjcS62dBev/xyA7bGCz7tfMv9+/7LAR5VOHIdXajEoIIRbj8cLRD
12
+ xWkwSGW1nPiuLPEgHHTztJulMUM8gpGmR9h8Uw3nCDDldSzlynMX2nQtzx2TPzdC
13
+ FOTCV3pOBmUJbaAuJlDhGKtWH3GdLTCmUUQhw3KpeQKBgQDfMbTmR2uE9B8MyGxP
14
+ wwpHcC791UFaxH+IxPj+eBANvLZBa6wfBj7eMqCQlXRJFo98NqbveHp1AjGjMYeA
15
+ UaDg7TiCBVmRBEJOjaIoGQ29orh+w/cbI4XZhENrXbq1TAzzrQBC10Pslo+AJHNx
16
+ C+OYXwY+8auc2tj/ytTVXYJT9wKBgQDFqHniPWdnDLlO/sdUzoXB1+1+g1pJ83HA
17
+ +0qPboaAckIh4nl/erS+9gakR/3OhDpOrXgB9K7riddSWBl399RWyrnWqONlosGh
18
+ 8Iz5b9kCuOtGPnadQ0MHuBILOBfF7Oah2MXjzRUula40nETO1wU/ME7iQUfUPN1h
19
+ ESq5p9kmqQKBgHRP6g7rfzutIJU/yToT0UKDUunS/Zj4t+GljIDIPs1535zeKLeO
20
+ QKr4xTYzvILuL94yePJo3aAmgYmjIRMc9vLgjMctVIy/JHLdWnj8/W0q5GyjYR4S
21
+ eAlv5NaJ5Off9wvxxyYOGoaru6Ky3Do4UOYx9wZMWPuIDPfYUuvSLD3nAoGBAK6O
22
+ Q9115tyN0xUdUcHmhR+UZsvEircmRD5ubgFPVX8X6KluDWh0YC0IrwLkBRQdmDSl
23
+ Y4cupE+kssCoxw04k5B2hjo5emDmpGQ0QPuEODz8O+MUBMKLhSXK/DxmNDB6L4+d
24
+ Fd5A3kT+sRMLMoUMRTjxm6iaiCm5+rPfakD9J3vJAoGAM1/JgP3RYr7nvk2BcbD+
25
+ O6/893QlsMcZvkikSWp6Qexum3qa8+MFMoPpQkWhfSkjIAOudsS0Y/jzG8vJXnNq
26
+ mVNg9CelAtIvse83lOe9taRDyAkzdcHXAorbIB1ZyoeobkDj5DMqXx/3JAIAyFeh
27
+ uaOVmtghx31mNCe5GUS0H1o=
28
+ -----END PRIVATE KEY-----
@@ -0,0 +1,21 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDZzCCAk+gAwIBAgIUWoGE9BH89lxIwuU9xy6AZFY/YfUwDQYJKoZIhvcNAQEL
3
+ BQAwFzEVMBMGA1UEAwwMZGRucyBtb2NrIENBMB4XDTI2MDgzMDA3NTAwNVoXDTM2
4
+ MDgyNzA3NTAwNVowHTEbMBkGA1UEAwwSYXBpLmNsb3VkZmxhcmUuY29tMIIBIjAN
5
+ BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArFQjF6oAJd66NEBIPHd7vXHW/8Mc
6
+ AzZua5zoXQ/Y3fhVXSEXxI3wAW1W1u5wxydMAPxb77NHzyjaM2H/LbO/BKQCYYSF
7
+ 7HInlAhZ7k0yb5VvEdtx8B2mbfohef0h/3lB5YNmZqtxhg6uVNERFAGDxhzzs632
8
+ P4S13d0JP0gjU326FNyoPAFNXm9ErnJiSGse/iF4eME/EhiXQfHFIUatX63zRdDG
9
+ R3l+MHInUQ/mltyOaQzVCoe4eDXJr3PW6HOFzGM7fYSFAeZY0sln4KwklxNYkwEY
10
+ YQ2/dHMyavh7cFsN9gQJHwM5MQ8bZogvISNupvGCrA2rmjmwtqh6YN8YDwIDAQAB
11
+ o4GkMIGhMF8GA1UdEQRYMFaCEmFwaS5jbG91ZGZsYXJlLmNvbYIJZG5zYXBpLmNu
12
+ ghNhbGlkbnMuYWxpeXVuY3MuY29tgg93d3cuZHVja2Rucy5vcmeCCWxvY2FsaG9z
13
+ dIcEfwAAATAdBgNVHQ4EFgQUxCD4eRMWklwxHlxJGmb9GMFq4pcwHwYDVR0jBBgw
14
+ FoAUqhYXNe0bvTC3Pl7Lcw2dwJf0C54wDQYJKoZIhvcNAQELBQADggEBAIrb+5KS
15
+ oaUsImjHYNu2fMBbiLF+WgJSGqhWAmudsV+msis56F7r/H2c5KTZb6Sk5y80DDdX
16
+ 4To/+e5TyJeX/jVnQ0A+lqmFdmbnibNCHPAVmuQKc2QFi0m0XBgGyGMRiR9TqzkB
17
+ ngv1hqm0ALDKgU1z8vcZl4vbWXtgrOllxWFcvVrzFgdZACNSds7twMuCX0A47hiy
18
+ 8GvhRvV7gdrS4CAAoA62XvZZ17UCsYTKFO26LZ8fTasJTnFZfdg+/8QcAMABLzgi
19
+ slq9VZkvsAiEDOajS42wJ6jk4uMzDs4d0zn2tqWqvk+Wfcrz61JvG1Ve3lYK1EyX
20
+ 8iYT42RsR236Ly0=
21
+ -----END CERTIFICATE-----
@@ -0,0 +1,38 @@
1
+ /** cloudflare mock — an in-memory implementation of the Cloudflare REST v4
2
+ * DNS API, so the real provider can be exercised end-to-end with no network
3
+ * (mirrors ddns-updater/lexicon tests, which stub the same endpoints). */
4
+ export interface CloudflareMockOptions {
5
+ /** Token the mock accepts in Authorization: Bearer. Default "test-token". */
6
+ token?: string;
7
+ /** Fixed listen port (default: kernel-assigned free port). */
8
+ port?: number;
9
+ /** Pre-seeded zones: name → records. */
10
+ zones?: Record<string, Array<{
11
+ type: string;
12
+ name: string;
13
+ content: string;
14
+ ttl?: number;
15
+ proxied?: boolean;
16
+ }>>;
17
+ }
18
+ export interface CloudflareMockState {
19
+ zones: Map<string, {
20
+ id: string;
21
+ records: Array<Record<string, unknown>>;
22
+ }>;
23
+ /** Every request the mock served (method, url, body). */
24
+ requests: Array<{
25
+ method: string;
26
+ url: string;
27
+ body?: unknown;
28
+ }>;
29
+ }
30
+ export interface CloudflareMockHandle {
31
+ /** Base URL to inject into the provider (cfg.baseUrl). */
32
+ url: string;
33
+ state: CloudflareMockState;
34
+ /** Wait until the mock is listening. */
35
+ ready: Promise<void>;
36
+ stop(): Promise<void>;
37
+ }
38
+ export declare function startCloudflareMock(opts?: CloudflareMockOptions): Promise<CloudflareMockHandle>;
@@ -0,0 +1,105 @@
1
+ /** cloudflare mock — an in-memory implementation of the Cloudflare REST v4
2
+ * DNS API, so the real provider can be exercised end-to-end with no network
3
+ * (mirrors ddns-updater/lexicon tests, which stub the same endpoints). */
4
+ import { createServer } from "node:http";
5
+ import { randomUUID } from "node:crypto";
6
+ export async function startCloudflareMock(opts = {}) {
7
+ const token = opts.token ?? "test-token";
8
+ const state = { zones: new Map(), requests: [] };
9
+ for (const [name, records] of Object.entries(opts.zones ?? {})) {
10
+ state.zones.set(name, {
11
+ id: randomUUID(),
12
+ records: records.map((r) => ({ id: randomUUID(), ...r })),
13
+ });
14
+ }
15
+ const authOk = (req) => req.headers.authorization === `Bearer ${token}`;
16
+ const json = (res, status, body) => {
17
+ res.writeHead(status, { "content-type": "application/json" });
18
+ res.end(JSON.stringify(body));
19
+ };
20
+ const fail = (res, status, code, message) => json(res, status, { success: false, errors: [{ code, message }] });
21
+ const server = createServer((req, res) => {
22
+ const url = new URL(req.url ?? "/", "http://localhost");
23
+ // Real Cloudflare API paths are /client/v4/zones... — the mock serves at
24
+ // <baseUrl>/client/v4 (provider baseUrl = API root), so strip the prefix.
25
+ const pathname = url.pathname.replace(/^\/client\/v4/, "") || "/";
26
+ const parts = pathname.split("/").filter(Boolean); // zones, :id, dns_records, :rid
27
+ let body = "";
28
+ req.on("data", (c) => (body += c));
29
+ req.on("end", () => {
30
+ const parsed = body ? JSON.parse(body) : undefined;
31
+ state.requests.push({ method: req.method ?? "GET", url: url.pathname + url.search, body: parsed });
32
+ if (!authOk(req))
33
+ return fail(res, 403, 9109, "Invalid Token");
34
+ // GET /zones?name= — result_info is required by lexicon's
35
+ // authenticate() (it paginates on result_info.total_pages).
36
+ if (parts[0] === "zones" && parts.length === 1 && req.method === "GET") {
37
+ const name = url.searchParams.get("name");
38
+ const zones = [...state.zones.entries()]
39
+ .filter(([n]) => !name || n === name)
40
+ .map(([n, z]) => ({ id: z.id, name: n }));
41
+ return json(res, 200, {
42
+ success: true,
43
+ result: zones,
44
+ result_info: { page: 1, per_page: 100, count: zones.length, total_count: zones.length, total_pages: 1 },
45
+ });
46
+ }
47
+ // GET /zones/:id/dns_records[?type&name]
48
+ if (parts[0] === "zones" && parts[2] === "dns_records" && parts.length === 3 && req.method === "GET") {
49
+ const zone = [...state.zones.values()].find((z) => z.id === parts[1]);
50
+ if (!zone)
51
+ return fail(res, 404, 6003, "Zone not found");
52
+ let records = zone.records;
53
+ const type = url.searchParams.get("type");
54
+ const name = url.searchParams.get("name");
55
+ if (type)
56
+ records = records.filter((r) => r.type === type);
57
+ if (name)
58
+ records = records.filter((r) => r.name === name);
59
+ // lexicon 的 list_records 读取 result_info 分页(total_pages/page)
60
+ return json(res, 200, {
61
+ success: true,
62
+ result: records,
63
+ result_info: { page: 1, per_page: 100, count: records.length, total_count: records.length, total_pages: 1 },
64
+ });
65
+ }
66
+ // POST /zones/:id/dns_records
67
+ if (parts[0] === "zones" && parts[2] === "dns_records" && parts.length === 3 && req.method === "POST") {
68
+ const zone = [...state.zones.values()].find((z) => z.id === parts[1]);
69
+ if (!zone)
70
+ return fail(res, 404, 6003, "Zone not found");
71
+ const rec = { id: randomUUID(), ...parsed };
72
+ zone.records.push(rec);
73
+ return json(res, 200, { success: true, result: rec });
74
+ }
75
+ // PUT /zones/:id/dns_records/:rid
76
+ if (parts[0] === "zones" && parts[2] === "dns_records" && parts.length === 4 && req.method === "PUT") {
77
+ const zone = [...state.zones.values()].find((z) => z.id === parts[1]);
78
+ const rec = zone?.records.find((r) => r.id === parts[3]);
79
+ if (!zone || !rec)
80
+ return fail(res, 404, 81044, "Record not found");
81
+ Object.assign(rec, parsed);
82
+ return json(res, 200, { success: true, result: rec });
83
+ }
84
+ // DELETE /zones/:id/dns_records/:rid
85
+ if (parts[0] === "zones" && parts[2] === "dns_records" && parts.length === 4 && req.method === "DELETE") {
86
+ const zone = [...state.zones.values()].find((z) => z.id === parts[1]);
87
+ const idx = zone?.records.findIndex((r) => r.id === parts[3]) ?? -1;
88
+ if (!zone || idx < 0)
89
+ return fail(res, 404, 81044, "Record not found");
90
+ zone.records.splice(idx, 1);
91
+ return json(res, 200, { success: true, result: { id: parts[3] } });
92
+ }
93
+ return fail(res, 404, 7000, `Unknown endpoint ${url.pathname}`);
94
+ });
95
+ });
96
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
97
+ const addr = server.address();
98
+ const port = typeof addr === "object" && addr ? addr.port : 0;
99
+ return {
100
+ url: `http://127.0.0.1:${port}/client/v4`,
101
+ state,
102
+ ready: Promise.resolve(),
103
+ stop: () => new Promise((resolve) => server.close(() => resolve())),
104
+ };
105
+ }
@@ -0,0 +1,24 @@
1
+ /** custom mock — a generic webhook endpoint for the custom provider (Go
2
+ * ddns-updater custom semantics): records every request's query params and
3
+ * returns a configurable response body so success_regex matching can be
4
+ * tested both ways. */
5
+ export interface CustomMockOptions {
6
+ /** Fixed listen port (default: kernel-assigned free port). */
7
+ port?: number;
8
+ /** Response body for every request (default "good"). */
9
+ responseText?: string;
10
+ /** HTTP status for every request (default 200). */
11
+ status?: number;
12
+ }
13
+ export interface CustomMockState {
14
+ requests: Array<{
15
+ url: string;
16
+ query: URLSearchParams;
17
+ }>;
18
+ }
19
+ export interface CustomMockHandle {
20
+ url: string;
21
+ state: CustomMockState;
22
+ stop(): Promise<void>;
23
+ }
24
+ export declare function startCustomMock(opts?: CustomMockOptions): Promise<CustomMockHandle>;
package/dist/custom.js ADDED
@@ -0,0 +1,22 @@
1
+ /** custom mock — a generic webhook endpoint for the custom provider (Go
2
+ * ddns-updater custom semantics): records every request's query params and
3
+ * returns a configurable response body so success_regex matching can be
4
+ * tested both ways. */
5
+ import { createServer } from "node:http";
6
+ export async function startCustomMock(opts = {}) {
7
+ const state = { requests: [] };
8
+ const server = createServer((req, res) => {
9
+ const url = new URL(req.url ?? "/", "http://localhost");
10
+ state.requests.push({ url: url.pathname + url.search, query: url.searchParams });
11
+ res.writeHead(opts.status ?? 200, { "content-type": "text/plain" });
12
+ res.end(opts.responseText ?? "good");
13
+ });
14
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
15
+ const addr = server.address();
16
+ const port = typeof addr === "object" && addr ? addr.port : 0;
17
+ return {
18
+ url: `http://127.0.0.1:${port}/update`,
19
+ state,
20
+ stop: () => new Promise((resolve) => server.close(() => resolve())),
21
+ };
22
+ }
@@ -0,0 +1,33 @@
1
+ /** dnspod mock — in-memory dnsapi.cn (Record.List/Create/Modify/Remove),
2
+ * form-encoded POST like the real service. */
3
+ export interface DnspodMockOptions {
4
+ token?: string;
5
+ /** Fixed listen port (default: kernel-assigned free port). */
6
+ port?: number;
7
+ records?: Array<{
8
+ name: string;
9
+ type: string;
10
+ value: string;
11
+ ttl?: number;
12
+ }>;
13
+ }
14
+ export interface DnspodMockState {
15
+ records: Array<{
16
+ id: string;
17
+ name: string;
18
+ type: string;
19
+ value: string;
20
+ ttl: number;
21
+ line: string;
22
+ }>;
23
+ requests: Array<{
24
+ action: string;
25
+ params: URLSearchParams;
26
+ }>;
27
+ }
28
+ export interface DnspodMockHandle {
29
+ url: string;
30
+ state: DnspodMockState;
31
+ stop(): Promise<void>;
32
+ }
33
+ export declare function startDnspodMock(opts?: DnspodMockOptions): Promise<DnspodMockHandle>;
package/dist/dnspod.js ADDED
@@ -0,0 +1,104 @@
1
+ /** dnspod mock — in-memory dnsapi.cn (Record.List/Create/Modify/Remove),
2
+ * form-encoded POST like the real service. */
3
+ import { createServer } from "node:http";
4
+ import { randomUUID } from "node:crypto";
5
+ export async function startDnspodMock(opts = {}) {
6
+ const token = opts.token ?? "1,test-token";
7
+ const state = {
8
+ records: (opts.records ?? []).map((r) => ({ id: randomUUID(), line: "默认", ttl: r.ttl ?? 600, ...r })),
9
+ requests: [],
10
+ };
11
+ const json = (res, body) => {
12
+ res.writeHead(200, { "content-type": "application/json" });
13
+ res.end(JSON.stringify(body));
14
+ };
15
+ const ok = (res, data = {}) => json(res, { status: { code: "1", message: "Action completed successful" }, data });
16
+ // 真实 DNSPod 的 Record.* 响应字段在 TOP level({status, records/record/id})
17
+ // 而非 data 包裹 —— 用 okTop 模拟真实形状
18
+ const okTop = (res, body = {}) => json(res, { status: { code: "1", message: "Action completed successful" }, ...body });
19
+ const fail = (res, code, message) => json(res, { status: { code, message } });
20
+ const server = createServer((req, res) => {
21
+ let body = "";
22
+ req.on("data", (c) => (body += c));
23
+ req.on("end", () => {
24
+ const params = new URLSearchParams(body);
25
+ const action = (req.url ?? "").replace(/^\//, "").split("?")[0] ?? "";
26
+ state.requests.push({ action, params });
27
+ if (params.get("login_token") !== token)
28
+ return fail(res, "6", "Invalid Token");
29
+ switch (action) {
30
+ case "Domain.Info": {
31
+ // lexicon's dnspod authenticate() calls this first — domain.id is
32
+ // required, status.code must be "1".
33
+ const domain = params.get("domain");
34
+ const exists = state.records.some((r) => r.name.endsWith(`.${domain}`) || r.name === domain);
35
+ if (!exists)
36
+ return fail(res, "8", "Domain not found");
37
+ return okTop(res, { id: randomUUID(), name: domain });
38
+ }
39
+ case "Record.List": {
40
+ const domain = params.get("domain");
41
+ const sub = params.get("sub_domain");
42
+ const type = params.get("record_type");
43
+ let records = state.records;
44
+ if (domain)
45
+ records = records.filter((r) => r.name.endsWith(`.${domain}`) || r.name === domain);
46
+ if (sub && sub !== "@")
47
+ records = records.filter((r) => r.name.split(".")[0] === sub);
48
+ if (type)
49
+ records = records.filter((r) => r.type === type);
50
+ // real DNSPod returns the SHORT label as "name" (not the FQDN)
51
+ const shaped = records.map((r) => ({
52
+ ...r,
53
+ name: r.name === domain ? "@" : r.name.replace(`.${domain}`, ""),
54
+ }));
55
+ return okTop(res, { records: shaped });
56
+ }
57
+ case "Record.Create": {
58
+ const domain = params.get("domain") ?? "";
59
+ const sub = params.get("sub_domain") ?? "";
60
+ const owner = sub === "@" ? "" : `${sub}.`;
61
+ const rec = {
62
+ id: randomUUID(),
63
+ name: `${owner}${domain}`,
64
+ type: params.get("record_type") ?? "A",
65
+ value: params.get("value") ?? "",
66
+ ttl: Number(params.get("ttl") ?? 600),
67
+ line: params.get("record_line") ?? "默认",
68
+ };
69
+ state.records.push(rec);
70
+ // 真实 DNSPod 返回 {record: {id, name, status, ...}} —— 模拟真实形状
71
+ return okTop(res, { record: { id: rec.id, name: rec.name, status: "enabled" } });
72
+ }
73
+ case "Record.Modify": {
74
+ const id = params.get("record_id");
75
+ const rec = state.records.find((r) => r.id === id);
76
+ if (!rec)
77
+ return fail(res, "8", "Record not found");
78
+ rec.type = params.get("record_type") ?? rec.type;
79
+ rec.value = params.get("value") ?? rec.value;
80
+ rec.ttl = Number(params.get("ttl") ?? rec.ttl);
81
+ return okTop(res, { id });
82
+ }
83
+ case "Record.Remove": {
84
+ const id = params.get("record_id");
85
+ const idx = state.records.findIndex((r) => r.id === id);
86
+ if (idx < 0)
87
+ return fail(res, "8", "Record not found");
88
+ state.records.splice(idx, 1);
89
+ return okTop(res, { id });
90
+ }
91
+ default:
92
+ return fail(res, "15", `Unknown action ${action}`);
93
+ }
94
+ });
95
+ });
96
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
97
+ const addr = server.address();
98
+ const port = typeof addr === "object" && addr ? addr.port : 0;
99
+ return {
100
+ url: `http://127.0.0.1:${port}`,
101
+ state,
102
+ stop: () => new Promise((resolve) => server.close(() => resolve())),
103
+ };
104
+ }
@@ -0,0 +1,24 @@
1
+ /** duckdns mock — in-memory /v3/update endpoint (GET form like the real
2
+ * service; "OK"/"KO" responses exactly as duckdns returns them, so Go
3
+ * ddns-updater and Python lexicon duckdns implementations both work). */
4
+ export interface DuckdnsMockOptions {
5
+ token?: string;
6
+ /** Fixed listen port (default: kernel-assigned free port). */
7
+ port?: number;
8
+ }
9
+ export interface DuckdnsMockState {
10
+ /** label → { ipv4?, ipv6? } as last pushed. */
11
+ ips: Map<string, {
12
+ ipv4?: string;
13
+ ipv6?: string;
14
+ }>;
15
+ requests: Array<{
16
+ url: string;
17
+ }>;
18
+ }
19
+ export interface DuckdnsMockHandle {
20
+ url: string;
21
+ state: DuckdnsMockState;
22
+ stop(): Promise<void>;
23
+ }
24
+ export declare function startDuckdnsMock(opts?: DuckdnsMockOptions): Promise<DuckdnsMockHandle>;
@@ -0,0 +1,41 @@
1
+ /** duckdns mock — in-memory /v3/update endpoint (GET form like the real
2
+ * service; "OK"/"KO" responses exactly as duckdns returns them, so Go
3
+ * ddns-updater and Python lexicon duckdns implementations both work). */
4
+ import { createServer } from "node:http";
5
+ export async function startDuckdnsMock(opts = {}) {
6
+ const token = opts.token ?? "test-token";
7
+ const state = { ips: new Map(), requests: [] };
8
+ const server = createServer((req, res) => {
9
+ const url = new URL(req.url ?? "/", "http://localhost");
10
+ state.requests.push({ url: url.pathname + url.search });
11
+ // The real duckdns service keys everything on the token — Go ddns-updater
12
+ // hits /update, lexicon hits /, our client hits /v3/update. Answer every
13
+ // path identically so all three implementations work against the mock.
14
+ const domains = url.searchParams.get("domains") ?? "";
15
+ const tok = url.searchParams.get("token") ?? "";
16
+ const ip = url.searchParams.get("ip");
17
+ const ipv6 = url.searchParams.get("ipv6");
18
+ res.writeHead(200, { "content-type": "text/plain" });
19
+ if (tok !== token)
20
+ return res.end("KO");
21
+ if (!domains)
22
+ return res.end("KO");
23
+ for (const label of domains.split(",")) {
24
+ const entry = state.ips.get(label) ?? {};
25
+ if (ip !== null)
26
+ entry.ipv4 = ip;
27
+ if (ipv6 !== null)
28
+ entry.ipv6 = ipv6;
29
+ state.ips.set(label, entry);
30
+ }
31
+ res.end("OK");
32
+ });
33
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
34
+ const addr = server.address();
35
+ const port = typeof addr === "object" && addr ? addr.port : 0;
36
+ return {
37
+ url: `http://127.0.0.1:${port}`,
38
+ state,
39
+ stop: () => new Promise((resolve) => server.close(() => resolve())),
40
+ };
41
+ }