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: {
|
rules: {
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: ["/ase/", "/api/", "/settings/", "/me/"],
|
disallow: ["/ase-next/", "/api/", "/settings/", "/me/"],
|
||||||
},
|
},
|
||||||
sitemap: `${appUrl}/sitemap.xml`,
|
sitemap: `${appUrl}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ describe("BcPageDetail", () => {
|
|||||||
page={page}
|
page={page}
|
||||||
items={[]}
|
items={[]}
|
||||||
canEdit={false}
|
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"');
|
expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -86,7 +86,7 @@ export function BcPageDetail({
|
|||||||
page,
|
page,
|
||||||
items,
|
items,
|
||||||
canEdit,
|
canEdit,
|
||||||
returnHref = "/ase/economy/catalog",
|
returnHref = "/ase-next/economy/catalog",
|
||||||
}: BcPageDetailProps) {
|
}: BcPageDetailProps) {
|
||||||
const { isPending, run } = useServerAction();
|
const { isPending, run } = useServerAction();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
|
|||||||
@@ -1751,7 +1751,7 @@ export function CatalogItemsTable({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Select a soundtrack to play when this item is used
|
Select a soundtrack to play when this item is used
|
||||||
in-game. Uploaded from{" "}
|
in-game. Uploaded from{" "}
|
||||||
<code>/ase/economy/rewards/sounds</code>.
|
<code>/ase-next/economy/rewards/sounds</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function CatalogPageForm({
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
||||||
successMessage: "Page deleted.",
|
successMessage: "Page deleted.",
|
||||||
redirectTo: "/ase/economy/catalog",
|
redirectTo: "/ase-next/economy/catalog",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ export function CatalogPageForm({
|
|||||||
{childPages.map((child) => (
|
{childPages.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
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"
|
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showAdmin || showMod ? (
|
{showAdmin || showMod ? (
|
||||||
<Link href="/ase" className="nav-item">
|
<Link href="/ase-next" className="nav-item">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/home.png"
|
src="/assets/images/icons/navigation/home.png"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -397,7 +397,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showAdmin || showMod ? (
|
{showAdmin || showMod ? (
|
||||||
<Link href="/ase" className="nav-item">
|
<Link href="/ase-next" className="nav-item">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/home.png"
|
src="/assets/images/icons/navigation/home.png"
|
||||||
alt=""
|
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";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const PREVIEW_ENTRYPOINTS = [
|
const PREVIEW_ENTRYPOINTS = [
|
||||||
@@ -14,13 +15,25 @@ const PREMATURE_UI_ROOTS = [
|
|||||||
["src/app/admin", "-next"].join(""),
|
["src/app/admin", "-next"].join(""),
|
||||||
["src/app", "ase"].join("/"),
|
["src/app", "ase"].join("/"),
|
||||||
] as const;
|
] as const;
|
||||||
const STABLE_GLOBAL_SOURCES = [
|
const CUTOVER_GLOBAL_SOURCES = [
|
||||||
"src/lib/admin/guard.ts",
|
"src/lib/admin/guard.ts",
|
||||||
"src/lib/proxy-access.ts",
|
|
||||||
"src/components/navigation.tsx",
|
"src/components/navigation.tsx",
|
||||||
"src/components/top-header.tsx",
|
|
||||||
] as const;
|
] 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", () => {
|
describe("gated Housekeeping preview coexistence", () => {
|
||||||
it("publishes the /ase-next entrypoints and dispatcher", () => {
|
it("publishes the /ase-next entrypoints and dispatcher", () => {
|
||||||
for (const path of PREVIEW_ENTRYPOINTS) {
|
for (const path of PREVIEW_ENTRYPOINTS) {
|
||||||
@@ -58,9 +71,9 @@ describe("gated Housekeeping preview coexistence", () => {
|
|||||||
expect(gate).toContain("input.flag");
|
expect(gate).toContain("input.flag");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves stable global links and authorization fallbacks on /admin and /mod", () => {
|
it("canonicalizes Housekeeping links while retaining /admin and /mod", () => {
|
||||||
for (const path of STABLE_GLOBAL_SOURCES) {
|
for (const path of CUTOVER_GLOBAL_SOURCES) {
|
||||||
expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next");
|
expect(readFileSync(path, "utf8"), path).toContain("/ase-next");
|
||||||
}
|
}
|
||||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||||
'href="/admin"',
|
'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!' })
|
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
|
||||||
*
|
*
|
||||||
* // Delete with redirect
|
* // Delete with redirect
|
||||||
* run(() => deleteNews(id), { redirectTo: '/ase/content/articles' })
|
* run(() => deleteNews(id), { redirectTo: '/ase-next/content/articles' })
|
||||||
*/
|
*/
|
||||||
export function useServerAction(options: UseServerActionOptions = {}) {
|
export function useServerAction(options: UseServerActionOptions = {}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ describe("requireStaffRateLimited", () => {
|
|||||||
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||||
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||||
await requireStaffRateLimited();
|
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());
|
context ?? (await getHousekeepingCapabilityContext());
|
||||||
const authorization = authorizeHousekeeping(capabilityContext, requirement);
|
const authorization = authorizeHousekeeping(capabilityContext, requirement);
|
||||||
|
|
||||||
if (!authorization.ok) redirectSafe("/ase", "/ase");
|
if (!authorization.ok) redirectSafe("/ase-next", "/ase-next");
|
||||||
|
|
||||||
return capabilityContext;
|
return capabilityContext;
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ export async function requirePermission(
|
|||||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||||
redirectSafe("/", "/");
|
redirectSafe("/", "/");
|
||||||
if (!canAccess(permissions, permission, session.user.rank))
|
if (!canAccess(permissions, permission, session.user.rank))
|
||||||
redirectSafe("/ase", "/ase");
|
redirectSafe("/ase-next", "/ase-next");
|
||||||
return {
|
return {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
rank: session.user.rank,
|
rank: session.user.rank,
|
||||||
@@ -62,7 +62,7 @@ export async function requirePermissionRateLimited(
|
|||||||
const staff = await requirePermission(permission);
|
const staff = await requirePermission(permission);
|
||||||
const ip = await clientIp();
|
const ip = await clientIp();
|
||||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
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;
|
return staff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
|||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const ip = await clientIp();
|
const ip = await clientIp();
|
||||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
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;
|
return staff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +106,6 @@ export async function requireModPermission(
|
|||||||
const { permissions } = await getAdminContext();
|
const { permissions } = await getAdminContext();
|
||||||
const needed = Array.isArray(permission) ? permission : [permission];
|
const needed = Array.isArray(permission) ? permission : [permission];
|
||||||
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
|
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
|
||||||
if (!ok) redirectSafe("/ase", "/ase");
|
if (!ok) redirectSafe("/ase-next", "/ase-next");
|
||||||
return staff;
|
return staff;
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ async function fetchCmsCandidates(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
|
|
||||||
const prefix = "/ase/people/support/tickets";
|
const prefix = "/ase-next/people/support/tickets";
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
key: `cms-${row.id}`,
|
key: `cms-${row.id}`,
|
||||||
@@ -190,7 +190,7 @@ async function fetchHelpCandidates(
|
|||||||
: [];
|
: [];
|
||||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
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) => ({
|
return rows.map((row) => ({
|
||||||
key: `help-${row.id}`,
|
key: `help-${row.id}`,
|
||||||
@@ -438,8 +438,8 @@ async function fetchStrictUnifiedTicketInbox(
|
|||||||
}
|
}
|
||||||
const prefix =
|
const prefix =
|
||||||
row.kind === "cms"
|
row.kind === "cms"
|
||||||
? "/ase/people/support/tickets"
|
? "/ase-next/people/support/tickets"
|
||||||
: "/ase/people/support/help-tickets";
|
: "/ase-next/people/support/help-tickets";
|
||||||
const dateValue =
|
const dateValue =
|
||||||
row.dateValue === null
|
row.dateValue === null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock("next/headers", () => ({
|
|||||||
headers: mocks.headers,
|
headers: mocks.headers,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { readCsrfCookieToken } from "./security";
|
import { readCsrfCookieToken, safeRedirect } from "./security";
|
||||||
|
|
||||||
describe("readCsrfCookieToken", () => {
|
describe("readCsrfCookieToken", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -39,3 +39,26 @@ describe("readCsrfCookieToken", () => {
|
|||||||
await expect(readCsrfCookieToken()).resolves.toBe("");
|
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 {
|
function isSafePath(path: string): boolean {
|
||||||
if (!path.startsWith("/")) return false;
|
if (!path.startsWith("/")) return false;
|
||||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
|||||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||||
: "") +
|
: "") +
|
||||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
`<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 {
|
try {
|
||||||
return await sendMail(to, subject, html);
|
return await sendMail(to, subject, html);
|
||||||
|
|||||||
Reference in new issue
Block a user