core/steps/probe.step.ts

core/steps/probe.step.ts is a file in Bane's Lab Deploy. 197 lines of code and 57 definitions.

import type {
    CatalogRow,
    Journal,
    ProbeExpectation,
    ProbeFetcher,
    ProbeResponse,
    Shell,
} from "#types/deployment.types";
import { LINE_BREAK, PATH_SEPARATOR } from "#configuration/constants/deployment.constants";
import {
    PROBE_ANSWERED,
    PROBE_FAILED,
    PROBE_INSTEAD,
    PROBE_LOGGED,
    PROBE_NO_RECORD,
    PROBE_PASSED,
    PROBE_STALE,
    PROBING_SITE,
} from "#configuration/strings/deployment.strings";
import {
    QUERY_ENDPOINT,
    RECORDS_SEGMENT,
    SOURCE_SEGMENT,
} from "@banes-lab/build-scripts/configuration/constants/catalog.constants.ts";
import { idsIndex, siteIndex } from "@banes-lab/build-scripts/core/resolvers/catalog.resolver.ts";
import { logSize, loggedSince } from "#core/adapters/server.adapter";
import { rootRows, shardAddresses, shardRow } from "#core/converters/catalog.converter";
import { Buffer } from "node:buffer";
import { COLLECTION_TITLES } from "@banes-lab/web/strings/catalog.strings";
import { JSON_ROUTE } from "@banes-lab/build-scripts/configuration/constants/site.constants.ts";
import { absolutePath } from "@ssot/paths";
import { digestOf } from "@govlab/content-fingerprint";
import { errorLogOf } from "#core/converters/nginx.converter";
import { fetchProbe } from "#core/adapters/site.adapter";
import { readFileSync } from "node:fs";

const JSON_TYPE = "application/json";
const MARKDOWN_TYPE = "text/markdown";
const TEXT_TYPE = "text/plain";
const ANY_TYPE = "";
const GET = "GET";
const HEAD = "HEAD";
const OPTIONS = "OPTIONS";
const OK = 200;
const NO_CONTENT = 204;
const NOT_FOUND = 404;
const BAD_REQUEST = 400;
const QUERY_PROBE = "?ref=api:route";
const STATIC_QUERY_PROBE = "?probe=1";
const MISSING_SEGMENT = "no-such-address";
const MARKDOWN_SUFFIX = ".md";
const TEXT_SUFFIX = ".txt";
const JSON_SUFFIX = ".json";
const SPACE = " ";
const STOP = ".";
const QUERY_MARK = "?";
const REF_SEPARATOR = ":";
const WILDCARD = "*";
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
const ENCODING = "utf8";

const answer = function answer(status: number, type: string): string {
    return (String(status) + SPACE + type).trim();
};

const mismatch = function mismatch(expected: ProbeExpectation, response: ProbeResponse): string | null {
    if (response.status === expected.status && response.type.startsWith(expected.type)) {
        return null;
    }
    const found = answer(response.status, response.type);
    return expected.address + PROBE_ANSWERED + found + PROBE_INSTEAD + answer(expected.status, expected.type) + STOP;
};

const expectation = function expectation(
    address: string,
    method: string,
    status: number,
    type: string,
): ProbeExpectation {
    return { address, method, status, type };
};

const behaviors = function behaviors(site: string): readonly ProbeExpectation[] {
    const root = siteIndex();
    const route = (root.markdown ?? MARKDOWN_SUFFIX).slice(0, -MARKDOWN_SUFFIX.length);
    return [
        expectation(site + route, GET, OK, MARKDOWN_TYPE),
        expectation(site + root.json + PATH_SEPARATOR, GET, OK, JSON_TYPE),
        expectation(site + root.json, OPTIONS, NO_CONTENT, ANY_TYPE),
        expectation(site + root.json + PATH_SEPARATOR + MISSING_SEGMENT, GET, NOT_FOUND, JSON_TYPE),
        expectation(site + PATH_SEPARATOR + MISSING_SEGMENT + MARKDOWN_SUFFIX, GET, NOT_FOUND, MARKDOWN_TYPE),
        expectation(site + PATH_SEPARATOR + MISSING_SEGMENT + TEXT_SUFFIX, GET, NOT_FOUND, TEXT_TYPE),
        expectation(site + PATH_SEPARATOR + MISSING_SEGMENT + JSON_SUFFIX, GET, NOT_FOUND, JSON_TYPE),
        expectation(site + JSON_ROUTE + RECORDS_SEGMENT, GET, OK, JSON_TYPE),
        expectation(site + JSON_ROUTE + SOURCE_SEGMENT, GET, OK, JSON_TYPE),
        expectation(site + QUERY_ENDPOINT + QUERY_PROBE, GET, OK, JSON_TYPE),
        expectation(site + QUERY_ENDPOINT, GET, BAD_REQUEST, JSON_TYPE),
        expectation(site + root.json + STATIC_QUERY_PROBE, GET, BAD_REQUEST, JSON_TYPE),
    ];
};

