This commit is contained in:
Your Name committed 2026-10-01 17:26:18 +07:00
1 parent 78b722e120
commit 75770ca122
18 files changed
+1119 -318

No files matched your search

@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_FOOTER_LAYOUT_SETTINGS } from '@homaroma/types'
import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
validateFooterLayoutSettingsPatch,
} from './footerLayoutSettings'
describe('footer layout settings', () => {
test('normalizes missing and invalid persisted values to the shared default', () => {
expect(normalizeFooterLayoutSettings(null)).toEqual(DEFAULT_FOOTER_LAYOUT_SETTINGS)
expect(normalizeFooterLayoutSettings({ columns: ['brand'] })).toEqual(
DEFAULT_FOOTER_LAYOUT_SETTINGS,
)
})
test('preserves a valid persisted layout', () => {
const layout = { ...DEFAULT_FOOTER_LAYOUT_SETTINGS, mobileColumns: 2 as const }
expect(normalizeFooterLayoutSettings(layout)).toEqual(layout)
})
test('merges a partial finale patch without losing sibling settings', () => {
expect(mergeFooterLayoutSettings(DEFAULT_FOOTER_LAYOUT_SETTINGS, {
containerWidth: 'wide',
finale: { enabled: false },
})).toEqual({
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
containerWidth: 'wide',
finale: { ...DEFAULT_FOOTER_LAYOUT_SETTINGS.finale, enabled: false },
})
})
test('strictly rejects unknown patch fields', () => {
const result = validateFooterLayoutSettingsPatch({ arbitraryCss: 'display:none' })
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,32 @@
import {
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsPatchSchema,
FooterLayoutSettingsSchema,
type FooterLayoutSettings,
type FooterLayoutSettingsPatch,
} from '@homaroma/types'
export function normalizeFooterLayoutSettings(value: unknown): FooterLayoutSettings {
const result = FooterLayoutSettingsSchema.safeParse(value)
if (result.success) return result.data
return FooterLayoutSettingsSchema.parse(DEFAULT_FOOTER_LAYOUT_SETTINGS)
}
export function validateFooterLayoutSettingsPatch(value: unknown) {
return FooterLayoutSettingsPatchSchema.safeParse(value)
}
export function mergeFooterLayoutSettings(
current: FooterLayoutSettings,
patch: FooterLayoutSettingsPatch,
): FooterLayoutSettings {
return FooterLayoutSettingsSchema.parse({
...current,
...patch,
finale: {
...current.finale,
...patch.finale,
},
})
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { requireAdmin } from './auth'
function context(authorization?: string, payload: unknown = null) {
return {
jwt: { verify: async () => payload },
headers: { authorization },
set: { status: 200 },
}
}
describe('requireAdmin', () => {
test('rejects a missing bearer token', async () => {
const request = context()
expect(await requireAdmin(request)).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
expect(request.set.status).toBe(401)
})
test('rejects a non-admin token', async () => {
const request = context('Bearer customer-token', { role: 'CUSTOMER' })
expect(await requireAdmin(request)).toEqual({
error: 'Unauthorized: Admin access required',
})
expect(request.set.status).toBe(401)
})
test('allows an admin token', async () => {
const request = context('Bearer admin-token', { role: 'ADMIN' })
expect(await requireAdmin(request)).toBeUndefined()
expect(request.set.status).toBe(200)
})
})
+22 -20
View File
@@ -1,6 +1,27 @@
import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt";
export async function requireAdmin({ jwt, headers, set }: any) {
const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) {
set.status = 401;
return { error: "Unauthorized: Missing or invalid token format" };
}
const token = auth.substring(7);
const payload = await jwt.verify(token);
if (!payload) {
set.status = 401;
return { error: "Unauthorized: Invalid token" };
}
if ((payload as any).role !== "ADMIN") {
set.status = 401;
return { error: "Unauthorized: Admin access required" };
}
}
export const adminOnly = new Elysia({ name: "adminOnly" })
.use(
jwt({
@@ -8,23 +29,4 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
secret: process.env.JWT_SECRET || "fallback-secret"
})
)
.onBeforeHandle(async ({ jwt, headers, set }) => {
const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) {
set.status = 401;
return { error: "Unauthorized: Missing or invalid token format" };
}
const token = auth.substring(7);
const payload = await jwt.verify(token);
if (!payload) {
set.status = 401;
return { error: "Unauthorized: Invalid token" };
}
if ((payload as any).role !== "ADMIN") {
set.status = 401;
return { error: "Unauthorized: Admin access required" };
}
});
.onBeforeHandle(requireAdmin);
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { settingsRoute } from './settings'
function jsonRequest(path: string, method: string, body: unknown) {
return new Request(`http://localhost${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('footer layout route boundaries', () => {
for (const method of ['PUT', 'POST']) {
test(`${method} /settings rejects footerLayout writes`, async () => {
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] },
}))
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Use PATCH /settings/footer-layout to update footer layout',
})
})
}
test('PATCH /settings/footer-layout requires admin authentication', async () => {
const response = await settingsRoute.handle(jsonRequest('/settings/footer-layout', 'PATCH', {
dryRun: true,
}))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
})
})
+109 -4
View File
@@ -1,6 +1,25 @@
import { Elysia, t } from "elysia";
import { FooterLayoutSettingsPatchSchema } from "@homaroma/types";
import { z } from "zod";
import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { adminOnly, requireAdmin } from "../middleware/auth";
import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
} from "../lib/footerLayoutSettings";
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
expectedVersion: z.string().optional(),
dryRun: z.boolean().optional(),
}).strict().superRefine((value, context) => {
if (!value.dryRun && !value.expectedVersion) {
context.addIssue({
code: "custom",
path: ["expectedVersion"],
message: "expectedVersion is required when updating footer layout",
});
}
});
export async function saveStoreSettingsTranslations(settingsId: number, payload: any) {
const results: any[] = [];
@@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settingsData,
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
translations,
headerBehavior,
mobileHeaderBehavior,
@@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
return { error: "Failed to fetch footer brand" };
}
})
.get("/footer-layout", async ({ set }) => {
try {
const settings = await prisma.storeSettings.findFirst();
const current = settings ?? await prisma.storeSettings.create({ data: {} });
return {
version: current.updatedAt.toISOString(),
layout: normalizeFooterLayoutSettings((current as any).footerLayout),
};
} catch (err) {
console.error("[Settings] Footer layout fetch error:", err);
set.status = 500;
return { error: "Failed to fetch footer layout" };
}
})
.use(adminOnly)
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
try {
@@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 400;
return { error: err?.message || "Failed to update footer brand" };
}
}, { body: t.Any() })
}, { beforeHandle: requireAdmin, body: t.Any() })
.patch("/footer-layout", async ({ body, set }: { body: unknown; set: any }) => {
const request = FooterLayoutUpdateRequestSchema.safeParse(body);
if (!request.success) {
set.status = 400;
return { error: "Invalid footer layout", issues: request.error.issues };
}
try {
const { expectedVersion, dryRun, ...patch } = request.data;
const settings = await prisma.storeSettings.findFirst();
const current = settings ?? await prisma.storeSettings.create({ data: {} });
const version = current.updatedAt.toISOString();
if (expectedVersion && expectedVersion !== version) {
set.status = 409;
return {
error: "Footer layout has changed since it was read",
code: "VERSION_CONFLICT",
expectedVersion,
currentVersion: version,
};
}
const currentLayout = normalizeFooterLayoutSettings((current as any).footerLayout);
const layout = mergeFooterLayoutSettings(currentLayout, patch);
if (dryRun) {
return { dryRun: true, version, wouldUpdate: layout };
}
const updated = await prisma.$transaction(async (transaction) => {
const result = await transaction.storeSettings.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: { footerLayout: layout as any },
});
if (result.count === 0) return null;
return transaction.storeSettings.findUniqueOrThrow({ where: { id: current.id } });
});
if (!updated) {
const latest = await prisma.storeSettings.findUnique({ where: { id: current.id } });
set.status = 409;
return {
error: "Footer layout has changed since it was read",
code: "VERSION_CONFLICT",
expectedVersion,
currentVersion: latest?.updatedAt.toISOString() ?? version,
};
}
return {
success: true,
version: updated.updatedAt.toISOString(),
layout,
};
} catch (err) {
console.error("[Settings] Footer layout update error:", err);
set.status = 500;
return { error: "Failed to update footer layout" };
}
}, { beforeHandle: requireAdmin, body: t.Any() })
.put("/", async ({ body, set }: { body: any; set: any }) => {
try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
if (footerLayout !== undefined) {
set.status = 400;
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
}
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens
: {};
@@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())),
brandPrompt: t.Optional(t.Nullable(t.String())),
footerLayout: t.Optional(t.Any()),
translations: t.Optional(t.Any()),
})
})
.post("/", async ({ body, set }: { body: any; set: any }) => {
try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
if (footerLayout !== undefined) {
set.status = 400;
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
}
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens
: {};
@@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())),
brandPrompt: t.Optional(t.Nullable(t.String())),
footerLayout: t.Optional(t.Any()),
translations: t.Optional(t.Any()),
})
})