diff --git a/tests/domains.test.js b/tests/domains.test.js index fa8988590..071f2c534 100644 --- a/tests/domains.test.js +++ b/tests/domains.test.js @@ -5,49 +5,73 @@ 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 +} + + +function getDomainData(subdomain) { + try { + return fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); + } catch (error) { + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); + } +} + t("Nested subdomains should not exist without a parent subdomain", (t) => { - files.forEach((file) => { + for (const file of files) { const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { - const parentSubdomain = subdomain.split(".").pop(); - + const parentSubdomain = getParentSubdomain(subdomain); t.true(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) => { - files.forEach((file) => { + for (const file of files) { const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { - const parentSubdomain = subdomain.split(".").pop(); - const parentDomain = fs.readJsonSync(path.join(domainsPath, `${parentSubdomain}.json`)); + const parentSubdomain = getParentSubdomain(subdomain); + const parentDomain = getDomainData(parentSubdomain); - t.is(parentDomain.record.NS, undefined, `${file}: 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) => { - files.forEach((file) => { - const subdomain = file.replace(/\.json$/, ""); + for (const file of files) { + const subdomain = file.replace(/\.json$/, ""); if (subdomain.split(".").length > 1) { - const data = fs.readJsonSync(path.join(domainsPath, file)); + const data = getDomainData(subdomain); - const parentSubdomain = subdomain.split(".").pop(); - const parentDomain = fs.readJsonSync(path.join(domainsPath, `${parentSubdomain}.json`)); + const parentSubdomain = getParentSubdomain(subdomain); + const parentDomain = getDomainData(parentSubdomain); t.true( data.owner.username.toLowerCase() === parentDomain.owner.username.toLowerCase(), - `${file}: owner.username is not the same as the parent subdomain` + `${file}: Owner does not match the parent subdomain` ); } - }); + } }); diff --git a/tests/json.test.js b/tests/json.test.js index c89b28b2f..1bf3e3f3d 100644 --- a/tests/json.test.js +++ b/tests/json.test.js @@ -59,8 +59,9 @@ t("All files should have valid file names", (t) => { // Ignore root domain if (file !== "@.json") { + const subdomain = file.replace(/\.json$/, ""); t.regex( - file.replace(/\.json$/, "") + ".is-a.dev", + 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.` ); @@ -72,9 +73,13 @@ t("All files should have the required fields", (t) => { files.forEach((file) => { const data = fs.readJsonSync(path.join(domainsPath, file)); + // Validate top-level required fields validateRequiredFields(t, data, requiredFields, file); + + // Validate owner object fields validateRequiredFields(t, data.owner, requiredOwnerFields, file); + // Ensure 'record' field is not empty unless reserved if (!data.reserved) { t.true(Object.keys(data.record).length > 0, `${file}: No record types found`); } @@ -85,22 +90,24 @@ t("All files should have valid optional fields", (t) => { files.forEach((file) => { const data = fs.readJsonSync(path.join(domainsPath, file)); + // 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`); } }); }); -const ignoredJSONFiles = [ - "package-lock.json", - "package.json" -] +const ignoredJSONFiles = ["package-lock.json", "package.json"]; t("JSON files should not be in the root directory", (t) => { - const files = fs.readdirSync(path.resolve()).filter((file) => file.endsWith(".json") && !ignoredJSONFiles.includes(file));; - - t.is(files.length, 0, "JSON files should not be in the root directory"); + 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"); }); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 7e8299c70..2bb64b48c 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -2,21 +2,27 @@ const t = require("ava"); const fs = require("fs-extra"); const path = require("path"); -const requiredRecordsToProxy = ["A", "AAAA", "CNAME"]; +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 function validateProxiedRecords(t, data, file) { - if (data.proxied) { - const hasProxiedRecord = Object.keys(data.record).some((key) => requiredRecordsToProxy.includes(key)); + // Convert the Set to an array for message display + const recordTypes = Array.from(requiredRecordsToProxy).join(", "); - t.true(hasProxiedRecord, `${file}: Proxied is true but there are no records that can be proxied`); + if (data.proxied) { + const hasProxiedRecord = Object.keys(data.record).some((key) => requiredRecordsToProxy.has(key)); + + t.true( + hasProxiedRecord, + `${file}: Proxied is true but there are no records that can be proxied (${recordTypes} expected)` + ); } } const domainsPath = path.resolve("domains"); -const files = fs.readdirSync(domainsPath); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); -t("Domains with proxy enabled should have have at least one record that can be proxied", (t) => { +t("Domains with proxy enabled should have at least one record that can be proxied", (t) => { files.forEach((file) => { const domain = fs.readJsonSync(path.join(domainsPath, file)); diff --git a/tests/records.test.js b/tests/records.test.js index 2970b629e..d819fa9b9 100644 --- a/tests/records.test.js +++ b/tests/records.test.js @@ -2,7 +2,7 @@ const t = require("ava"); const fs = require("fs-extra"); const path = require("path"); -const validRecordTypes = ["A", "AAAA", "CAA", "CNAME", "DS", "MX", "NS", "SRV", "TXT", "URL"]; +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}$/; @@ -13,17 +13,14 @@ const domainsPath = path.resolve("domains"); const files = fs.readdirSync(domainsPath); function expandIPv6(ip) { - // Split into segments by ":" let segments = ip.split(":"); - // Count the number of segments that are empty due to "::" shorthand const emptyIndex = segments.indexOf(""); + if (emptyIndex !== -1) { - // Calculate how many "0000" segments are missing const nonEmptySegments = segments.filter((seg) => seg !== ""); const missingSegments = 8 - nonEmptySegments.length; - // Insert the missing "0000" segments into the position of the empty segment segments = [ ...nonEmptySegments.slice(0, emptyIndex), ...Array(missingSegments).fill("0000"), @@ -31,76 +28,66 @@ function expandIPv6(ip) { ]; } - // Expand each segment to 4 characters, padding with leading zeros - const expandedSegments = segments.map((segment) => segment.padStart(4, "0")); - - // Join the segments back together - return expandedSegments.join(":"); + return segments.map((segment) => segment.padStart(4, "0")).join(":"); } -function isPublicIPv4(ip, proxied) { +function validateIPv4(ip, proxied, file, index) { const parts = ip.split(".").map(Number); - // Validate IPv4 address format - if (parts.length !== 4 || parts.some((part) => isNaN(part) || part < 0 || part > 255)) { - return false; - } + if (parts.length !== 4 || parts.some((part) => isNaN(part) || part < 0 || part > 255)) return false; + if (ip === "192.0.2.1" && proxied) return true; - // Exception for 192.0.2.1, assuming the domain is proxied - if (ip === "192.0.2.1" && proxied) { - return true; - } - - // Check for private and reserved IPv4 ranges return !( - // Private ranges - ( - parts[0] === 10 || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168) || - // Reserved or special-use ranges - (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || // Carrier-grade NAT - (parts[0] === 169 && parts[1] === 254) || // Link-local - (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || // IETF Protocol Assignments - (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || // Documentation (TEST-NET-1) - (parts[0] === 198 && parts[1] === 18) || // Network Interconnect Devices - (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || // Documentation (TEST-NET-2) - (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || // Documentation (TEST-NET-3) - parts[0] >= 224 - ) // Multicast and reserved ranges + parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || + (parts[0] === 198 && parts[1] === 18) || + (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || + (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || + parts[0] >= 224 ); } -function isPublicIPv6(ip) { - const normalizedIP = ip.toLowerCase(); - - // Check for private or special-use IPv6 ranges +function validateIPv6(ip) { return !( - ( - normalizedIP.startsWith("fc") || // Unique Local Address (ULA) - normalizedIP.startsWith("fd") || // Unique Local Address (ULA) - normalizedIP.startsWith("fe80") || // Link-local - normalizedIP.startsWith("::1") || // Loopback address (::1) - normalizedIP.startsWith("2001:db8") - ) // Documentation range + ip.toLowerCase().startsWith("fc") || + ip.toLowerCase().startsWith("fd") || + ip.toLowerCase().startsWith("fe80") || + ip.toLowerCase().startsWith("::1") || + ip.toLowerCase().startsWith("2001:db8") ); } +function validateRecordType(recordType) { + return validRecordTypes.has(recordType); +} + +function isValidHostname(hostname) { + return hostnameRegex.test(hostname); +} + +function isValidHexadecimal(value) { + return /^[0-9a-fA-F]+$/.test(value); +} + t("All files should have valid record types", (t) => { files.forEach((file) => { const data = fs.readJsonSync(path.join(domainsPath, file)); const recordKeys = Object.keys(data.record); recordKeys.forEach((key) => { - t.true(validRecordTypes.includes(key), `${file}: Invalid record type: ${key}`); + t.true(validateRecordType(key), `${file}: Invalid record type: ${key}`); }); - // CNAME records cannot be combined with any other record type unless it is proxied + // Specific record rules for CNAME, NS, and DS if (recordKeys.includes("CNAME") && !data.proxied) { - t.is(recordKeys.length, Number(1), `${file}: CNAME records cannot be combined with other records`); + t.is(recordKeys.length, 1, `${file}: CNAME records cannot be combined with other records unless proxied`); } - // NS records cannot be combined with any other record type, except for DS records if (recordKeys.includes("NS")) { t.true( recordKeys.length === 1 || (recordKeys.length === 2 && recordKeys.includes("DS")), @@ -108,7 +95,6 @@ t("All files should have valid record types", (t) => { ); } - // DS records must be combined with NS records if (recordKeys.includes("DS")) { t.true(recordKeys.includes("NS"), `${file}: DS records must be combined with NS records`); } @@ -118,7 +104,6 @@ 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 recordKeys = Object.keys(data.record); const uniqueRecordKeys = new Set(recordKeys); @@ -132,230 +117,95 @@ t("All files should have valid record values", (t) => { Object.keys(data.record).forEach((key) => { const value = data.record[key]; + const subdomain = file.replace(/\.json$/, ""); // Get the subdomain from the filename - // *: string[] + // Validate A, AAAA, MX, NS records: Array of strings if (["A", "AAAA", "MX", "NS"].includes(key)) { - t.true(Array.isArray(value), `${file}: Record value should be an array for ${key}`); + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); - value.forEach((record) => { + value.forEach((record, idx) => { t.true( typeof record === "string", - `${file}: Record value should be a string for ${key} at index ${value.indexOf(record)}` + `${file}: Record value for ${key} should be a string at index ${idx}` ); - }); - - // A: string[] - if (key === "A") { - value.forEach((record) => { - t.regex( - record, - ipv4Regex, - `${file}: Record value should be a valid IPv4 address for ${key} at index ${value.indexOf( - record - )}` - ); + if (key === "A") { + t.regex(record, ipv4Regex, `${file}: Invalid IPv4 address for ${key} at index ${idx}`); t.true( - isPublicIPv4(record, data.proxied), - `${file}: Record value should be a public IPv4 address for ${key} at index ${value.indexOf( - record - )}` + validateIPv4(record, data.proxied, file, idx), + `${file}: Invalid IPv4 address for ${key} at index ${idx}` ); - }); - } + } - // AAAA: string[] - if (key === "AAAA") { - value.forEach((record) => { + if (key === "AAAA") { t.regex( expandIPv6(record), ipv6Regex, - `${file}: Record value should be a valid IPv6 address for ${key} at index ${value.indexOf( - record - )}` + `${file}: Invalid IPv6 address for ${key} at index ${idx}` ); + t.true(validateIPv6(record), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + } - t.true( - isPublicIPv6(record), - `${file}: Record value should be a public IPv6 address for ${key} at index ${value.indexOf( - record - )}` - ); - }); - } - - // *: string[] - if (["MX", "NS"].includes(key)) { - value.forEach((record) => { - t.regex( - record, - hostnameRegex, - `${file}: Record value should be a valid hostname for ${key} at index ${value.indexOf( - record - )}` - ); - }); - } + if (["MX", "NS"].includes(key)) { + t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); + } + }); } - // *: string + // Validate CNAME and URL records: Single string if (["CNAME", "URL"].includes(key)) { - t.true(typeof value === "string", `${file}: Record value should be a string for ${key}`); + t.true(typeof value === "string", `${file}: Record value for ${key} should be a string`); if (key === "CNAME") { - t.regex(value, hostnameRegex, `${file}: Record value should be a valid hostname for ${key}`); - - if(file === "@.json") { - t.false( - value === "is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); - } else { - t.false( - value === file.replace(/\.json$/, "") + ".is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); + 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.notThrows(() => new URL(value), `${file}: Record value should be a valid URL for ${key}`); - - if(file === "@.json") { - t.false( - value === "http://is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); - t.false( - value === "https://is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); - } else { - t.false( - value === "http://" + file.replace(/\.json$/, "") + ".is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); - t.false( - value === "https://" + file.replace(/\.json$/, "") + ".is-a.dev", - `${file}: Record value should not reference itself for ${key}` - ); + t.notThrows(() => new URL(value), `${file}: Invalid URL for ${key}`); + try { + const urlObj = new URL(value); + t.true(urlObj.hostname !== subdomain, `${file}: URL cannot point to itself`); + } catch { + t.fail(`${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 should be an array for ${key}`); + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); - value.forEach((record) => { + value.forEach((record, idx) => { t.true( typeof record === "object", - `${file}: Record value should be an object for ${key} at index ${value.indexOf(record)}` + `${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}`); + } }); - - // CAA: { flags: number, tag: string, value: string }[] - if (key === "CAA") { - value.forEach((record) => { - t.true( - typeof record.flags === "number", - `${file}: CAA record value should have a number for flags at index ${value.indexOf(record)}` - ); - - t.true( - typeof record.tag === "string", - `${file}: CAA record value should have a string for tag at index ${value.indexOf(record)}` - ); - - t.true( - typeof record.value === "string", - `${file}: CAA record value should have a string for value at index ${value.indexOf(record)}` - ); - }); - } - - // DS: { key_tag: number, algorithm: number, digest_type: number, digest: string }[] - if (key === "DS") { - value.forEach((record) => { - t.true( - typeof record.key_tag === "number", - `${file}: DS record value should have a number for key_tag at index ${value.indexOf( - record - )}` - ); - - t.true( - typeof record.algorithm === "number", - `${file}: DS record value should have a number for algorithm at index ${value.indexOf( - record - )}` - ); - - t.true( - typeof record.digest_type === "number", - `${file}: DS record value should have a number for digest_type at index ${value.indexOf( - record - )}` - ); - - t.true( - typeof record.digest === "string", - `${file}: DS record value should have a string for digest at index ${value.indexOf(record)}` - ); - }); - } - - // SRV: { priority: number, weight: number, port: number, target: string }[] - if (key === "SRV") { - value.forEach((record) => { - t.true( - typeof record.priority === "number", - `${file}: SRV record value should have a number for priority at index ${value.indexOf( - record - )}` - ); - - t.true( - typeof record.weight === "number", - `${file}: SRV record value should have a number for weight at index ${value.indexOf( - record - )}` - ); - - t.true( - typeof record.port === "number", - `${file}: SRV record value should have a number for port at index ${value.indexOf(record)}` - ); - - t.true( - typeof record.target === "string", - `${file}: SRV record value should have a string for target at index ${value.indexOf( - record - )}` - ); - - t.regex( - value.target, - hostnameRegex, - `${file}: SRV record value should be a valid hostname for target at index ${value.indexOf( - record - )}` - ); - }); - } } - // TXT: string | string[] + // TXT: Single string or array of strings if (key === "TXT") { if (Array.isArray(value)) { - value.forEach((record) => { + value.forEach((record, idx) => { t.true( typeof record === "string", - `${file}: Record value should be a string for ${key} at index ${value.indexOf(record)}` + `${file}: TXT record value should be a string at index ${idx}` ); }); } else { - t.true(typeof value === "string", `${file}: Record value should be a string for ${key}`); + t.true(typeof value === "string", `${file}: TXT record value should be a string`); } } });