Source: routes/system.js

/**
 * @module routes/system
 * @description System info, activities, data management, and URL reachability. Mounted at `/api/v1` (INF-005).
 *
 * All queries are scoped to the authenticated user's workspace (ACL-001).
 *
 * ### Endpoints
 * | Method   | Path                          | Description                                | Min Role  |
 * |----------|-------------------------------|--------------------------------------------|-----------|
 * | `GET`    | `/api/v1/activities`          | Activity log (filterable by type, project)  | viewer    |
 * | `POST`   | `/api/v1/test-connection`     | Verify a URL is reachable (SSRF-protected)  | qa_lead   |
 * | `GET`    | `/api/v1/system`              | Uptime, Node/Playwright versions, DB counts | viewer    |
 * | `POST`   | `/api/v1/system/client-error` | Log a frontend crash report                 | viewer    |
 * | `DELETE` | `/api/v1/data/runs`           | Clear all run history (incl. soft-deleted)  | admin     |
 * | `DELETE` | `/api/v1/data/activities`     | Clear activity log                          | admin     |
 * | `DELETE` | `/api/v1/data/healing`        | Clear self-healing history                  | admin     |
 */

import { Router } from "express";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import * as activityRepo from "../database/repositories/activityRepo.js";
import * as healingRepo from "../database/repositories/healingRepo.js";
import { logActivity } from "../utils/activityLogger.js";
import { actor } from "../utils/actor.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { activeTaskCount } from "../scheduler.js";
import { requireRole } from "../middleware/requireRole.js";

const router = Router();

// ─── Activities ───────────────────────────────────────────────────────────────

router.get("/activities", (req, res) => {
  const limit = parseInt(req.query.limit, 10) || 200;
  const activities = activityRepo.getFiltered({
    type: req.query.type || undefined,
    projectId: req.query.projectId || undefined,
    workspaceId: req.workspaceId,
    limit,
  });
  res.json(activities);
});

// ─── URL reachability test ────────────────────────────────────────────────────

router.post("/test-connection", requireRole("qa_lead"), async (req, res) => {
  const { url } = req.body;
  if (!url) return res.status(400).json({ error: "url is required" });
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return res.status(400).json({ error: "Invalid URL format" });
  }
  if (!["http:", "https:"].includes(parsed.protocol)) {
    return res.status(400).json({ error: "URL must use http or https protocol" });
  }
  // SSRF protection: block loopback, link-local, and private IP ranges.
  //
  // Dev escape hatch — when `ALLOW_PRIVATE_URLS=true`, skip the SSRF check so
  // developers can Test against `http://localhost:3000`, Dockerised stacks, or
  // internal staging hostnames (mirrors the `SKIP_EMAIL_VERIFICATION` pattern).
  // Never set this in production — it permits SSRF to cloud metadata endpoints
  // (169.254.169.254), databases on the local network, etc.
  if (process.env.ALLOW_PRIVATE_URLS === "true") {
    try {
      const response = await fetch(url, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(10000) });
      return res.json({ ok: true, status: response.status });
    } catch (err) {
      return res.status(502).json({ ok: false, error: err.message });
    }
  }
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");

  function extractMappedIPv4(host) {
    const dottedMatch = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
    if (dottedMatch) return dottedMatch[1];
    const hexMatch = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
    if (hexMatch) {
      const hi = parseInt(hexMatch[1], 16);
      const lo = parseInt(hexMatch[2], 16);
      return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
    }
    return null;
  }

  function isPrivateIPv4(ip) {
    return (
      /^127\.\d+\.\d+\.\d+$/.test(ip) ||
      /^10\.\d+\.\d+\.\d+$/.test(ip) ||
      /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/.test(ip) ||
      /^192\.168\.\d+\.\d+$/.test(ip) ||
      ip === "0.0.0.0" ||
      ip === "169.254.169.254"
    );
  }

  const mappedIPv4 = extractMappedIPv4(hostname);
  const blocked =
    hostname === "localhost" ||
    hostname.endsWith(".localhost") ||
    isPrivateIPv4(hostname) ||
    (mappedIPv4 && isPrivateIPv4(mappedIPv4)) ||
    hostname === "0.0.0.0" ||
    hostname === "::" ||
    hostname === "::1" ||
    (/^::ffff:/i.test(hostname) && mappedIPv4 === null) ||
    hostname === "169.254.169.254" ||
    hostname === "metadata.google.internal" ||
    hostname.endsWith(".internal") ||
    /^fe80:/i.test(hostname) ||
    /^fd[0-9a-f]{2}:/i.test(hostname) ||
    /^fc[0-9a-f]{2}:/i.test(hostname);
  if (blocked) {
    return res.status(400).json({ error: "URL must not point to localhost, private, or internal addresses" });
  }
  try {
    const response = await fetch(url, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(10000) });
    res.json({ ok: true, status: response.status });
  } catch (err) {
    res.status(502).json({ ok: false, error: err.message });
  }
});

