feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { migrationState, workerState } from "./installation-state";
|
||||
|
||||
it("distinguishes missing, malformed and stale worker evidence", () => {
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
expect(workerState(null, now)).toBe("unknown");
|
||||
expect(workerState("bad", now)).toBe("unknown");
|
||||
expect(workerState("2026-09-09T11:59:30Z", now)).toBe("ok");
|
||||
expect(workerState("2026-09-09T11:55:00Z", now)).toBe("failed");
|
||||
});
|
||||
it("never treats missing migration history as up to date", () => {
|
||||
expect(migrationState(["0026"], null)).toEqual({
|
||||
state: "unknown",
|
||||
pending: null,
|
||||
});
|
||||
expect(migrationState(["0025", "0026"], ["0025"])).toEqual({
|
||||
state: "failed",
|
||||
pending: 1,
|
||||
});
|
||||
expect(migrationState(["0026"], ["0026"])).toEqual({
|
||||
state: "ok",
|
||||
pending: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type DiagnosticState = "ok" | "failed" | "unknown" | "missing";
|
||||
export function workerState(
|
||||
value: string | null,
|
||||
now = Date.now(),
|
||||
): DiagnosticState {
|
||||
if (!value) return "unknown";
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time) || time > now + 60000) return "unknown";
|
||||
return now - time <= 120000 ? "ok" : "failed";
|
||||
}
|
||||
export function migrationState(
|
||||
expected: string[],
|
||||
applied: string[] | null,
|
||||
): { state: DiagnosticState; pending: number | null } {
|
||||
if (!expected.length || applied === null)
|
||||
return { state: "unknown", pending: null };
|
||||
const done = new Set(applied);
|
||||
const pending = expected.filter((name) => !done.has(name)).length;
|
||||
return { state: pending ? "failed" : "ok", pending };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import "server-only";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
type DiagnosticState,
|
||||
migrationState,
|
||||
workerState,
|
||||
} from "@/lib/admin/installation-state";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { db } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function inspectInstallation() {
|
||||
const started = performance.now();
|
||||
const [health, storage, expected, applied, heartbeat, renderer] =
|
||||
await Promise.all([
|
||||
fetchOpsHealth(),
|
||||
Promise.all(
|
||||
["storage", "public/nitro-assets", "public/swf"].map(async (name) => ({
|
||||
name,
|
||||
state: await access(
|
||||
path.join(process.cwd(), name),
|
||||
constants.R_OK | constants.W_OK,
|
||||
)
|
||||
.then(() => "ok" as const)
|
||||
.catch(() => "failed" as const),
|
||||
})),
|
||||
),
|
||||
readdir(path.join(process.cwd(), "drizzle/migrations"))
|
||||
.then((files) =>
|
||||
files
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.map((name) => name.slice(0, -4)),
|
||||
)
|
||||
.catch(() => []),
|
||||
db
|
||||
.execute(sql`SELECT migration FROM cms_migrations`)
|
||||
.then(([rows]) =>
|
||||
(rows as unknown as { migration: string }[]).map(
|
||||
(row) => row.migration,
|
||||
),
|
||||
)
|
||||
.catch(() => null),
|
||||
redis
|
||||
? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
siteSettings.get("nitro_client_url", "").catch(() => ""),
|
||||
]);
|
||||
const migration = migrationState(expected, applied);
|
||||
const release = process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown";
|
||||
const rows: Array<{ key: string; state: DiagnosticState; detail?: string }> =
|
||||
[
|
||||
{
|
||||
key: "release",
|
||||
state: /^[a-f0-9]{40}$/i.test(release) ? "ok" : "unknown",
|
||||
detail: /^[a-f0-9]{40}$/i.test(release) ? release : undefined,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
state: health.dbOk ? "ok" : "failed",
|
||||
detail: health.dbOk ? `${health.dbLatencyMs} ms` : undefined,
|
||||
},
|
||||
{
|
||||
key: "redis",
|
||||
state:
|
||||
health.redisOk === null
|
||||
? "missing"
|
||||
: health.redisOk
|
||||
? "ok"
|
||||
: "failed",
|
||||
},
|
||||
{ key: "emulator", state: health.emulatorOk ? "ok" : "failed" },
|
||||
{
|
||||
key: "storage",
|
||||
state: storage.every((item) => item.state === "ok") ? "ok" : "failed",
|
||||
detail:
|
||||
storage
|
||||
.filter((item) => item.state !== "ok")
|
||||
.map((item) => item.name)
|
||||
.join(", ") || undefined,
|
||||
},
|
||||
{
|
||||
key: "migrations",
|
||||
state: migration.state,
|
||||
detail:
|
||||
migration.pending === null ? undefined : String(migration.pending),
|
||||
},
|
||||
{
|
||||
key: "worker",
|
||||
state: workerState(heartbeat),
|
||||
detail:
|
||||
heartbeat && Number.isFinite(Date.parse(heartbeat))
|
||||
? new Date(heartbeat).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "renderer",
|
||||
state: health.dbOk ? "ok" : "unknown",
|
||||
detail: health.dbOk && !renderer ? "/nitro-client/" : undefined,
|
||||
},
|
||||
{ key: "registry", state: "unknown" },
|
||||
];
|
||||
return {
|
||||
rows,
|
||||
checkedAt: new Date().toISOString(),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { collectOperations } from "./operations-inbox";
|
||||
|
||||
it("does not load unauthorized sources and reports failed sources separately", async () => {
|
||||
const denied = vi.fn();
|
||||
const result = await collectOperations([
|
||||
{ source: "errors", allowed: false, load: denied },
|
||||
{
|
||||
source: "imports",
|
||||
allowed: true,
|
||||
load: async () => {
|
||||
throw Error("offline");
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(denied).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ items: [], unavailable: ["imports"] });
|
||||
});
|
||||
it("deduplicates, prioritizes and excludes empty work", async () => {
|
||||
const item = {
|
||||
id: "one",
|
||||
source: "errors" as const,
|
||||
href: "/admin/devops/cms-errors",
|
||||
count: 2,
|
||||
severity: 3,
|
||||
};
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: true,
|
||||
load: async () => [item, item, { ...item, id: "empty", count: 0 }],
|
||||
},
|
||||
]);
|
||||
expect(result.items).toEqual([item]);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type OperationsSource =
|
||||
| "errors"
|
||||
| "imports"
|
||||
| "tickets"
|
||||
| "publications";
|
||||
export interface OperationsItem {
|
||||
id: string;
|
||||
source: OperationsSource;
|
||||
href: string;
|
||||
count: number;
|
||||
severity: number;
|
||||
}
|
||||
export interface OperationsLoader {
|
||||
source: OperationsSource;
|
||||
allowed: boolean;
|
||||
load: () => Promise<OperationsItem[]>;
|
||||
}
|
||||
/** Authorization precedes loading; a failed source is never reported as empty. */
|
||||
export async function collectOperations(loaders: OperationsLoader[]) {
|
||||
const available = loaders.filter((loader) => loader.allowed);
|
||||
const results = await Promise.allSettled(
|
||||
available.map((loader) => loader.load()),
|
||||
);
|
||||
const unique = new Map<string, OperationsItem>();
|
||||
const unavailable: OperationsSource[] = [];
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
unavailable.push(available[index].source);
|
||||
continue;
|
||||
}
|
||||
for (const item of result.value) {
|
||||
const key = `${item.source}:${item.id}`;
|
||||
if (item.count > 0 && !unique.has(key)) unique.set(key, item);
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [...unique.values()]
|
||||
.sort((a, b) => b.severity - a.severity || a.id.localeCompare(b.id))
|
||||
.slice(0, 12),
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
+40
-37
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import { env } from "@/env";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -19,40 +20,42 @@ export type OpsHealth = {
|
||||
* Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs.
|
||||
* Semantics match `/api/health` for Redis (null when not configured).
|
||||
*/
|
||||
export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
}
|
||||
})(),
|
||||
(async (): Promise<boolean | null> => {
|
||||
if (!env.REDIS_URL) return null;
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const pong = await redis.ping();
|
||||
return pong === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return {
|
||||
dbOk: dbProbe.dbOk,
|
||||
dbLatencyMs: dbProbe.dbLatencyMs,
|
||||
redisOk,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
}
|
||||
export const fetchOpsHealth = cache(
|
||||
async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
}
|
||||
})(),
|
||||
(async (): Promise<boolean | null> => {
|
||||
if (!env.REDIS_URL) return null;
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const pong = await redis.ping();
|
||||
return pong === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return {
|
||||
dbOk: dbProbe.dbOk,
|
||||
dbLatencyMs: dbProbe.dbLatencyMs,
|
||||
redisOk,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleKey, readArticleDraft } from "./article-draft";
|
||||
import { articleEditToken } from "./article-edit-token";
|
||||
|
||||
it("allows incomplete drafts without requiring publication-ready fields", () => {
|
||||
const form = new FormData();
|
||||
form.set("status", "scheduled");
|
||||
expect(readArticleDraft(form)).toMatchObject({
|
||||
title: "",
|
||||
fullStory: "",
|
||||
publishAt: "",
|
||||
status: "scheduled",
|
||||
});
|
||||
});
|
||||
it("rejects unbounded or malformed recovery keys", () => {
|
||||
for (const key of ["", "../1", "0", "-1", "1e5", "1".repeat(21)])
|
||||
expect(() => articleKey(key)).toThrow();
|
||||
expect(articleKey("new")).toBe("new");
|
||||
expect(articleKey("123")).toBe("123");
|
||||
});
|
||||
it("detects body and scheduled instant changes even within the same clock second", () => {
|
||||
const row = {
|
||||
title: "News",
|
||||
slug: "news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "old",
|
||||
status: "scheduled",
|
||||
publishAt: new Date("2030-01-01T00:00:00Z"),
|
||||
};
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, fullStory: "new" }),
|
||||
);
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, publishAt: new Date("2030-01-01T01:00:00Z") }),
|
||||
);
|
||||
expect(articleEditToken(row)).toBe(articleEditToken({ ...row }));
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ArticleDraft = {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: string;
|
||||
baseToken: string;
|
||||
};
|
||||
export function readArticleDraft(form: FormData): ArticleDraft {
|
||||
const keys = [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"baseToken",
|
||||
] as const;
|
||||
const result = {} as ArticleDraft;
|
||||
for (const key of keys) {
|
||||
const value = form.get(key) ?? "";
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > (key === "fullStory" ? 500_000 : 255)
|
||||
)
|
||||
throw new Error("draftInvalid");
|
||||
result[key] = value;
|
||||
}
|
||||
if (!["draft", "scheduled", "published"].includes(result.status))
|
||||
throw new Error("draftInvalid");
|
||||
return result;
|
||||
}
|
||||
export function articleKey(value: string): string {
|
||||
if (value === "new" || /^[1-9]\d{0,19}$/.test(value)) return value;
|
||||
throw new Error("draftInvalid");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHash } from "node:crypto";
|
||||
export function articleEditToken(article: {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: Date | null;
|
||||
}) {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
article.title,
|
||||
article.slug,
|
||||
article.image,
|
||||
article.shortStory,
|
||||
article.fullStory,
|
||||
article.status,
|
||||
article.publishAt?.toISOString() ?? "",
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -3,6 +3,8 @@ export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "articleNotFound"
|
||||
| "editConflict"
|
||||
| "titleRequired"
|
||||
| "titleTooLong"
|
||||
| "summaryTooLong"
|
||||
|
||||
@@ -78,10 +78,22 @@ describe("deployment transaction", () => {
|
||||
result.calls.indexOf("docker stop"),
|
||||
);
|
||||
expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan(
|
||||
result.calls.indexOf("docker tag sha256:new epicnext-cms:latest"),
|
||||
);
|
||||
expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan(
|
||||
result.calls.indexOf(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
),
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
`docker image rm epicnext-cms:verified-${"c".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${"b".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
"docker tag sha256:old epicnext-cms:previous",
|
||||
);
|
||||
@@ -97,9 +109,12 @@ describe("deployment transaction", () => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.app).toBe("old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.output).toContain("Rollback verified: sha256:old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
"docker tag sha256:new epicnext-cms:latest",
|
||||
);
|
||||
},
|
||||
30000,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { errorOperationLink, summarizeErrorGroup } from "./error-groups";
|
||||
import { createErrorRecord } from "./error-monitor";
|
||||
|
||||
it("counts retained occurrences and identified users across releases without trusting browser identities", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", { userId: 12 });
|
||||
const first = { ...base, at: "2026-09-08T10:00:00Z", release: "a" };
|
||||
const latest = { ...base, at: "2026-09-09T10:00:00Z", release: "b" };
|
||||
const browser = {
|
||||
...latest,
|
||||
source: "browser" as const,
|
||||
context: { userId: 99 },
|
||||
};
|
||||
const summary = summarizeErrorGroup([
|
||||
latest,
|
||||
browser,
|
||||
first,
|
||||
{ ...latest, context: { userId: "12" } },
|
||||
{ ...latest, context: { email: "redacted", userId: "[REDACTED]" } },
|
||||
]);
|
||||
expect(summary.count).toBe(5);
|
||||
expect(summary.firstAt).toBe(first.at);
|
||||
expect(summary.lastAt).toBe(latest.at);
|
||||
expect(summary.affectedUsers).toBe(1);
|
||||
expect(summary.unidentified).toBe(2);
|
||||
expect(summary.releases.map((r) => [r.release, r.count])).toEqual([
|
||||
["a", 1],
|
||||
["b", 4],
|
||||
]);
|
||||
});
|
||||
it("creates only recognized local operation links", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", {
|
||||
module: "news",
|
||||
articleId: 7,
|
||||
});
|
||||
expect(errorOperationLink(base)).toBe("/admin/articles/7");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
context: { module: "news", articleId: "../settings" },
|
||||
}),
|
||||
).toBe("/admin/articles");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
source: "browser",
|
||||
context: { path: "/admin/media" },
|
||||
}),
|
||||
).toBe("/admin/media");
|
||||
for (const path of [
|
||||
"https://evil.invalid",
|
||||
"//evil.invalid",
|
||||
"/admin/articles/1?token=x",
|
||||
"/admin/articles/../settings",
|
||||
"/admin/users/42",
|
||||
"javascript:alert(1)",
|
||||
"/admin/%61rticles",
|
||||
])
|
||||
expect(errorOperationLink({ ...base, context: { path } })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CmsErrorRecord } from "./error-monitor";
|
||||
|
||||
export function errorOperationLink(record: CmsErrorRecord): string | null {
|
||||
const context = record.context;
|
||||
if (record.source === "server") {
|
||||
if (context.module === "news") {
|
||||
const id = String(context.articleId ?? "");
|
||||
return /^[1-9]\d{0,18}$/.test(id)
|
||||
? `/admin/articles/${id}`
|
||||
: "/admin/articles";
|
||||
}
|
||||
const routes: Record<string, string> = {
|
||||
catalog: "/admin/catalog",
|
||||
media: "/admin/media",
|
||||
users: "/admin/users",
|
||||
settings: "/admin/settings",
|
||||
studio: "/admin/studio",
|
||||
};
|
||||
if (
|
||||
typeof context.module === "string" &&
|
||||
Object.hasOwn(routes, context.module)
|
||||
)
|
||||
return routes[context.module];
|
||||
}
|
||||
// Never use arbitrary error-provided URLs as navigation targets.
|
||||
const candidate = context.path;
|
||||
if (typeof candidate !== "string") return null;
|
||||
if (
|
||||
/^\/admin\/(articles|users|catalog|media|settings|studio)$/.test(candidate)
|
||||
)
|
||||
return candidate;
|
||||
if (/^\/admin\/articles\/[1-9]\d{0,18}$/.test(candidate)) return candidate;
|
||||
return null;
|
||||
}
|
||||
export function summarizeErrorGroup(events: CmsErrorRecord[]) {
|
||||
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
|
||||
const first = sorted[0];
|
||||
const latest = sorted.at(-1);
|
||||
const users = new Set<string>();
|
||||
let unidentified = 0;
|
||||
const releases = new Map<
|
||||
string,
|
||||
{ release: string; count: number; firstAt: string; lastAt: string }
|
||||
>();
|
||||
for (const event of sorted) {
|
||||
const raw = event.source === "server" ? event.context.userId : null;
|
||||
const id =
|
||||
typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0
|
||||
? String(raw)
|
||||
: typeof raw === "string" && /^[1-9]\d{0,14}$/.test(raw)
|
||||
? raw
|
||||
: null;
|
||||
if (id) users.add(id);
|
||||
else unidentified++;
|
||||
const prior = releases.get(event.release);
|
||||
releases.set(event.release, {
|
||||
release: event.release,
|
||||
count: (prior?.count ?? 0) + 1,
|
||||
firstAt: prior?.firstAt ?? event.at,
|
||||
lastAt: event.at,
|
||||
});
|
||||
}
|
||||
return {
|
||||
count: events.length,
|
||||
firstAt: first?.at ?? null,
|
||||
lastAt: latest?.at ?? null,
|
||||
affectedUsers: users.size,
|
||||
unidentified,
|
||||
releases: [...releases.values()],
|
||||
latest,
|
||||
operationLink: latest ? errorOperationLink(latest) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -54,3 +54,50 @@ it("resolves a group and reopens it when a later occurrence arrives", async () =
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists assignments across instances and clears them without modifying resolution", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const store = new ErrorStore(dir);
|
||||
const record = createErrorRecord("server", "assign", "failed");
|
||||
await store.append(record);
|
||||
await store.resolve(record.fingerprint);
|
||||
await store.assign(record.fingerprint, 42);
|
||||
const persisted = (await new ErrorStore(dir).read()).records[0];
|
||||
expect(persisted.assignment?.userId).toBe(42);
|
||||
expect(persisted.resolved).toBe(true);
|
||||
await new ErrorStore(dir).assign(record.fingerprint, null);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
await expect(store.assign("../escape", 42)).rejects.toThrow(
|
||||
"Invalid fingerprint",
|
||||
);
|
||||
await expect(store.assign(record.fingerprint, -1)).rejects.toThrow(
|
||||
"Invalid assignee",
|
||||
);
|
||||
await expect(store.assign("0000000000000000", 42)).rejects.toThrow(
|
||||
"no longer available",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("ignores expired assignment metadata and prunes it with error retention", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const record = createErrorRecord("server", "retention", "failed");
|
||||
const expired = `2000-01-01.${record.fingerprint}.assignment`;
|
||||
await writeFile(
|
||||
path.join(dir, expired),
|
||||
JSON.stringify({ userId: 99, at: "2000-01-01T00:00:00Z" }),
|
||||
);
|
||||
const store = new ErrorStore(dir);
|
||||
await store.append(record);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
expect(await readdir(dir)).not.toContain(expired);
|
||||
const current = `${record.at.slice(0, 10)}.${record.fingerprint}.assignment`;
|
||||
await writeFile(path.join(dir, current), "x".repeat(513));
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
@@ -27,6 +28,7 @@ export function redactErrorText(value: string): string {
|
||||
export interface CmsErrorRecord {
|
||||
id: string;
|
||||
resolved?: boolean;
|
||||
assignment?: { userId: number; at: string } | null;
|
||||
at: string;
|
||||
source: "server" | "browser";
|
||||
event: string;
|
||||
@@ -111,7 +113,9 @@ export class ErrorStore {
|
||||
.slice(0, 10);
|
||||
for (const name of files)
|
||||
if (
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.(?:resolved|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.test(
|
||||
name,
|
||||
) &&
|
||||
name.slice(0, 10) < cutoff
|
||||
)
|
||||
await unlink(path.join(this.directory, name)).catch(() => {});
|
||||
@@ -135,6 +139,43 @@ export class ErrorStore {
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
async assign(fingerprint: string, userId: number | null) {
|
||||
if (!/^[a-f0-9]{16}$/.test(fingerprint))
|
||||
throw new Error("Invalid fingerprint");
|
||||
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
|
||||
throw new Error("Invalid assignee");
|
||||
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
|
||||
ErrorStore.pending++;
|
||||
const write = ErrorStore.queue.then(async () => {
|
||||
const { records } = await this.read();
|
||||
if (!records.some((record) => record.fingerprint === fingerprint))
|
||||
throw new Error("Error group no longer available");
|
||||
const at = new Date().toISOString();
|
||||
const file = path.join(
|
||||
this.directory,
|
||||
`${at.slice(0, 10)}.${fingerprint}.assignment`,
|
||||
);
|
||||
const metadata = (await readdir(this.directory)).filter(
|
||||
(name) =>
|
||||
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
|
||||
);
|
||||
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
|
||||
throw new Error("Daily assignment storage limit reached");
|
||||
const temp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
|
||||
await rename(temp, file);
|
||||
} finally {
|
||||
await unlink(temp).catch(() => {});
|
||||
}
|
||||
});
|
||||
ErrorStore.queue = write.catch(() => {});
|
||||
try {
|
||||
await write;
|
||||
} finally {
|
||||
ErrorStore.pending--;
|
||||
}
|
||||
}
|
||||
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
|
||||
const files = await readdir(this.directory).catch(
|
||||
(e: NodeJS.ErrnoException) => {
|
||||
@@ -142,17 +183,48 @@ export class ErrorStore {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const file = path.join(this.directory, name);
|
||||
if ((await stat(file)).size > 512) continue;
|
||||
try {
|
||||
const value = JSON.parse(await readFile(file, "utf8"));
|
||||
if (
|
||||
typeof value.at !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.at))
|
||||
)
|
||||
continue;
|
||||
if (value.userId === null) assignments.set(name.split(".")[1], null);
|
||||
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
|
||||
assignments.set(name.split(".")[1], {
|
||||
userId: value.userId,
|
||||
at: value.at,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore interrupted or malformed metadata. */
|
||||
}
|
||||
}
|
||||
const resolved = new Map<string, string>();
|
||||
for (const name of files
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const at = await readFile(path.join(this.directory, name), "utf8");
|
||||
resolved.set(name.split(".")[1], at);
|
||||
}
|
||||
const records: CmsErrorRecord[] = [];
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
|
||||
@@ -169,6 +241,7 @@ export class ErrorStore {
|
||||
records.push({
|
||||
...r,
|
||||
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
|
||||
assignment: assignments.get(r.fingerprint) ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore an interrupted final write. */
|
||||
|
||||
@@ -5,7 +5,14 @@ import { retryableJobItems } from "./import-job-retry";
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
items: [
|
||||
"done",
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
@@ -14,12 +21,33 @@ it("only retries failures after a completed job", () =>
|
||||
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
]));
|
||||
it("recovers all unfinished items from an interrupted job", () =>
|
||||
it("recovers only safe unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
|
||||
it("retries cancelled remaining work without redoing completed or uncertain items", () => {
|
||||
expect(retryableJobItems(job("cancelled")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not blindly retry legacy interrupted mutations stored as failures", () => {
|
||||
const interrupted = job("interrupted");
|
||||
interrupted.items = [
|
||||
{
|
||||
...interrupted.items[0],
|
||||
state: "failed",
|
||||
error:
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.",
|
||||
},
|
||||
];
|
||||
expect(retryableJobItems(interrupted)).toEqual([]);
|
||||
});
|
||||
@@ -4,7 +4,9 @@ export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||
if (job.state === "running" || job.state === "queued") return [];
|
||||
return job.items.filter(
|
||||
(item) =>
|
||||
item.state === "failed" ||
|
||||
(job.state === "interrupted" && item.state !== "done"),
|
||||
(item.state === "failed" &&
|
||||
!item.error?.startsWith("Server restarted during import.")) ||
|
||||
((job.state === "interrupted" || job.state === "cancelled") &&
|
||||
(item.state === "pending" || item.state === "cancelled")),
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,20 @@ export interface ImportJob {
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
state: "queued" | "running" | "completed" | "interrupted";
|
||||
state: "queued" | "running" | "completed" | "interrupted" | "cancelled";
|
||||
cancelRequested?: boolean;
|
||||
sourceId?: string;
|
||||
translate: boolean;
|
||||
langs?: string[];
|
||||
items: Array<
|
||||
ImportJobItem & {
|
||||
state: "pending" | "running" | "done" | "failed";
|
||||
state:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "interrupted";
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { furnitureCompleteness } from "./studio-completeness";
|
||||
import {
|
||||
compareFurniture,
|
||||
type FurnitureInspection,
|
||||
} from "./studio-inspection";
|
||||
|
||||
const complete: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [{ id: 100, spriteId: 200, type: "s", name: "Chair" }],
|
||||
catalog: [{ id: 50, pageId: 1, credits: 3, points: 0 }],
|
||||
furnidata: [
|
||||
{ id: 200, type: "flooritem", name: "Chair", description: "", revision: 1 },
|
||||
],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: true, bytes: 200 },
|
||||
icon: { exists: true, bytes: 30 },
|
||||
};
|
||||
describe("furniture completeness", () => {
|
||||
it("checks all five independent parts and accepts locally remapped IDs", () => {
|
||||
expect(furnitureCompleteness(complete).map((p) => p.status)).toEqual(
|
||||
Array(5).fill("present"),
|
||||
);
|
||||
});
|
||||
it("does not infer a catalog offer or icon from SQL and Nitro", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [],
|
||||
icon: { exists: false, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "catalog")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "catalog",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "icon",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "sql")?.status).toBe("present");
|
||||
});
|
||||
it("keeps unreadable assets unknown and directs operators to recheck", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "furnidata")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ id: 201, type: "flooritem" },
|
||||
{ id: 200, type: "wallitem" },
|
||||
])(
|
||||
"flags ID or type mismatches in both review and completeness: %j",
|
||||
(change) => {
|
||||
const local = {
|
||||
...complete,
|
||||
furnidata: [{ ...complete.furnidata[0], ...change }],
|
||||
};
|
||||
expect(
|
||||
furnitureCompleteness(local)
|
||||
.filter((p) => p.status === "conflict")
|
||||
.map((p) => p.id),
|
||||
).toEqual(["sql", "furnidata"]);
|
||||
expect(
|
||||
compareFurniture(
|
||||
{ name: "Chair", description: "", revision: 1, type: "flooritem" },
|
||||
local,
|
||||
).state,
|
||||
).toBe("conflict");
|
||||
},
|
||||
);
|
||||
it("allows multiple legitimate catalog offers but surfaces duplicate SQL records", () => {
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [...complete.catalog, { ...complete.catalog[0], id: 51 }],
|
||||
}).find((p) => p.id === "catalog")?.status,
|
||||
).toBe("present");
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
sql: [...complete.sql, { ...complete.sql[0], id: 101 }],
|
||||
}).find((p) => p.id === "sql"),
|
||||
).toMatchObject({ status: "conflict", action: "audit" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
type FurnitureInspection,
|
||||
hasFurnitureConflict,
|
||||
} from "./studio-inspection";
|
||||
export type FurniturePartId =
|
||||
| "sql"
|
||||
| "catalog"
|
||||
| "furnidata"
|
||||
| "icon"
|
||||
| "nitro";
|
||||
export interface FurniturePart {
|
||||
id: FurniturePartId;
|
||||
status: "present" | "missing" | "unknown" | "conflict";
|
||||
action: FurniturePartId | "recheck" | "audit" | null;
|
||||
}
|
||||
/** Report each required part without treating unavailable checks as missing data. */
|
||||
export function furnitureCompleteness(
|
||||
local: FurnitureInspection,
|
||||
): FurniturePart[] {
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const evidence: Array<[FurniturePartId, boolean | null]> = [
|
||||
["sql", local.sql.length > 0],
|
||||
["catalog", local.catalog.length > 0],
|
||||
["furnidata", local.furnidataReadable ? local.furnidata.length > 0 : null],
|
||||
["icon", local.icon.exists],
|
||||
["nitro", local.nitro.exists],
|
||||
];
|
||||
return evidence.map(([id, present]) => {
|
||||
if (conflict && (id === "sql" || id === "furnidata"))
|
||||
return { id, status: "conflict", action: "audit" };
|
||||
if (present === null) return { id, status: "unknown", action: "recheck" };
|
||||
return {
|
||||
id,
|
||||
status: present ? "present" : "missing",
|
||||
action: present ? null : id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -56,16 +56,15 @@ export function compareFurniture(
|
||||
after !== undefined &&
|
||||
String(before) !== String(after),
|
||||
}));
|
||||
const state =
|
||||
local.sql.length > 1 || local.furnidata.length > 1
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
const state = hasFurnitureConflict(local)
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
return { state, fields } as const;
|
||||
}
|
||||
export function furnitureHealth(local: FurnitureInspection) {
|
||||
@@ -76,17 +75,7 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
missing.push("Furnidata");
|
||||
if (!local.sql.length) missing.push("SQL item");
|
||||
if (!local.catalog.length) missing.push("Catalog offer");
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
const conflict =
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
);
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const unknown =
|
||||
!local.furnidataReadable ||
|
||||
local.nitro.exists === null ||
|
||||
@@ -102,3 +91,18 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
: "incomplete";
|
||||
return { state, missing } as const;
|
||||
}
|
||||
|
||||
export function hasFurnitureConflict(local: FurnitureInspection): boolean {
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
return (
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((dir) => [
|
||||
join(dir, "bash.exe"),
|
||||
join(dirname(dir), "bin", "bash.exe"),
|
||||
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||
])
|
||||
.find((path) => existsSync(path)) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "a".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-publish-test-"));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
bash,
|
||||
[resolve(root, "scripts/publish-container.sh")],
|
||||
{
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/publish-container-harness.sh"),
|
||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
REGISTRY_SERVER: "https://registry.invalid",
|
||||
REGISTRY_REPOSITORY: "owner/cms",
|
||||
REGISTRY_USER: "fixture",
|
||||
REGISTRY_TOKEN: "fixture-only",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
return readFileSync(join(dir, "calls"), "utf8");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
describe("verified application image reuse", () => {
|
||||
it("reuses only the exact image digest that passed deployment checks", () => {
|
||||
const calls = simulate("verified");
|
||||
expect(calls).toContain(
|
||||
`docker tag sha256:candidate registry.invalid/owner/cms:${sha}`,
|
||||
);
|
||||
expect(calls).not.toContain("docker build --network=host --build-arg");
|
||||
expect(
|
||||
calls.indexOf("verify scripts/verify-portable-image.mjs"),
|
||||
).toBeLessThan(calls.indexOf("docker push"));
|
||||
});
|
||||
it.each(["missing", "mismatch"])(
|
||||
"builds committed source when verification marker is %s",
|
||||
(scenario) => {
|
||||
const calls = simulate(scenario);
|
||||
expect(calls).toContain("docker build --network=host --build-arg");
|
||||
expect(calls).not.toContain("docker tag sha256:candidate");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
total: 55,
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadArticleList } from "./article-list";
|
||||
|
||||
describe("article listing", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
state.total = 55;
|
||||
});
|
||||
it("filters drafts before pagination and binds search text", async () => {
|
||||
const result = await loadArticleList({
|
||||
status: "draft",
|
||||
search: "needle'",
|
||||
page: 2,
|
||||
});
|
||||
expect(result.page).toBe(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).toContain("`website_articles`.`status` = ?");
|
||||
expect(query.sql).not.toContain("needle");
|
||||
expect(query.params.slice(0, 3)).toEqual([
|
||||
"draft",
|
||||
"%needle'%",
|
||||
"%needle'%",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 24]);
|
||||
});
|
||||
it("clamps an out-of-range page to the last filtered page", async () => {
|
||||
const result = await loadArticleList({ status: "scheduled", page: 999 });
|
||||
expect(result).toMatchObject({ page: 3, lastPage: 3, total: 55 });
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 48]);
|
||||
});
|
||||
it("bounds invalid input and keeps an empty collection on page one", async () => {
|
||||
state.total = 0;
|
||||
const result = await loadArticleList({ status: "unexpected", page: NaN });
|
||||
expect(result).toMatchObject({
|
||||
status: "all",
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
total: 0,
|
||||
});
|
||||
expect(state.queries[1].sql).not.toContain(
|
||||
"`website_articles`.`status` = ?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
export async function loadArticleList(
|
||||
options: { status?: string; search?: string; page?: number } = {},
|
||||
) {
|
||||
const status = ["draft", "published", "scheduled"].includes(
|
||||
options.status ?? "",
|
||||
)
|
||||
? options.status
|
||||
: "all";
|
||||
const search = (options.search ?? "").trim().slice(0, 191);
|
||||
const where = and(
|
||||
status !== "all" ? eq(WebsiteArticles.status, status ?? "all") : undefined,
|
||||
search
|
||||
? or(
|
||||
like(WebsiteArticles.title, `%${search}%`),
|
||||
like(WebsiteArticles.shortStory, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
const totals = await db
|
||||
.select({ value: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(where);
|
||||
const total = Number(totals[0]?.value ?? 0);
|
||||
const perPage = 24;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(
|
||||
lastPage,
|
||||
Number.isFinite(options.page)
|
||||
? Math.max(1, Math.trunc(options.page ?? 1))
|
||||
: 1,
|
||||
);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
status: WebsiteArticles.status,
|
||||
author: User.username,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.leftJoin(User, eq(User.id, WebsiteArticles.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(WebsiteArticles.createdAt), desc(WebsiteArticles.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage);
|
||||
return { rows, page, perPage, total, lastPage, status, search };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
type Change = { key: string; from: unknown; to: unknown };
|
||||
const sensitive =
|
||||
/password|secret|token|otp|recovery|authTicket|two_factor|api_key/i;
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 10) return "[…]";
|
||||
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
||||
if (!record(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
sensitive.test(key) ? "[Redacted]" : redact(item, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
function parse(value: string) {
|
||||
if (value.length > 100000) throw new Error("oversize");
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!record(parsed)) throw new Error("invalid");
|
||||
return parsed;
|
||||
}
|
||||
export function readAuditChanges(
|
||||
diff: string | null,
|
||||
before?: string | null,
|
||||
after?: string | null,
|
||||
) {
|
||||
try {
|
||||
let changes: Change[];
|
||||
if (diff) {
|
||||
const parsed = parse(diff);
|
||||
changes = Object.entries(parsed).map(([key, value]) => {
|
||||
if (!record(value) || !("from" in value || "to" in value))
|
||||
throw new Error("invalid");
|
||||
return { key, from: value.from, to: value.to };
|
||||
});
|
||||
} else {
|
||||
const previous = before ? parse(before) : {};
|
||||
const next = after ? parse(after) : {};
|
||||
changes = [...new Set([...Object.keys(previous), ...Object.keys(next)])]
|
||||
.filter(
|
||||
(key) => JSON.stringify(previous[key]) !== JSON.stringify(next[key]),
|
||||
)
|
||||
.map((key) => ({ key, from: previous[key], to: next[key] }));
|
||||
}
|
||||
return {
|
||||
invalid: false,
|
||||
changes: changes.map(({ key, from, to }) => ({
|
||||
key,
|
||||
from: sensitive.test(key) ? "[Redacted]" : redact(from),
|
||||
to: sensitive.test(key) ? "[Redacted]" : redact(to),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { invalid: true, changes: [] as Change[] };
|
||||
}
|
||||
}
|
||||
export function formatAuditValue(value: unknown) {
|
||||
const text =
|
||||
value === undefined
|
||||
? "∅"
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2);
|
||||
return text.length > 4000 ? `${text.slice(0, 4000)}…` : text;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface AuditFilters {
|
||||
search?: string;
|
||||
actor?: string;
|
||||
action?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
function day(value?: string) {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return "";
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(date.getTime()) &&
|
||||
date.toISOString().slice(0, 10) === value
|
||||
? value
|
||||
: "";
|
||||
}
|
||||
export function normalizeAuditFilters(options: AuditFilters = {}) {
|
||||
const from = day(options.from);
|
||||
const to = day(options.to);
|
||||
return {
|
||||
search: (options.search ?? "").trim().slice(0, 191),
|
||||
actor: (options.actor ?? "").trim().slice(0, 191),
|
||||
action: (options.action ?? "").trim().slice(0, 191),
|
||||
from,
|
||||
to,
|
||||
until: to
|
||||
? new Date(new Date(`${to}T00:00:00Z`).getTime() + 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: "",
|
||||
page: Number.isFinite(options.page)
|
||||
? Math.min(1000000, Math.max(1, Math.trunc(options.page ?? 1)))
|
||||
: 1,
|
||||
perPage: Number.isFinite(options.perPage)
|
||||
? Math.min(100, Math.max(1, Math.trunc(options.perPage ?? 20)))
|
||||
: 20,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { getAuditLogs } from "./audit";
|
||||
|
||||
describe("audit query filters", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
});
|
||||
it("binds actor/action/date/search values in both list and count queries", async () => {
|
||||
await getAuditLogs({
|
||||
actor: "Alice' OR 1=1 --",
|
||||
action: "update",
|
||||
search: "user",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
perPage: 10,
|
||||
page: 2,
|
||||
});
|
||||
expect(state.queries).toHaveLength(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).not.toContain("Alice");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`user_id` in (select");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` >= ?");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` < ?");
|
||||
expect(query.params.slice(0, 6)).toEqual([
|
||||
"%user%",
|
||||
"%user%",
|
||||
"update",
|
||||
"Alice' OR 1=1 --",
|
||||
"2026-09-09",
|
||||
"2026-09-10",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[0].params.slice(-2)).toEqual([10, 10]);
|
||||
});
|
||||
it("uses an exact numeric actor ID and caps row count", async () => {
|
||||
await getAuditLogs({ actor: "42", perPage: 1000, page: -2 });
|
||||
expect(state.queries[0].params).toEqual([42, 100]);
|
||||
expect(state.queries[0].sql).not.toContain("in (select");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditValue, readAuditChanges } from "./audit-diff";
|
||||
import { normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
describe("audit filter bounds", () => {
|
||||
it("normalizes pagination and rejects invalid calendar dates", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
page: NaN,
|
||||
perPage: Infinity,
|
||||
from: "2026-02-30",
|
||||
to: "x",
|
||||
}),
|
||||
).toMatchObject({ page: 1, perPage: 20, from: "", to: "" });
|
||||
expect(normalizeAuditFilters({ page: -3, perPage: 1000 })).toMatchObject({
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
});
|
||||
it("caps strings and preserves exact actor/action and UTC day boundaries", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
actor: " Alice ",
|
||||
action: " update ",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
}),
|
||||
).toMatchObject({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
until: "2026-09-10",
|
||||
});
|
||||
expect(
|
||||
normalizeAuditFilters({ search: "x".repeat(500) }).search,
|
||||
).toHaveLength(191);
|
||||
});
|
||||
});
|
||||
describe("legacy audit diffs", () => {
|
||||
it.each(["null", "[]", "1", "bad JSON", '{"name":null}', '{"name":"value"}'])(
|
||||
"handles %s safely",
|
||||
(diff) => {
|
||||
expect(readAuditChanges(diff).invalid).toBe(true);
|
||||
},
|
||||
);
|
||||
it("keeps objects and explicit null distinct from absent values", () => {
|
||||
const result = readAuditChanges(
|
||||
'{"settings":{"from":{"enabled":false},"to":null},"added":{"to":3}}',
|
||||
);
|
||||
expect(result.changes).toHaveLength(2);
|
||||
expect(formatAuditValue(result.changes[0].from)).toContain(
|
||||
'"enabled": false',
|
||||
);
|
||||
expect(formatAuditValue(result.changes[0].to)).toBe("null");
|
||||
expect(formatAuditValue(result.changes[1].from)).toBe("∅");
|
||||
});
|
||||
it("shows create/delete snapshots when a legacy record has no diff", () => {
|
||||
expect(readAuditChanges(null, null, '{"name":"New"}').changes).toEqual([
|
||||
{ key: "name", from: undefined, to: "New" },
|
||||
]);
|
||||
});
|
||||
it("redacts historical sensitive nested fields before rendering", () => {
|
||||
expect(
|
||||
formatAuditValue(
|
||||
readAuditChanges('{"settings":{"from":{"password":"secret"},"to":{}}}')
|
||||
.changes[0].from,
|
||||
),
|
||||
).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -187,3 +187,24 @@ describe("getAuditLogs", () => {
|
||||
expect(result.rows[0].username).toBe("User #99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit response privacy", () => {
|
||||
it("does not serialize legacy snapshot secrets to the client", async () => {
|
||||
selectRows.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
diff: null,
|
||||
before: null,
|
||||
after: '{"name":"Alice","password":"secret-value"}',
|
||||
},
|
||||
]);
|
||||
selectCount.mockResolvedValue([{ value: 1 }]);
|
||||
selectUsers.mockResolvedValue([]);
|
||||
const result = await getAuditLogs();
|
||||
expect(JSON.stringify(result.rows)).not.toContain("secret-value");
|
||||
expect(result.rows[0].before).toBeNull();
|
||||
expect(result.rows[0].after).toBeNull();
|
||||
expect(result.rows[0].diff).toContain("Alice");
|
||||
});
|
||||
});
|
||||
+49
-21
@@ -1,5 +1,7 @@
|
||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
|
||||
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||
import { readAuditChanges } from "./audit-diff";
|
||||
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
@@ -68,23 +70,32 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
const { search, page = 1, perPage = 20 } = options;
|
||||
export async function getAuditLogs(options: AuditFilters = {}) {
|
||||
const { search, actor, action, from, until, page, perPage } =
|
||||
normalizeAuditFilters(options);
|
||||
const skip = (page - 1) * perPage;
|
||||
|
||||
const where = search
|
||||
? or(
|
||||
like(AdminAuditLog.action, `%${search}%`),
|
||||
like(AdminAuditLog.target, `%${search}%`),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const where = and(
|
||||
search
|
||||
? or(
|
||||
like(AdminAuditLog.action, `%${search}%`),
|
||||
like(AdminAuditLog.target, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
action ? eq(AdminAuditLog.action, action) : undefined,
|
||||
actor
|
||||
? /^[1-9]\d*$/.test(actor) && Number.isSafeInteger(Number(actor))
|
||||
? eq(AdminAuditLog.userId, Number(actor))
|
||||
: inArray(
|
||||
AdminAuditLog.userId,
|
||||
db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.username, actor)),
|
||||
)
|
||||
: undefined,
|
||||
from ? gte(AdminAuditLog.createdAt, from) : undefined,
|
||||
until ? lt(AdminAuditLog.createdAt, until) : undefined,
|
||||
);
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -94,6 +105,8 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
target: AdminAuditLog.target,
|
||||
targetId: AdminAuditLog.targetId,
|
||||
diff: AdminAuditLog.diff,
|
||||
before: AdminAuditLog.before,
|
||||
after: AdminAuditLog.after,
|
||||
createdAt: AdminAuditLog.createdAt,
|
||||
})
|
||||
.from(AdminAuditLog)
|
||||
@@ -116,10 +129,25 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
: [];
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
const enrichedRows = rows.map((r) => {
|
||||
const details = readAuditChanges(r.diff, r.before, r.after);
|
||||
return {
|
||||
...r,
|
||||
// Only sanitized changes may cross the server/client boundary.
|
||||
before: null,
|
||||
after: null,
|
||||
diff: details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes.length
|
||||
? JSON.stringify(
|
||||
Object.fromEntries(
|
||||
details.changes.map(({ key, from, to }) => [key, { from, to }]),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
const now = new Date("2026-09-09T12:00:00Z");
|
||||
const event = (
|
||||
startsAt: string,
|
||||
endsAt: string | null = null,
|
||||
status = "published",
|
||||
) => ({
|
||||
startsAt: new Date(startsAt),
|
||||
endsAt: endsAt ? new Date(endsAt) : null,
|
||||
status,
|
||||
});
|
||||
|
||||
describe("dashboard event timing", () => {
|
||||
it("shows a future published event without requiring an end", () => {
|
||||
expect(getDashboardEventPhase(event("2026-09-09T13:00:00Z"), now)).toBe(
|
||||
"upcoming",
|
||||
);
|
||||
});
|
||||
it("marks the start boundary and known duration as ongoing", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T12:00:00Z", "2026-09-09T13:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBe("ongoing");
|
||||
});
|
||||
it("excludes ended events including the exact end boundary", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T11:00:00Z", "2026-09-09T12:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
it("does not invent a duration for old events without an end", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(event("2026-09-08T12:00:00Z"), now),
|
||||
).toBeNull();
|
||||
});
|
||||
it.each(["draft", "cancelled", "completed"])(
|
||||
"excludes %s events",
|
||||
(status) => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T13:00:00Z", null, status),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getDashboardEventPhase(
|
||||
event: { status: string; startsAt: Date; endsAt: Date | null },
|
||||
now: Date,
|
||||
): "upcoming" | "ongoing" | null {
|
||||
if (event.status !== "published") return null;
|
||||
if (event.startsAt > now) return "upcoming";
|
||||
return event.endsAt && event.endsAt > now ? "ongoing" : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[],
|
||||
fail: false,
|
||||
query: "",
|
||||
params: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (query, params) => {
|
||||
state.query = query;
|
||||
state.params = params;
|
||||
if (state.fail) throw new Error("offline");
|
||||
return { rows: state.rows };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
|
||||
describe("dashboard event loading", () => {
|
||||
beforeEach(() => {
|
||||
state.rows = [];
|
||||
state.fail = false;
|
||||
});
|
||||
it("limits to one published ongoing or future event in date order", async () => {
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z"));
|
||||
expect(state.query).toContain("`website_events`.`status` = ?");
|
||||
expect(state.query).toContain(
|
||||
"(`website_events`.`starts_at` > ? or `website_events`.`ends_at` > ?)",
|
||||
);
|
||||
expect(state.query).toContain(
|
||||
"order by `website_events`.`starts_at` asc, `website_events`.`id` asc limit ?",
|
||||
);
|
||||
expect(state.params).toEqual([
|
||||
"published",
|
||||
"2026-09-09 12:00:00.000",
|
||||
"2026-09-09 12:00:00.000",
|
||||
1,
|
||||
]);
|
||||
});
|
||||
it("distinguishes an empty calendar from a failed query", async () => {
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: false,
|
||||
});
|
||||
state.fail = true;
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: true,
|
||||
});
|
||||
});
|
||||
it("returns the next event with the phase derived from its real dates", async () => {
|
||||
state.rows = [
|
||||
[3, "Quiz", "published", "2026-09-09 13:00:00.000", null, "Games"],
|
||||
];
|
||||
expect(
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z")),
|
||||
).toMatchObject({
|
||||
unavailable: false,
|
||||
event: { id: 3, title: "Quiz", phase: "upcoming" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { and, asc, eq, gt, or } from "drizzle-orm";
|
||||
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
export async function loadDashboardEvent(now = new Date()) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
typeName: WebsiteEventType.name,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteEvent.status, "published"),
|
||||
or(gt(WebsiteEvent.startsAt, now), gt(WebsiteEvent.endsAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(1);
|
||||
const event = rows[0];
|
||||
const phase = event ? getDashboardEventPhase(event, now) : null;
|
||||
return {
|
||||
event: event && phase ? { ...event, phase } : null,
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
return { event: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,44 @@ it("rejects unsafe job paths", async () => {
|
||||
);
|
||||
await expect(s.read("../escape")).rejects.toThrow("Invalid import ID");
|
||||
});
|
||||
|
||||
it("cancellation survives a stale worker save", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.cancelRequested).toBe(
|
||||
true,
|
||||
);
|
||||
await s.save({ ...j, state: "running" });
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
expect((await s.read(j.id)).cancelRequested).toBe(true);
|
||||
});
|
||||
it("rejects cancellation by a different operator and is idempotent for the owner", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect(await s.requestCancellation(j.id, 99)).toBeNull();
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
await Promise.all([
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
]);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
});
|
||||
it("does not alter completed jobs when cancellation arrives late", async () => {
|
||||
const s = await store(),
|
||||
j = { ...job(), state: "completed" as const };
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.state).toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
});
|
||||
it("loads bounded history for the requesting owner", async () => {
|
||||
const s = await store();
|
||||
for (let i = 0; i < 5; i++)
|
||||
await s.create({ ...job(), userId: i % 2 === 0 ? 4 : 8 });
|
||||
const result = await s.list({ userId: 4, limit: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((job) => job.userId === 4)).toBe(true);
|
||||
});
|
||||
@@ -39,20 +39,68 @@ export class ImportJobStore {
|
||||
}
|
||||
async read(id: string): Promise<ImportJob> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
return JSON.parse(
|
||||
const job: ImportJob = JSON.parse(
|
||||
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
|
||||
);
|
||||
if (await this.isCancellationRequested(id)) job.cancelRequested = true;
|
||||
return job;
|
||||
}
|
||||
async list(): Promise<ImportJob[]> {
|
||||
async isCancellationRequested(id: string): Promise<boolean> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
try {
|
||||
await fs.access(path.join(this.root, `${id}.cancel`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Cancellation has its own durable marker: a worker save cannot overwrite it. */
|
||||
async requestCancellation(
|
||||
id: string,
|
||||
userId: number,
|
||||
): Promise<ImportJob | null> {
|
||||
const job = await this.read(id).catch((error) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!job || job.userId !== userId) return null;
|
||||
if (job.state !== "queued" && job.state !== "running") return job;
|
||||
await fs
|
||||
.writeFile(path.join(this.root, `${id}.cancel`), "cancel", { flag: "wx" })
|
||||
.catch((error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
});
|
||||
return this.read(id);
|
||||
}
|
||||
async list(options?: {
|
||||
userId: number;
|
||||
limit: number;
|
||||
}): Promise<ImportJob[]> {
|
||||
const files = await fs.readdir(this.root).catch((error) => {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
});
|
||||
const jobs = await Promise.all(
|
||||
files
|
||||
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5)))
|
||||
.map((f) => this.read(f.slice(0, -5))),
|
||||
const candidates: Array<{ id: string; modified: number }> = [];
|
||||
// Metadata and payload reads are sequential, avoiding thousands of concurrent opens.
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json") || !validJobId(file.slice(0, -5))) continue;
|
||||
const stat = await fs.stat(path.join(this.root, file));
|
||||
candidates.push({ id: file.slice(0, -5), modified: stat.mtimeMs });
|
||||
}
|
||||
candidates.sort((a, b) =>
|
||||
options ? b.modified - a.modified : a.modified - b.modified,
|
||||
);
|
||||
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
const jobs: ImportJob[] = [];
|
||||
const limit = options ? Math.min(30, Math.max(1, options.limit)) : Infinity;
|
||||
for (const candidate of candidates) {
|
||||
const job = await this.read(candidate.id);
|
||||
if (options && job.userId !== options.userId) continue;
|
||||
jobs.push(job);
|
||||
if (jobs.length >= limit) break;
|
||||
}
|
||||
return options
|
||||
? jobs
|
||||
: jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ const mocks = vi.hoisted(() => ({
|
||||
set: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
list: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
import: vi.fn(),
|
||||
attachment: vi.fn(),
|
||||
export: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
updateCatalog: vi.fn(),
|
||||
updateItems: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
|
||||
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
|
||||
@@ -19,6 +22,7 @@ vi.mock("./clone-sources", () => ({ getSource: async () => null }));
|
||||
vi.mock("./furni-job-store", () => ({
|
||||
ImportJobStore: class {
|
||||
list = mocks.list;
|
||||
isCancellationRequested = mocks.cancel;
|
||||
save = mocks.save;
|
||||
},
|
||||
}));
|
||||
@@ -33,7 +37,7 @@ vi.mock("./furni-attachment", () => ({
|
||||
readFurnitureAttachment: mocks.attachment,
|
||||
}));
|
||||
vi.mock("./rcon", () => ({
|
||||
rcon: { updateCatalog: async () => {}, updateItems: async () => {} },
|
||||
rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
|
||||
}));
|
||||
|
||||
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
|
||||
@@ -44,10 +48,14 @@ let job: ImportJob;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.set.mockResolvedValue("OK");
|
||||
mocks.cancel.mockResolvedValue(false);
|
||||
mocks.eval.mockResolvedValue(1);
|
||||
mocks.export.mockImplementation((fn) => fn());
|
||||
mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] });
|
||||
mocks.save.mockResolvedValue(undefined);
|
||||
mocks.updateCatalog.mockResolvedValue(undefined);
|
||||
mocks.updateItems.mockResolvedValue(undefined);
|
||||
mocks.translate.mockResolvedValue(undefined);
|
||||
job = {
|
||||
id: "job",
|
||||
userId: 5,
|
||||
@@ -89,7 +97,7 @@ it("marks abandoned work interrupted without repeating an uncertain import", asy
|
||||
job.items[0].state = "running";
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("interrupted");
|
||||
expect(job.items[0].state).toBe("failed");
|
||||
expect(job.items[0].state).toBe("interrupted");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps errors for failed items and continues the batch", async () => {
|
||||
@@ -112,3 +120,106 @@ it("loads attachments using the job owner and exact classname", async () => {
|
||||
expect.objectContaining({ providedNitro: Buffer.from("verified") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels queued work without importing any items", async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items[0].state).toBe("cancelled");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("finishes the in-flight item and cancels only the remaining items", async () => {
|
||||
job.items.push({ ...job.items[0], classname: "table" });
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items.map((item) => item.state)).toEqual(["done", "cancelled"]);
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("reports completion if cancellation arrives during the final item", async () => {
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("completed");
|
||||
});
|
||||
it("preserves pending items when recovery finds an uncertain in-flight import", async () => {
|
||||
job.state = "running";
|
||||
job.items[0].state = "running";
|
||||
job.items.push({ ...job.items[0], classname: "table", state: "pending" });
|
||||
await drainFurnitureImports();
|
||||
expect(job.items.map((item) => item.state)).toEqual([
|
||||
"interrupted",
|
||||
"pending",
|
||||
]);
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite another worker's interruption after losing ownership during the final import", async () => {
|
||||
let persisted: ImportJob | undefined;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
persisted = structuredClone(value);
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
persisted = {
|
||||
...structuredClone(job),
|
||||
state: "interrupted",
|
||||
items: job.items.map((item) => ({ ...item, state: "interrupted" })),
|
||||
};
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||
expect(persisted?.state).toBe("interrupted");
|
||||
expect(persisted?.items[0].state).toBe("interrupted");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps an uncertain import running on disk when lease verification fails", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockRejectedValue(new Error("Redis unavailable"));
|
||||
throw new Error("SQL outcome unknown");
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
});
|
||||
it("does not persist final completion when ownership changes during cache refresh", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.updateCatalog.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots.at(-1)?.state).toBe("running");
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("done");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
expect(mocks.updateItems).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not save a translated outcome after ownership is lost during translation", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
job.translate = true;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.translate.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.translate).toHaveBeenCalledOnce();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logAudit } from "./audit";
|
||||
@@ -13,6 +14,7 @@ import { repairFurniture } from "./furniture-repair";
|
||||
import { rcon } from "./rcon";
|
||||
|
||||
const LOCK = "furniture-import-worker:v1";
|
||||
class ImportLeaseLostError extends Error {}
|
||||
let running: Promise<void> | undefined;
|
||||
export function drainFurnitureImports(): Promise<void> {
|
||||
if (running) return running;
|
||||
@@ -30,6 +32,28 @@ async function drain() {
|
||||
const token = randomUUID();
|
||||
if ((await redis.set(LOCK, token, "EX", 600, "NX")) !== "OK") return;
|
||||
let lease = true;
|
||||
async function requireLease() {
|
||||
if (!lease) throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
try {
|
||||
const owned = await redis?.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],600) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
if (owned !== 1 || !lease) {
|
||||
lease = false;
|
||||
throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
}
|
||||
} catch (error) {
|
||||
lease = false;
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
throw new ImportLeaseLostError(
|
||||
"Import worker could not verify its lease",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void redis
|
||||
?.eval(
|
||||
@@ -47,29 +71,48 @@ async function drain() {
|
||||
}, 30000);
|
||||
try {
|
||||
const store = new ImportJobStore();
|
||||
// Redis ownership checks and file writes are separate operations. These checks
|
||||
// prevent known stale writes; they do not provide atomic filesystem fencing.
|
||||
async function saveOwned(job: ImportJob) {
|
||||
await requireLease();
|
||||
await store.save(job);
|
||||
}
|
||||
for (const job of await store.list()) {
|
||||
if (!lease) break;
|
||||
await requireLease();
|
||||
if (job.state === "running") {
|
||||
job.state = "interrupted";
|
||||
for (const item of job.items)
|
||||
if (item.state === "running" || item.state === "pending") {
|
||||
item.state = "failed";
|
||||
if (item.state === "running") {
|
||||
item.state = "interrupted";
|
||||
item.error =
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.";
|
||||
"Server restarted during import. Its outcome is uncertain. Inspect local data before starting a new repair.";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
if (job.state !== "queued") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
job.state = "cancelled";
|
||||
for (const item of job.items)
|
||||
if (item.state === "pending") item.state = "cancelled";
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
job.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
await withCatalogExport(async () => {
|
||||
await ensureDirectories();
|
||||
const source = job.sourceId ? await getSource(job.sourceId) : null;
|
||||
for (const item of job.items) {
|
||||
if (!lease) throw Error("Import worker lost its lease");
|
||||
await requireLease();
|
||||
if (item.state !== "pending") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
for (const remaining of job.items)
|
||||
if (remaining.state === "pending") remaining.state = "cancelled";
|
||||
break;
|
||||
}
|
||||
item.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
try {
|
||||
if (job.sourceId && !source)
|
||||
throw Error("Import source no longer exists");
|
||||
@@ -80,6 +123,7 @@ async function drain() {
|
||||
job.userId,
|
||||
)
|
||||
: undefined;
|
||||
await requireLease();
|
||||
const result = await (job.mode === "repair"
|
||||
? repairFurniture
|
||||
: importSingleFurni)({
|
||||
@@ -90,6 +134,7 @@ async function drain() {
|
||||
nitroBaseUrl: source?.nitroBaseUrl,
|
||||
iconBaseUrl: source?.iconBaseUrl,
|
||||
});
|
||||
await requireLease();
|
||||
item.warnings = result.warnings;
|
||||
item.itemId = result.itemId;
|
||||
if (!result.ok) throw Error(result.error || "Import failed");
|
||||
@@ -114,16 +159,21 @@ async function drain() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
await requireLease();
|
||||
item.state = "failed";
|
||||
item.error =
|
||||
error instanceof Error ? error.message : "Import failed";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await requireLease();
|
||||
await rcon.updateItems();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
for (const item of job.items)
|
||||
if (item.state === "done") {
|
||||
item.warnings ??= [];
|
||||
@@ -131,17 +181,25 @@ async function drain() {
|
||||
}
|
||||
}
|
||||
});
|
||||
job.state = "completed";
|
||||
await store.save(job);
|
||||
await requireLease();
|
||||
job.state = job.items.some((item) => item.state === "cancelled")
|
||||
? "cancelled"
|
||||
: "completed";
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
await runCatalogExport();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await redis.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
await redis
|
||||
.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
)
|
||||
.catch((error) => {
|
||||
logServerError("furni.worker_release_failed", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
import { siteSettings } from "./site-settings";
|
||||
export async function loadUserDashboard(userId: number) {
|
||||
const [
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
@@ -29,6 +31,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
friends,
|
||||
currencyRows,
|
||||
] = await Promise.all([
|
||||
loadDashboardEvent(),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -62,7 +65,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => []),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
@@ -85,7 +88,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
@@ -144,6 +147,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
];
|
||||
|
||||
return {
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { verifyRuntimeMetadata } from "../../scripts/verify-runtime-metadata.mjs";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("verifies runtime names and domains without requesting DB-dependent login HTML", async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/login"))
|
||||
return new Response("Database unavailable", { status: 500 });
|
||||
return url.endsWith("webmanifest")
|
||||
? Response.json({ name: "Fixture alpha" })
|
||||
: new Response("https://alpha.test/sitemap.xml");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetcher.mock.calls.map((c) => c[0])).not.toContain(
|
||||
"https://alpha.test/login",
|
||||
);
|
||||
});
|
||||
it.each(["manifest", "domain", "http"])("rejects wrong %s", async (mode) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) =>
|
||||
url.endsWith("webmanifest")
|
||||
? Response.json(
|
||||
{ name: mode === "manifest" ? "Build fixture" : "Fixture alpha" },
|
||||
{ status: mode === "http" ? 500 : 200 },
|
||||
)
|
||||
: new Response("https://wrong.test"),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
Reference in new issue
Block a user