fix(housekeeping): canonicalize live cutover links
This commit is contained in:
1 parent
1f10e15c1c
commit
c841595458
15 files changed
+107
-30
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import robots from "./robots";
|
||||
|
||||
describe("robots", () => {
|
||||
it("marks the canonical Housekeeping namespace as private", () => {
|
||||
const result = robots();
|
||||
const rules = Array.isArray(result.rules) ? result.rules : [result.rules];
|
||||
const disallow = rules.flatMap((rule) => rule.disallow ?? []);
|
||||
|
||||
expect(disallow).toContain("/ase-next/");
|
||||
expect(disallow).not.toContain(["/", "ase", "/"].join(""));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/ase/", "/api/", "/settings/", "/me/"],
|
||||
disallow: ["/ase-next/", "/api/", "/settings/", "/me/"],
|
||||
},
|
||||
sitemap: `${appUrl}/sitemap.xml`,
|
||||
};
|
||||
|
||||
@@ -41,11 +41,11 @@ describe("BcPageDetail", () => {
|
||||
page={page}
|
||||
items={[]}
|
||||
canEdit={false}
|
||||
returnHref="/ase/economy/catalog"
|
||||
returnHref="/ase-next/economy/catalog"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('href="/ase/economy/catalog"');
|
||||
expect(markup).toContain('href="/ase-next/economy/catalog"');
|
||||
expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function BcPageDetail({
|
||||
page,
|
||||
items,
|
||||
canEdit,
|
||||
returnHref = "/ase/economy/catalog",
|
||||
returnHref = "/ase-next/economy/catalog",
|
||||
}: BcPageDetailProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
@@ -1751,7 +1751,7 @@ export function CatalogItemsTable({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a soundtrack to play when this item is used
|
||||
in-game. Uploaded from{" "}
|
||||
<code>/ase/economy/rewards/sounds</code>.
|
||||
<code>/ase-next/economy/rewards/sounds</code>.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function CatalogPageForm({
|
||||
if (!ok) return;
|
||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
||||
successMessage: "Page deleted.",
|
||||
redirectTo: "/ase/economy/catalog",
|
||||
redirectTo: "/ase-next/economy/catalog",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export function CatalogPageForm({
|
||||
{childPages.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/ase/economy/catalog/${child.id}`}
|
||||
href={`/ase-next/economy/catalog/${child.id}`}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -215,7 +215,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
) : null}
|
||||
|
||||
{showAdmin || showMod ? (
|
||||
<Link href="/ase" className="nav-item">
|
||||
<Link href="/ase-next" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
@@ -397,7 +397,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
) : null}
|
||||
|
||||
{showAdmin || showMod ? (
|
||||
<Link href="/ase" className="nav-item">
|
||||
<Link href="/ase-next" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, posix } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PREVIEW_ENTRYPOINTS = [
|
||||
@@ -14,13 +15,25 @@ const PREMATURE_UI_ROOTS = [
|
||||
["src/app/admin", "-next"].join(""),
|
||||
["src/app", "ase"].join("/"),
|
||||
] as const;
|
||||
const STABLE_GLOBAL_SOURCES = [
|
||||
const CUTOVER_GLOBAL_SOURCES = [
|
||||
"src/lib/admin/guard.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/components/navigation.tsx",
|
||||
"src/components/top-header.tsx",
|
||||
] as const;
|
||||
|
||||
function liveSourceFiles(root: string): readonly string[] {
|
||||
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = posix.normalize(join(root, entry.name).replaceAll("\\", "/"));
|
||||
if (entry.isDirectory()) {
|
||||
if (path === "src/features/housekeeping/migration") return [];
|
||||
return liveSourceFiles(path);
|
||||
}
|
||||
if (!/\.(?:ts|tsx)$/u.test(entry.name) || entry.name.includes(".test.")) {
|
||||
return [];
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
describe("gated Housekeeping preview coexistence", () => {
|
||||
it("publishes the /ase-next entrypoints and dispatcher", () => {
|
||||
for (const path of PREVIEW_ENTRYPOINTS) {
|
||||
@@ -58,9 +71,9 @@ describe("gated Housekeeping preview coexistence", () => {
|
||||
expect(gate).toContain("input.flag");
|
||||
});
|
||||
|
||||
it("leaves stable global links and authorization fallbacks on /admin and /mod", () => {
|
||||
for (const path of STABLE_GLOBAL_SOURCES) {
|
||||
expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next");
|
||||
it("canonicalizes Housekeeping links while retaining /admin and /mod", () => {
|
||||
for (const path of CUTOVER_GLOBAL_SOURCES) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("/ase-next");
|
||||
}
|
||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||
'href="/admin"',
|
||||
@@ -78,4 +91,24 @@ describe("gated Housekeeping preview coexistence", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps live Housekeeping links on the canonical namespace", () => {
|
||||
const staleRoute = new RegExp(
|
||||
[
|
||||
"/",
|
||||
"ase",
|
||||
"(?:[/?#]|[",
|
||||
'"',
|
||||
"'",
|
||||
String.fromCharCode(96),
|
||||
"]|$)",
|
||||
].join(""),
|
||||
"u",
|
||||
);
|
||||
const violations = liveSourceFiles("src").filter((path) =>
|
||||
staleRoute.test(readFileSync(path, "utf8")),
|
||||
);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ interface RunOptions {
|
||||
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
|
||||
*
|
||||
* // Delete with redirect
|
||||
* run(() => deleteNews(id), { redirectTo: '/ase/content/articles' })
|
||||
* run(() => deleteNews(id), { redirectTo: '/ase-next/content/articles' })
|
||||
*/
|
||||
export function useServerAction(options: UseServerActionOptions = {}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -139,6 +139,9 @@ describe("requireStaffRateLimited", () => {
|
||||
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||
await requireStaffRateLimited();
|
||||
expect(redirectSafe).toHaveBeenCalledWith("/ase?error=ratelimit", "/ase");
|
||||
expect(redirectSafe).toHaveBeenCalledWith(
|
||||
"/ase-next?error=ratelimit",
|
||||
"/ase-next",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export async function requireHousekeepingCapability(
|
||||
context ?? (await getHousekeepingCapabilityContext());
|
||||
const authorization = authorizeHousekeeping(capabilityContext, requirement);
|
||||
|
||||
if (!authorization.ok) redirectSafe("/ase", "/ase");
|
||||
if (!authorization.ok) redirectSafe("/ase-next", "/ase-next");
|
||||
|
||||
return capabilityContext;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export async function requirePermission(
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
if (!canAccess(permissions, permission, session.user.rank))
|
||||
redirectSafe("/ase", "/ase");
|
||||
redirectSafe("/ase-next", "/ase-next");
|
||||
return {
|
||||
id: session.user.id,
|
||||
rank: session.user.rank,
|
||||
@@ -62,7 +62,7 @@ export async function requirePermissionRateLimited(
|
||||
const staff = await requirePermission(permission);
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
redirectSafe("/ase-next?error=ratelimit", "/ase-next");
|
||||
return staff;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
redirectSafe("/ase-next?error=ratelimit", "/ase-next");
|
||||
return staff;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,6 @@ export async function requireModPermission(
|
||||
const { permissions } = await getAdminContext();
|
||||
const needed = Array.isArray(permission) ? permission : [permission];
|
||||
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
|
||||
if (!ok) redirectSafe("/ase", "/ase");
|
||||
if (!ok) redirectSafe("/ase-next", "/ase-next");
|
||||
return staff;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ async function fetchCmsCandidates(
|
||||
.limit(limit)
|
||||
.catch(() => []);
|
||||
|
||||
const prefix = "/ase/people/support/tickets";
|
||||
const prefix = "/ase-next/people/support/tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `cms-${row.id}`,
|
||||
@@ -190,7 +190,7 @@ async function fetchHelpCandidates(
|
||||
: [];
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const prefix = "/ase/people/support/help-tickets";
|
||||
const prefix = "/ase-next/people/support/help-tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `help-${row.id}`,
|
||||
@@ -438,8 +438,8 @@ async function fetchStrictUnifiedTicketInbox(
|
||||
}
|
||||
const prefix =
|
||||
row.kind === "cms"
|
||||
? "/ase/people/support/tickets"
|
||||
: "/ase/people/support/help-tickets";
|
||||
? "/ase-next/people/support/tickets"
|
||||
: "/ase-next/people/support/help-tickets";
|
||||
const dateValue =
|
||||
row.dateValue === null
|
||||
? null
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock("next/headers", () => ({
|
||||
headers: mocks.headers,
|
||||
}));
|
||||
|
||||
import { readCsrfCookieToken } from "./security";
|
||||
import { readCsrfCookieToken, safeRedirect } from "./security";
|
||||
|
||||
describe("readCsrfCookieToken", () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,3 +39,26 @@ describe("readCsrfCookieToken", () => {
|
||||
await expect(readCsrfCookieToken()).resolves.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeRedirect", () => {
|
||||
it("recognizes only the canonical Housekeeping private prefix", () => {
|
||||
const canonical = "/ase-next/content/%";
|
||||
const legacy = ["/", "ase", "/content/%"].join("");
|
||||
const NativeUrl = globalThis.URL;
|
||||
|
||||
try {
|
||||
vi.stubGlobal(
|
||||
"URL",
|
||||
class {
|
||||
constructor() {
|
||||
throw new Error("invalid URL");
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(safeRedirect(canonical, "/")).toBe(canonical);
|
||||
expect(safeRedirect(legacy, "/")).toBe("/");
|
||||
} finally {
|
||||
vi.stubGlobal("URL", NativeUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,12 @@ const SAFE_REDIRECT_PATHS = new Set([
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/ase/") || path.startsWith("/api/")) return true;
|
||||
if (
|
||||
path === "/ase-next" ||
|
||||
path.startsWith("/ase-next/") ||
|
||||
path.startsWith("/api/")
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase/system/operations/alerts">view alerts</a></p>`;
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase-next/system/operations/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
|
||||
Reference in new issue
Block a user