const groupOf = function groupOf(ref: string): string {
    return ref.slice(0, ref.indexOf(REF_SEPARATOR));
};

const firstWord = function firstWord(title: string): string {
    let word = "";
    for (const character of title.toLowerCase()) {
        if (!LETTERS.includes(character)) {
            break;
        }
        word += character;
    }
    return word;
};

const queryProbes = function queryProbes(
    site: string,
    rows: readonly CatalogRow[],
): readonly ProbeExpectation[] | null {
    const record = rows.find((row) => row.ref !== null && COLLECTION_TITLES.has(groupOf(row.ref)));
    if (record?.ref === null || record?.ref === undefined || record.title === null) {
        return null;
    }
    const group = groupOf(record.ref);
    const queries = [
        { ref: record.ref },
        { name: record.title },
        { q: firstWord(record.title) },
        { id: group + REF_SEPARATOR + WILDCARD },
        { walk: record.ref },
        { collection: group },
    ];
    return queries.map((query) =>
        expectation(site + QUERY_ENDPOINT + QUERY_MARK + new URLSearchParams(query).toString(), GET, OK, JSON_TYPE),
    );
};

const parsed = function parsed(response: ProbeResponse): unknown {
    return response.status === OK ? JSON.parse(response.body) : null;
};

const isStale = function isStale(row: CatalogRow, response: ProbeResponse): boolean {
    if (response.status !== OK) {
        return false;
    }
    return Buffer.byteLength(response.body) !== row.bytes || digestOf(response.body) !== row.fingerprint;
};

const rowProblems = async function rowProblems(row: CatalogRow, fetcher: ProbeFetcher): Promise<readonly string[]> {
    const json = await fetcher(row.json, GET);
    const markdown = row.markdown === null ? null : await fetcher(row.markdown, HEAD);
    const found = [
        mismatch(expectation(row.json, GET, OK, JSON_TYPE), json),
        isStale(row, json) ? row.json + PROBE_STALE : null,
        markdown === null || row.markdown === null
            ? null
            : mismatch(expectation(row.markdown, HEAD, OK, MARKDOWN_TYPE), markdown),
    ];
    return found.filter((problem): problem is string => problem !== null);
};

const behaviorProblem = async function behaviorProblem(
    expected: ProbeExpectation,
    fetcher: ProbeFetcher,
): Promise<string | null> {
    return mismatch(expected, await fetcher(expected.address, expected.method));
};

export const probeSite = async function probeSite(
    journal: Journal,
    site: string,
    shell: Shell,
    fetcher: ProbeFetcher = fetchProbe,
): Promise<void> {
    journal.log(PROBING_SITE);
    const log = errorLogOf(readFileSync(absolutePath("app.nginxSite"), ENCODING));
    const offset = await logSize(shell, log);
    const rootAddress = site + siteIndex().json;
    const idsAddress = site + idsIndex().json;
    const [root, ids] = await Promise.all([fetcher(rootAddress, GET), fetcher(idsAddress, GET)]);
    const shards = await Promise.all(shardAddresses(parsed(ids)).map(async (address) => fetcher(address, GET)));
    const rows = [
        ...rootRows(parsed(root)),
        ...shards.flatMap((shard) => {
            const row = shardRow(parsed(shard));
            return row === null ? [] : [row];
        }),
    ];
    const queries = queryProbes(site, rows);
    const heads = [
        mismatch(expectation(rootAddress, GET, OK, JSON_TYPE), root),
        mismatch(expectation(idsAddress, GET, OK, JSON_TYPE), ids),
        queries === null ? PROBE_NO_RECORD : null,
        ...(await Promise.all(
            [...behaviors(site), ...(queries ?? [])].map(async (expected) => behaviorProblem(expected, fetcher)),
        )),
    ];
    const rowFindings = (await Promise.all(rows.map(async (row) => rowProblems(row, fetcher)))).flat();
    const logged = await loggedSince(shell, log, offset);
    const problems = [
        ...heads.filter((problem): problem is string => problem !== null),
        ...rowFindings,
        ...(logged.length === 0 ? [] : [PROBE_LOGGED + logged]),
    ];
    if (problems.length > 0) {
        throw new Error(PROBE_FAILED + LINE_BREAK + problems.join(LINE_BREAK));
    }
    journal.mark(PROBE_PASSED);
};