From bcf071b6f2ea43c9416d6e3535b1e0d98c664273 Mon Sep 17 00:00:00 2001 From: William Harrison <87287585+wdhdev@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:11:37 +0800 Subject: [PATCH] optimise + improve tests --- tests/domains.test.js | 111 +++++----------------- tests/json.test.js | 149 +++++++++++++++++++----------- tests/proxy.test.js | 23 ++++- tests/records.test.js | 209 +++++++++++++++++++++--------------------- 4 files changed, 244 insertions(+), 248 deletions(-) diff --git a/tests/domains.test.js b/tests/domains.test.js index f43b05254..ce2c3817d 100644 --- a/tests/domains.test.js +++ b/tests/domains.test.js @@ -5,72 +5,52 @@ const path = require("path"); const domainsPath = path.resolve("domains"); const files = fs.readdirSync(domainsPath); -function getParentSubdomain(subdomain) { - const parts = subdomain.split("."); - - if (parts.length <= 1) return null; // No parent for top-level subdomains - - // Attempt to find the parent subdomain by removing the last part - for (let i = parts.length - 1; i > 0; i--) { - const potentialParent = parts.slice(i - 1).join("."); - if (files.includes(`${potentialParent}.json`)) { - return potentialParent; // Return the parent subdomain if it exists - } - } - - return null; // Return null if no valid parent is found -} +const domainCache = {}; function getDomainData(subdomain) { + if (domainCache[subdomain]) { + return domainCache[subdomain]; + } + try { - return fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); + const data = fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); + domainCache[subdomain] = data; // Cache the domain data + return data; } catch (error) { throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); } } -function expandReservedDomains() { - const reserved = require("../util/reserved-domains.json"); - const expandedList = [...reserved]; +function getParentSubdomain(subdomain) { + const parts = subdomain.split("."); - for (const item of reserved) { - const rangeMatch = item.match(/\[(\d+)-(\d+)\]/); // Matches [min-max] + if (parts.length <= 1) return null; // No parent for top-level subdomains - if (rangeMatch) { - const prefix = item.split("[")[0]; - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); + // Try to find the parent subdomain by iterating over the parts + for (let i = parts.length - 1; i > 0; i--) { + const potentialParent = parts.slice(i - 1).join("."); - if (start < end) { - for (let i = start; i <= end; i++) { - expandedList.push(prefix + i); - } - - expandedList.splice(expandedList.indexOf(item), 1); - } else { - throw new Error(`[util/reserved-domains.json] Invalid range [${start}-${end}] in "${item}"`); - } + if (files.includes(`${potentialParent}.json`)) { + return potentialParent; } } - return expandedList; + return null; } t("Nested subdomains should not exist without a parent subdomain", (t) => { - for (const file of files) { + files.forEach((file) => { const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { const parentSubdomain = getParentSubdomain(subdomain); - t.true(files.includes(`${parentSubdomain}.json`), `${file}: Parent subdomain does not exist`); + t.true(parentSubdomain && files.includes(`${parentSubdomain}.json`), `${file}: Parent subdomain does not exist`); } - } - - t.pass(); + }); }); t("Nested subdomains should not exist if the parent subdomain has NS records", (t) => { - for (const file of files) { + files.forEach((file) => { const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { @@ -79,18 +59,15 @@ t("Nested subdomains should not exist if the parent subdomain has NS records", ( t.true(!parentDomain.record.NS, `${file}: Parent subdomain has NS records`); } - } - - t.pass(); + }); }); t("Nested subdomains should be owned by the parent subdomain's owner", (t) => { - for (const file of files) { + files.forEach((file) => { const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { const data = getDomainData(subdomain); - const parentSubdomain = getParentSubdomain(subdomain); const parentDomain = getDomainData(parentSubdomain); @@ -99,45 +76,5 @@ t("Nested subdomains should be owned by the parent subdomain's owner", (t) => { `${file}: Owner does not match the parent subdomain` ); } - } + }); }); - -const reservedDomains = expandReservedDomains(); - -t("Subdomain names must not be reserved", (t) => { - for (const file of files) { - const subdomain = file.replace(/\.json$/, ""); - - t.true(!reservedDomains.includes(subdomain), `${file}: Subdomain name is reserved`); - } - - t.pass(); -}); - -t("Reserved domains file should be valid", (t) => { - const subdomainRegex = /^_?[a-zA-Z0-9]+([-\.][a-zA-Z0-9]+)*(\[\d+-\d+\])?$/; - - for (const item of reservedDomains) { - t.regex( - item, - subdomainRegex, - `[util/reserved-domains.json] Invalid subdomain name "${item}" at index ${reservedDomains.indexOf(item)}` - ); - } - - t.pass(); -}); - -const exceptedDomains = require("../util/excepted-domains.json"); - -t("Subdomains on the root should not start with an underscore", (t) => { - for (const file of files) { - const subdomain = file.replace(/\.json$/, ""); - - if (subdomain.split(".").length === 1 && !exceptedDomains.includes(subdomain)) { - t.true(subdomain[0] !== "_", `${file}: Root subdomains should not start with an underscore`); - } - } - - t.pass(); -}) diff --git a/tests/json.test.js b/tests/json.test.js index 4dab0ede7..80d83c9ab 100644 --- a/tests/json.test.js +++ b/tests/json.test.js @@ -2,13 +2,16 @@ const t = require("ava"); const fs = require("fs-extra"); const path = require("path"); +const ignoredRootJSONFiles = ["package-lock.json", "package.json"]; + const requiredFields = { owner: "object", record: "object" }; const optionalFields = { - proxied: "boolean" + proxied: "boolean", + redirect_config: "object" }; const requiredOwnerFields = { @@ -19,31 +22,87 @@ const optionalOwnerFields = { email: "string" }; +const optionalRedirectConfigFields = { + custom_paths: "object", + redirect_paths: "boolean" +}; + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; +const exceptedDomains = require("../util/excepted-domains.json"); +const reservedDomains = require("../util/reserved-domains.json"); const domainsPath = path.resolve("domains"); const files = fs.readdirSync(domainsPath); -function validateRequiredFields(t, obj, requiredFields, file) { - Object.keys(requiredFields).forEach((key) => { - t.true(obj.hasOwnProperty(key), `${file}: Missing required field: ${key}`); - t.is(typeof obj[key], requiredFields[key], `${file}: Field ${key} should be of type ${requiredFields[key]}`); +const expandReservedDomains = (reserved) => { + const expandedList = [...reserved]; + + reserved.forEach((item) => { + const rangeMatch = item.match(/\[(\d+)-(\d+)\]/); + + if (rangeMatch) { + const prefix = item.split("[")[0]; + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + + if (start < end) { + for (let i = start; i <= end; i++) { + expandedList.push(prefix + i); + } + expandedList.splice(expandedList.indexOf(item), 1); + } else { + throw new Error(`[util/reserved-domains.json] Invalid range [${start}-${end}] in "${item}"`); + } + } }); -} + + return expandedList; +}; + +const expandedReservedDomains = expandReservedDomains(reservedDomains); + +function validateFields(t, obj, fields, file, prefix = "") { + Object.keys(fields).forEach((key) => { + const fieldPath = prefix ? `${prefix}.${key}` : key; -function validateOptionalFields(t, obj, optionalFields, file) { - Object.keys(optionalFields).forEach((key) => { if (obj.hasOwnProperty(key)) { - t.is( - typeof obj[key], - optionalFields[key], - `${file}: Field ${key} should be of type ${optionalFields[key]}` - ); + t.is(typeof obj[key], fields[key], `${file}: Field ${fieldPath} should be of type ${fields[key]}`); + } else if (fields === requiredFields) { + t.true(false, `${file}: Missing required field: ${fieldPath}`); } }); } +function validateFileName(t, file) { + t.true(file.endsWith(".json"), `${file}: File does not have .json extension`); + t.false(file.includes(".is-a.dev"), `${file}: File name should not contain .is-a.dev`); + t.true(file === file.toLowerCase(), `${file}: File name should be all lowercase`); + + // Ignore root domain + if (file !== "@.json") { + const subdomain = file.replace(/\.json$/, ""); + + t.regex( + subdomain + ".is-a.dev", + hostnameRegex, + `${file}: FQDN must be 1-253 characters, use letters, numbers, dots, or hyphens, and not start or end with a hyphen.` + ); + t.true(!expandedReservedDomains.includes(subdomain), `${file}: Subdomain name is reserved`); + + if (subdomain.split(".").length === 1 && !exceptedDomains.includes(subdomain)) { + t.false(subdomain.startsWith("_"), `${file}: Root subdomains should not start with an underscore`); + } + } +} + +t("JSON files should not be in the root directory", (t) => { + const rootFiles = fs + .readdirSync(path.resolve()) + .filter((file) => file.endsWith(".json") && !ignoredRootJSONFiles.includes(file)); + t.is(rootFiles.length, 0, "JSON files should not be in the root directory"); +}); + t("All files should be valid JSON", (t) => { files.forEach((file) => { t.notThrows(() => fs.readJsonSync(path.join(domainsPath, file)), `${file}: Invalid JSON file`); @@ -52,59 +111,47 @@ t("All files should be valid JSON", (t) => { t("All files should have valid file names", (t) => { files.forEach((file) => { - t.true(file.endsWith(".json"), `${file}: File does not have .json extension`); - t.false(file.includes(".is-a.dev"), `${file}: File name should not contain .is-a.dev`); - t.true(file === file.toLowerCase(), `${file}: File name should be lowercase`); - - // Ignore root domain - if (file !== "@.json") { - const subdomain = file.replace(/\.json$/, ""); - t.regex( - subdomain + ".is-a.dev", - hostnameRegex, - `${file}: FQDN must be 1-253 characters, use letters, numbers, dots, or hyphens, and not start or end with a hyphen.` - ); - } + validateFileName(t, file); }); }); -t("All files should have the required fields", (t) => { +t("All files should have valid required and optional fields", (t) => { files.forEach((file) => { const data = fs.readJsonSync(path.join(domainsPath, file)); // Validate top-level required fields - validateRequiredFields(t, data, requiredFields, file); + validateFields(t, data, requiredFields, file); - // Validate owner object fields - validateRequiredFields(t, data.owner, requiredOwnerFields, file); + // Validate owner fields + validateFields(t, data.owner, requiredOwnerFields, file, "owner"); + validateFields(t, data.owner, optionalOwnerFields, file, "owner"); + + // Validate optional fields for top-level and redirect config + validateFields(t, data, optionalFields, file); + if (data.redirect_config) { + validateFields(t, data.redirect_config, optionalRedirectConfigFields, file, "redirect_config"); + } + + // Validate email format + if (data.owner.email) { + t.regex(data.owner.email, emailRegex, `${file}: Owner email should be a valid email address`); + } // Ensure 'record' field is not empty t.true(Object.keys(data.record).length > 0, `${file}: Missing DNS records`); }); }); -t("All files should have valid optional fields", (t) => { - files.forEach((file) => { - const data = fs.readJsonSync(path.join(domainsPath, file)); +t("Reserved domains file should be valid", (t) => { + const subdomainRegex = /^_?[a-zA-Z0-9]+([-\.][a-zA-Z0-9]+)*(\[\d+-\d+\])?$/; - // Validate optional fields at top level - validateOptionalFields(t, data, optionalFields, file); - - // Validate optional fields for owner object - validateOptionalFields(t, data.owner, optionalOwnerFields, file); - - // Email validation (if provided) - if (data.owner.email) { - t.regex(data.owner.email, emailRegex, `${file}: Owner email should be a valid email address`); - } + expandedReservedDomains.forEach((item, index) => { + t.regex( + item, + subdomainRegex, + `[util/reserved-domains.json] Invalid subdomain name "${item}" at index ${index}` + ); }); -}); -const ignoredJSONFiles = ["package-lock.json", "package.json"]; - -t("JSON files should not be in the root directory", (t) => { - const rootFiles = fs - .readdirSync(path.resolve()) - .filter((file) => file.endsWith(".json") && !ignoredJSONFiles.includes(file)); - t.is(rootFiles.length, 0, "JSON files should not be in the root directory"); + t.pass(); }); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 2bb64b48c..d7c73bc5b 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -3,10 +3,25 @@ const fs = require("fs-extra"); const path = require("path"); const requiredRecordsToProxy = new Set(["A", "AAAA", "CNAME"]); -// URL records are not listed here because they are proxied by default, so they don't need the proxied flag + +const domainCache = {}; + +function getDomainData(file) { + if (domainCache[file]) { + return domainCache[file]; + } + + try { + const data = fs.readJsonSync(path.join(domainsPath, file)); + domainCache[file] = data; + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${file}: ${error.message}`); + } +} function validateProxiedRecords(t, data, file) { - // Convert the Set to an array for message display + // Convert the Set to an array for message display (moved outside the loop to optimize performance) const recordTypes = Array.from(requiredRecordsToProxy).join(", "); if (data.proxied) { @@ -22,9 +37,9 @@ function validateProxiedRecords(t, data, file) { const domainsPath = path.resolve("domains"); const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); -t("Domains with proxy enabled should have at least one record that can be proxied", (t) => { +t("Domains with proxy enabled must have at least one proxy-able record", (t) => { files.forEach((file) => { - const domain = fs.readJsonSync(path.join(domainsPath, file)); + const domain = getDomainData(file); validateProxiedRecords(t, domain, file); }); diff --git a/tests/records.test.js b/tests/records.test.js index fdf2a17e9..d9b8b66f0 100644 --- a/tests/records.test.js +++ b/tests/records.test.js @@ -3,18 +3,32 @@ const fs = require("fs-extra"); const path = require("path"); const validRecordTypes = new Set(["A", "AAAA", "CAA", "CNAME", "DS", "MX", "NS", "SRV", "TXT", "URL"]); - const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/; const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){0,6}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; const domainsPath = path.resolve("domains"); -const files = fs.readdirSync(domainsPath); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); + +const domainCache = {}; + +function getDomainData(file) { + if (domainCache[file]) { + return domainCache[file]; + } + + try { + const data = fs.readJsonSync(path.join(domainsPath, file)); + domainCache[file] = data; + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${file}: ${error.message}`); + } +} function expandIPv6(ip) { let segments = ip.split(":"); - const emptyIndex = segments.indexOf(""); if (emptyIndex !== -1) { @@ -31,7 +45,7 @@ function expandIPv6(ip) { return segments.map((segment) => segment.padStart(4, "0")).join(":"); } -function validateIPv4(ip, proxied, file, index) { +function validateIPv4(ip, proxied, file) { const parts = ip.split(".").map(Number); if (parts.length !== 4 || parts.some((part) => isNaN(part) || part < 0 || part > 255)) return false; @@ -74,33 +88,112 @@ function isValidHexadecimal(value) { return /^[0-9a-fA-F]+$/.test(value); } +function validateRecordValues(t, data, file) { + Object.keys(data.record).forEach((key) => { + const value = data.record[key]; + + // Validate A, AAAA, MX, NS records: Array of strings + if (["A", "AAAA", "MX", "NS"].includes(key)) { + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); + value.forEach((record, idx) => { + t.true( + typeof record === "string", + `${file}: Record value for ${key} should be a string at index ${idx}` + ); + if (key === "A") { + t.regex(record, ipv4Regex, `${file}: Invalid IPv4 address for ${key} at index ${idx}`); + t.true( + validateIPv4(record, data.proxied, file, idx), + `${file}: Invalid IPv4 address for ${key} at index ${idx}` + ); + } + if (key === "AAAA") { + t.regex(expandIPv6(record), ipv6Regex, `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + t.true(validateIPv6(record), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + } + if (["MX", "NS"].includes(key)) { + t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); + } + }); + } + + // Validate CNAME and URL records: Single string + if (["CNAME", "URL"].includes(key)) { + t.true(typeof value === "string", `${file}: Record value for ${key} should be a string`); + if (key === "CNAME") { + t.true(isValidHostname(value), `${file}: Invalid hostname for ${key}`); + t.true(value !== file, `${file}: CNAME cannot point to itself`); + } + if (key === "URL") { + t.true( + value.startsWith("http://") || value.startsWith("https://"), + `${file}: Record value for ${key} must start with http:// or https://` + ); + t.notThrows(() => new URL(value), `${file}: Invalid URL for ${key}`); + } + } + + // Validate CAA, DS, SRV records: Array of objects + if (["CAA", "DS", "SRV"].includes(key)) { + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); + value.forEach((record, idx) => { + t.true( + typeof record === "object", + `${file}: Record value for ${key} should be an object at index ${idx}` + ); + if (key === "DS") { + t.true( + Number.isInteger(record.key_tag) && record.key_tag >= 0 && record.key_tag <= 65535, + `${file}: Invalid key_tag for DS at index ${idx}` + ); + t.true(isValidHexadecimal(record.digest), `${file}: Invalid digest for DS at index ${idx}`); + } + }); + } + + // TXT: Single string or array of strings + if (key === "TXT") { + if (Array.isArray(value)) { + value.forEach((record, idx) => { + t.true(typeof record === "string", `${file}: TXT record value should be a string at index ${idx}`); + }); + } else { + t.true(typeof value === "string", `${file}: TXT record value should be a string`); + } + } + }); +} + t("All files should have valid record types", (t) => { files.forEach((file) => { - const data = fs.readJsonSync(path.join(domainsPath, file)); + const data = getDomainData(file); const recordKeys = Object.keys(data.record); recordKeys.forEach((key) => { t.true(validateRecordType(key), `${file}: Invalid record type: ${key}`); }); + // Record type combinations validation if (recordKeys.includes("CNAME") && !data.proxied) { t.is(recordKeys.length, 1, `${file}: CNAME records cannot be combined with other records unless proxied`); } - if (recordKeys.includes("NS")) { t.true( recordKeys.length === 1 || (recordKeys.length === 2 && recordKeys.includes("DS")), `${file}: NS records cannot be combined with other records, except for DS records` ); } - if (recordKeys.includes("DS")) { t.true(recordKeys.includes("NS"), `${file}: DS records must be combined with NS records`); } - if (recordKeys.includes("URL")) { - t.true(!recordKeys.includes("A") && !recordKeys.includes("AAAA") && !recordKeys.includes("CNAME"), `${file}: URL records cannot be combined with A, AAAA, or CNAME records`); + t.true( + !recordKeys.includes("A") && !recordKeys.includes("AAAA") && !recordKeys.includes("CNAME"), + `${file}: URL records cannot be combined with A, AAAA, or CNAME records` + ); } + + validateRecordValues(t, data, file); }); t.pass(); @@ -108,106 +201,10 @@ t("All files should have valid record types", (t) => { t("All files should not have duplicate record keys", (t) => { files.forEach((file) => { - const data = fs.readJsonSync(path.join(domainsPath, file)); + const data = getDomainData(file); const recordKeys = Object.keys(data.record); const uniqueRecordKeys = new Set(recordKeys); t.is(recordKeys.length, uniqueRecordKeys.size, `${file}: Duplicate record keys found`); }); }); - -t("All files should have valid record values", (t) => { - files.forEach((file) => { - const data = fs.readJsonSync(path.join(domainsPath, file)); - - Object.keys(data.record).forEach((key) => { - const value = data.record[key]; - const subdomain = file.replace(/\.json$/, ""); // Get the subdomain from the filename - - // Validate A, AAAA, MX, NS records: Array of strings - if (["A", "AAAA", "MX", "NS"].includes(key)) { - t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); - - value.forEach((record, idx) => { - t.true( - typeof record === "string", - `${file}: Record value for ${key} should be a string at index ${idx}` - ); - - if (key === "A") { - t.regex(record, ipv4Regex, `${file}: Invalid IPv4 address for ${key} at index ${idx}`); - t.true( - validateIPv4(record, data.proxied, file, idx), - `${file}: Invalid IPv4 address for ${key} at index ${idx}` - ); - } - - if (key === "AAAA") { - t.regex( - expandIPv6(record), - ipv6Regex, - `${file}: Invalid IPv6 address for ${key} at index ${idx}` - ); - t.true(validateIPv6(record), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); - } - - if (["MX", "NS"].includes(key)) { - t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); - } - }); - } - - // Validate CNAME and URL records: Single string - if (["CNAME", "URL"].includes(key)) { - t.true(typeof value === "string", `${file}: Record value for ${key} should be a string`); - - if (key === "CNAME") { - t.true(isValidHostname(value), `${file}: Invalid hostname for ${key}`); - t.true(value !== file, `${file}: CNAME cannot point to itself`); - if (file === "@.json") { - t.true(value !== "is-a.dev", `${file}: CNAME cannot point to itself`); - } - } - - if (key === "URL") { - t.true(value.startsWith("http://") || value.startsWith("https://"), `${file}: Record value for ${key} must start with http:// or https://`) - t.notThrows(() => new URL(value), `${file}: Invalid URL for ${key}`); - } - } - - // Validate CAA, DS, SRV records: Array of objects - if (["CAA", "DS", "SRV"].includes(key)) { - t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); - - value.forEach((record, idx) => { - t.true( - typeof record === "object", - `${file}: Record value for ${key} should be an object at index ${idx}` - ); - - if (key === "DS") { - t.true( - Number.isInteger(record.key_tag) && record.key_tag >= 0 && record.key_tag <= 65535, - `${file}: Invalid key_tag for DS at index ${idx}` - ); - t.true(isValidHexadecimal(record.digest), `${file}: Invalid digest for DS at index ${idx}`); - } - }); - } - - // TXT: Single string or array of strings - if (key === "TXT") { - if (Array.isArray(value)) { - value.forEach((record, idx) => { - t.true( - typeof record === "string", - `${file}: TXT record value should be a string at index ${idx}` - ); - }); - } else { - t.true(typeof value === "string", `${file}: TXT record value should be a string`); - } - } - }); - }); -});