// ─── System Info ──────────────────────────────────────────────────────────────

router.get("/system", async (req, res) => {
  let playwrightVersion = null;
  try {
    const pwPkg = await import("playwright/package.json", { with: { type: "json" } }).catch(() => null);
    playwrightVersion = pwPkg?.default?.version || null;
  } catch { /* ignore */ }

  if (!playwrightVersion) {
    try {
      const { createRequire } = await import("module");
      const require = createRequire(import.meta.url);
      const pwPkg = require("playwright/package.json");
      playwrightVersion = pwPkg.version;
    } catch { /* ignore */ }
  }

  const projects = projectRepo.getAll(req.workspaceId);
  const projectIds = projects.map((p) => p.id);
  // Use SQL-level counts instead of loading all test rows into memory.
  const testCount = testRepo.countByProjectIds(projectIds);
  const approvedTests = testRepo.countApprovedByProjectIds(projectIds);
  const draftTests = testRepo.countDraftByProjectIds(projectIds);
  // Healing counts need test IDs — use the lightweight ID-only query.
  const testIds = testRepo.getAllIdsByProjectIdsIncludeDeleted(projectIds);

  const projectCount = projects.length;
  const runCount = runRepo.countByProjectIds(projectIds);
  const activityCount = activityRepo.countFiltered({ workspaceId: req.workspaceId });
  const healingEntries = healingRepo.countByTestIds(testIds);

  res.json({
    projects:     projectCount,
    tests:        testCount,
    runs:         runCount,
    activities:   activityCount,
    healingEntries,
    approvedTests,
    draftTests,
    uptime:        Math.floor(process.uptime()),
    nodeVersion:   process.version,
    playwrightVersion,
    memoryMB:      Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
    activeSchedules: activeTaskCount(),
  });
});

// ─── Client error reporting ───────────────────────────────────────────────────
// Receives crash reports from the frontend ErrorBoundary (componentDidCatch).
// Logs the error server-side so crashes are visible in backend logs even when
// the user doesn't report them. The endpoint intentionally does minimal work
// and always returns 200 — it must never throw back to the already-crashed UI.

router.post("/system/client-error", (req, res) => {
  const { message, stack, componentStack, url } = req.body || {};
  console.error(formatLogLine("error", null,
    `[client-error] ${message || "Unknown error"} at ${url || "unknown URL"}` +
    (stack ? `\n${stack}` : "") +
    (componentStack ? `\nComponent stack:${componentStack}` : ""),
  ));
  res.json({ ok: true });
});

// ─── Data Management ──────────────────────────────────────────────────────────

router.delete("/data/runs", requireRole("admin"), (req, res) => {
  const projects = projectRepo.getAllIncludeDeleted(req.workspaceId);
  const count = projects.reduce((sum, p) => sum + runRepo.hardDeleteByProjectId(p.id).length, 0);
  logActivity({ ...actor(req), type: "settings.update", detail: `Cleared ${count} run(s)` });
  res.json({ ok: true, cleared: count });
});

router.delete("/data/activities", requireRole("admin"), (req, res) => {
  const count = activityRepo.clearByWorkspaceId(req.workspaceId);
  res.json({ ok: true, cleared: count });
});

router.delete("/data/healing", requireRole("admin"), (req, res) => {
  const projectIds = projectRepo.getAllIncludeDeleted(req.workspaceId).map((p) => p.id);
  const testIds = testRepo.getAllIdsByProjectIdsIncludeDeleted(projectIds);
  const count = healingRepo.countByTestIds(testIds);
  healingRepo.deleteByTestIds(testIds);
  logActivity({ ...actor(req), type: "settings.update", detail: `Cleared ${count} healing history entries` });
  res.json({ ok: true, cleared: count });
});

export default router;