json.test.js (5758B)
1 import t from "ava"; 2 import fs from "fs-extra"; 3 import path from "path"; 4 5 const internalDomains = fs.readJsonSync(path.resolve("util/internal.json")); 6 const reservedDomains = fs.readJsonSync(path.resolve("util/reserved.json")); 7 8 const ignoredRootJSONFiles = ["package-lock.json", "package.json"]; 9 10 const requiredFields = { 11 owner: "object", 12 records: "object" 13 }; 14 15 const optionalFields = { 16 proxied: "boolean" 17 }; 18 19 const requiredOwnerFields = { 20 username: "string" 21 }; 22 23 const optionalOwnerFields = { 24 email: "string" 25 }; 26 27 const blockedFields = [ 28 "domain", 29 "internal", 30 "proxy", 31 "reserved", 32 "services", 33 "subdomain", 34 "nested", 35 "record", 36 "redirect_config", 37 "redirects", 38 "redirects_config" 39 ]; 40 41 const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 42 const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; 43 44 const domainsPath = path.resolve("domains"); 45 const files = fs.readdirSync(domainsPath); 46 47 function findDuplicateKeys(jsonString) { 48 const duplicateKeys = new Set(); 49 const keyStack = []; 50 51 const keyRegex = /"(.*?)"\s*:/g; 52 53 let i = 0; 54 while (i < jsonString.length) { 55 const char = jsonString[i]; 56 57 if (char === "{") { 58 keyStack.push({}); 59 i++; 60 continue; 61 } 62 63 if (char === "}") { 64 keyStack.pop(); 65 i++; 66 continue; 67 } 68 69 keyRegex.lastIndex = i; 70 const match = keyRegex.exec(jsonString); 71 if (match && match.index === i && keyStack.length > 0) { 72 const key = match[1]; 73 const currentScope = keyStack[keyStack.length - 1]; 74 75 if (currentScope[key]) { 76 duplicateKeys.add(key); 77 } else { 78 currentScope[key] = true; 79 } 80 81 i = keyRegex.lastIndex; 82 } else { 83 i++; 84 } 85 } 86 87 return [...duplicateKeys]; 88 } 89 90 async function validateFields(t, obj, fields, file, prefix = "") { 91 for (const key of Object.keys(fields)) { 92 const fieldPath = prefix ? `${prefix}.${key}` : key; 93 94 if (obj.hasOwnProperty(key)) { 95 t.is(typeof obj[key], fields[key], `${file}: Field ${fieldPath} should be of type ${fields[key]}`); 96 } else if (fields === requiredFields || fields === requiredOwnerFields) { 97 t.true(false, `${file}: Missing required field: ${fieldPath}`); 98 } 99 } 100 } 101 102 async function validateFileName(t, file) { 103 t.true(file.endsWith(".json"), `${file}: File does not have .json extension`); 104 t.false(file.includes(".is-a.bot"), `${file}: File name should not contain .is-a.bot`); 105 t.true(file === file.toLowerCase(), `${file}: File name should be all lowercase`); 106 t.false(file.includes("--"), `${file}: File name should not contain consecutive hyphens`); 107 108 const subdomain = file.replace(/\.json$/, ""); 109 110 t.regex( 111 subdomain + ".is-a.bot", 112 hostnameRegex, 113 `${file}: FQDN must be 1-253 characters, can use letters, numbers, dots, and non-consecutive hyphens.` 114 ); 115 t.false(internalDomains.includes(subdomain), `${file}: Subdomain name is registered internally`); 116 t.false(reservedDomains.includes(subdomain), `${file}: Subdomain name is reserved`); 117 t.true( 118 !internalDomains.some((i) => subdomain.endsWith(`.${i}`)), 119 `${file}: Subdomain name is registered internally` 120 ); 121 t.true(!reservedDomains.some((r) => subdomain.endsWith(`.${r}`)), `${file}: Subdomain name is reserved`); 122 123 const rootSubdomain = subdomain.split(".").pop(); 124 t.false(rootSubdomain.startsWith("_"), `${file}: Root subdomains should not start with an underscore`); 125 } 126 127 async function processFile(file, t) { 128 const filePath = path.join(domainsPath, file); 129 const data = await fs.readJson(filePath); 130 131 validateFileName(t, file); 132 133 // Check for duplicate keys 134 const rawData = await fs.readFile(filePath, "utf8"); 135 const duplicateKeys = findDuplicateKeys(rawData); 136 t.true(!duplicateKeys.length, `${file}: Duplicate keys found: ${duplicateKeys.join(", ")}`); 137 138 // Validate fields 139 validateFields(t, data, requiredFields, file); 140 validateFields(t, data.owner, requiredOwnerFields, file, "owner"); 141 validateFields(t, data.owner, optionalOwnerFields, file, "owner"); 142 validateFields(t, data, optionalFields, file); 143 144 if (data.owner.email) { 145 t.regex(data.owner.email, emailRegex, `${file}: Owner email should be a valid email address`); 146 t.false( 147 data.owner.email.endsWith("@users.noreply.github.com"), 148 `${file}: Owner email should not be a GitHub no-reply email` 149 ); 150 } 151 152 t.true(Object.keys(data.records).length > 0, `${file}: Missing DNS records`); 153 154 for (const field of blockedFields) { 155 t.true(!data.hasOwnProperty(field), `${file}: Disallowed field: ${field}`); 156 } 157 } 158 159 t("JSON files should not be in the root directory", (t) => { 160 const rootFiles = fs 161 .readdirSync(path.resolve()) 162 .filter((file) => file.endsWith(".json") && !ignoredRootJSONFiles.includes(file)); 163 t.is(rootFiles.length, 0, "JSON files should not be in the root directory"); 164 }); 165 166 t("All files should be valid JSON", async (t) => { 167 await Promise.all( 168 files.map((file) => { 169 return t.notThrows(() => fs.readJson(path.join(domainsPath, file)), `${file}: Invalid JSON file`); 170 }) 171 ); 172 }); 173 174 t("All files should have valid file names", async (t) => { 175 await Promise.all(files.map((file) => validateFileName(t, file))); 176 }); 177 178 t("All files should have valid required and optional fields", async (t) => { 179 await Promise.all(files.map((file) => processFile(file, t))); 180 });