diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9d669ede..c37cb072a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 - run: npm install @@ -39,11 +42,16 @@ jobs: - name: Get changed files if: github.event_name == 'pull_request' - run: | - CHANGED_FILES=$(gh pr view ${{ github.event.pull_request.number }} --json files -q '.files[].path' | jq -c .) - echo "CHANGED_FILES=$CHANGED_FILES" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@v6 + with: + script: | + const { execSync } = require("child_process") + const { commits } = context.payload.pull_request + const rawFiles = execSync(`git diff --name-only HEAD HEAD~${commits}`).toString() + const files = rawFiles.split("\n").filter(Boolean) + console.log(files) + const jsonArray = JSON.stringify(files) + core.exportVariable('CHANGED_FILES', jsonArray) - name: Run tests run: npx ava tests/*.test.js diff --git a/tests/json.test.js b/tests/json.test.js index 847cf0259..893cabfe7 100644 --- a/tests/json.test.js +++ b/tests/json.test.js @@ -1,35 +1,35 @@ const t = require("ava"); const fs = require("fs-extra"); const path = require("path"); +const { promisify } = require("util"); const ignoredRootJSONFiles = ["package-lock.json", "package.json"]; const requiredFields = { owner: "object", - record: "object", + record: "object" }; const optionalFields = { proxied: "boolean", - redirect_config: "object", + redirect_config: "object" }; const requiredOwnerFields = { - username: "string", + username: "string" }; const optionalOwnerFields = { - email: "string", + email: "string" }; const optionalRedirectConfigFields = { custom_paths: "object", - redirect_paths: "boolean", + 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 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.json"); const reservedDomains = require("../util/reserved.json"); @@ -53,9 +53,7 @@ function expandReservedDomains(reserved) { } expandedList.splice(expandedList.indexOf(item), 1); } else { - throw new Error( - `[util/reserved.json] Invalid range [${start}-${end}] in "${item}"`, - ); + throw new Error(`[util/reserved.json] Invalid range [${start}-${end}] in "${item}"`); } } }); @@ -70,163 +68,110 @@ function findDuplicateKeys(jsonString) { const keys = []; let match; - // Find all keys in the JSON string while ((match = keyPattern.exec(jsonString)) !== null) { keys.push(match[1]); } - // Count occurrences of each key const keyCount = {}; keys.forEach((key) => { keyCount[key] = (keyCount[key] || 0) + 1; }); - // Return keys that occur more than once return Object.keys(keyCount).filter((key) => keyCount[key] > 1); } -function validateFields(t, obj, fields, file, prefix = "") { - Object.keys(fields).forEach((key) => { +async function validateFields(t, obj, fields, file, prefix = "") { + for (const key of Object.keys(fields)) { const fieldPath = prefix ? `${prefix}.${key}` : key; if (obj.hasOwnProperty(key)) { - t.is( - typeof obj[key], - fields[key], - `${file}: Field ${fieldPath} should be of type ${fields[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`, - ); +async 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.`, + `${file}: FQDN must be 1-253 characters, use letters, numbers, dots, or hyphens, and not start or end with a hyphen.` ); - t.false( - expandedReservedDomains.includes(subdomain), - `${file}: Subdomain name is reserved`, - ); - // Disallow nested subdomains above reserved domains + t.false(expandedReservedDomains.includes(subdomain), `${file}: Subdomain name is reserved`); t.true( - !expandedReservedDomains.some((reserved) => - subdomain.endsWith(`.${reserved}`), - ), - `${file}: Subdomain name is reserved`, + !expandedReservedDomains.some((reserved) => subdomain.endsWith(`.${reserved}`)), + `${file}: Subdomain name is reserved` ); const rootSubdomain = subdomain.split(".").pop(); if (!exceptedDomains.includes(rootSubdomain)) { - t.false( - rootSubdomain.startsWith("_"), - `${file}: Root subdomains should not start with an underscore`, - ); + t.false(rootSubdomain.startsWith("_"), `${file}: Root subdomains should not start with an underscore`); } } } +async function processFile(file) { + const filePath = path.join(domainsPath, file); + const data = await fs.readJson(filePath); + + validateFileName(t, file); + + // Validate fields and duplicates + validateFields(t, data, requiredFields, file); + validateFields(t, data.owner, requiredOwnerFields, file, "owner"); + validateFields(t, data.owner, optionalOwnerFields, file, "owner"); + validateFields(t, data, optionalFields, file); + + if (data.redirect_config) { + validateFields(t, data.redirect_config, optionalRedirectConfigFields, file, "redirect_config"); + } + + if (data.owner.email) { + t.regex(data.owner.email, emailRegex, `${file}: Owner email should be a valid email address`); + t.false( + data.owner.email.endsWith("@users.noreply.github.com"), + `${file}: Owner email should not be a GitHub no-reply email` + ); + } + + t.true(Object.keys(data.record).length > 0, `${file}: Missing DNS records`); + + // Check for duplicate keys + const rawData = await fs.readFile(filePath, "utf8"); + const duplicateKeys = findDuplicateKeys(rawData); + t.true(!duplicateKeys.length, `${file}: Duplicate keys found: ${duplicateKeys.join(", ")}`); +} + 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), - ); + .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`, - ); - }); +t("All files should be valid JSON", async (t) => { + await Promise.all( + files.map((file) => { + return t.notThrows(() => fs.readJson(path.join(domainsPath, file)), `${file}: Invalid JSON file`); + }) + ); }); -t("All files should not have duplicate keys", (t) => { - files.forEach((file) => { - // Parse JSON as a string because JS automatically gets the last key if there are duplicates - const rawData = fs.readFileSync(`${domainsPath}/${file}`, "utf8"); - const duplicateKeys = findDuplicateKeys(rawData); - - t.true( - !duplicateKeys.length, - `${file}: Duplicate keys found: ${duplicateKeys.join(", ")}`, - ); - }); +t("All files should have valid file names", async (t) => { + await Promise.all(files.map((file) => validateFileName(t, file))); }); -t("All files should have valid file names", (t) => { - files.forEach((file) => { - validateFileName(t, file); - }); -}); - -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 - validateFields(t, data, requiredFields, 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`, - ); - t.false( - data.owner.email.endsWith("@users.noreply.github.com"), - `${file}: Owner email should not be a GitHub no-reply email`, - ); - } - - // Ensure 'record' field is not empty - t.true( - Object.keys(data.record).length > 0, - `${file}: Missing DNS records`, - ); - }); +t("All files should have valid required and optional fields", async (t) => { + await Promise.all(files.map((file) => processFile(file))); }); t("Reserved domains file should be valid", (t) => { @@ -236,7 +181,7 @@ t("Reserved domains file should be valid", (t) => { t.regex( item, subdomainRegex, - `[util/reserved-domains.json] Invalid subdomain name "${item}" at index ${index}`, + `[util/reserved-domains.json] Invalid subdomain name "${item}" at index ${index}` ); });