Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/api/src/lib/theme-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export async function validateThemeConfiguration(
patch: FrontendSettings,
) {
const current = await readThemeConfiguration(db, id);
const migrated = migrateFrontendSettings(current.schema, patch)!;
// `migrateFrontendSettings` 对空输入返回 undefined;补丁为空时按空对象处理,
// 而不是用 `!` 断言「一定非空」。断言断言不了这一点,只会把 undefined 带进
// 下面的 Object.entries。
const migrated = migrateFrontendSettings(current.schema, patch) ?? {};
const settings = mergeFrontendSettings(current.settings, migrated);
// Unknown keys must fail rather than being silently discarded by Zod.
for (const [group, values] of Object.entries(migrated)) {
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/routes/theme-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export async function themeConfigurationRoutes(app: FastifyInstance) {
if (base === '/admin/themes')
app.get(`${base}/:id/configuration/preview`, { preHandler: guards }, async (request) => {
const { id } = paramsSchema.parse(request.params);
return { settings: await readThemeConfigurationDraft(getPrisma(), id, request.user!.id) };
const userId = request.user?.id;
if (!userId) throw new PluginError('auth.unauthenticated', 401, '未登录或会话已过期');
return { settings: await readThemeConfigurationDraft(getPrisma(), id, userId) };
});
app.post(`${base}/:id/configuration/validate`, { preHandler: guards }, async (request) => {
const { id } = paramsSchema.parse(request.params);
Expand All @@ -45,7 +47,9 @@ export async function themeConfigurationRoutes(app: FastifyInstance) {
app.post(`${base}/:id/configuration/preview`, { preHandler: guards }, async (request) => {
const { id } = paramsSchema.parse(request.params);
const body = z.object({ settings }).strict().parse(request.body);
await saveThemeConfigurationDraft(getPrisma(), id, request.user!.id, body.settings);
const userId = request.user?.id;
if (!userId) throw new PluginError('auth.unauthenticated', 401, '未登录或会话已过期');
await saveThemeConfigurationDraft(getPrisma(), id, userId, body.settings);
return { ok: true };
});
app.patch(`${base}/:id/configuration`, { preHandler: guards }, async (request, reply) => {
Expand Down
37 changes: 33 additions & 4 deletions apps/api/tests/integration/audit-referral-reward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,36 @@ import { buildApp } from '../../src/app.ts';
import { getPrisma } from '../../src/plugins/prisma.ts';
import { checkDbAvailable } from '../helpers.ts';
import { createAdminUser } from '../auth-helpers.ts';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { env } from '../../src/config/env.ts';

const dbAvailable = await checkDbAvailable();

/**
* Is the referral plugin installed in this data directory?
*
* `referral` is not a kernel built-in: its source lives in the separate
* `stackpanel-plugins` repository, and only a deployed environment has it under
* `<dataDir>/plugins/referral`. A CI runner does not. Without this probe the
* suite activates a missing plugin, then fails on `relation
* "ext_referral_code" does not exist` — an error that reads like a schema bug
* when the real cause is "the plugin was never installed here".
*/
const referralInstalled = existsSync(
path.join(
env.STACKPANEL_DATA_DIR ?? path.join(process.cwd(), 'data'),
'plugins',
'referral',
'manifest.json',
),
);

/**
* Regression: user-created referral codes inherit the administrator-owned
* platform reward and cannot set or update a per-code amount.
*/
describe.skipIf(!dbAvailable)('regression: referral reward ownership', () => {
describe.skipIf(!dbAvailable || !referralInstalled)('regression: referral reward ownership', () => {
let app: FastifyInstance;
const suffix = Date.now();
const adminEmail = `audit_ref_admin_${suffix}@example.com`;
Expand Down Expand Up @@ -42,12 +64,18 @@ describe.skipIf(!dbAvailable)('regression: referral reward ownership', () => {
afterAll(async () => {
const prisma = getPrisma();
const emails = [adminEmail, attackerEmail];
const users = await prisma.user.findMany({ where: { email: { in: emails } }, select: { id: true } });
const users = await prisma.user.findMany({
where: { email: { in: emails } },
select: { id: true },
});
const ids = users.map((u) => u.id);
await prisma.walletLedgerEntry.deleteMany({ where: { userId: { in: ids } } });
await prisma.walletAccount.deleteMany({ where: { userId: { in: ids } } });
await prisma.user.deleteMany({ where: { email: { in: emails } } });
await prisma.$executeRawUnsafe(`DELETE FROM "ext_referral_code" WHERE "spec"->>'ownerId' = $1`, attackerId);
await prisma.$executeRawUnsafe(
`DELETE FROM "ext_referral_code" WHERE "spec"->>'ownerId' = $1`,
attackerId,
);
for (const id of ['store', 'referral']) {
await prisma.plugin.deleteMany({ where: { id } });
}
Expand Down Expand Up @@ -97,7 +125,8 @@ describe.skipIf(!dbAvailable)('regression: referral reward ownership', () => {
payload: { label: 'audit' },
});
expect(created.statusCode).toBe(200);
const code = (created.json() as { code: { id: string; code: string; rewardAmount: number } }).code;
const code = (created.json() as { code: { id: string; code: string; rewardAmount: number } })
.code;
expect(code.rewardAmount).toBe(1);

const rejectedUpdate = await app.inject({
Expand Down
6 changes: 4 additions & 2 deletions apps/api/tests/unit/market-theme-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type * as SdkPaths from '@stackpanel/sdk/paths';
import type * as MarketModule from '../../src/lib/market.ts';

const state = vi.hoisted(() => ({ dataDir: '', record: null as null | {
id: string; name: string; version: string; active: boolean;
}, failWrite: false }));
vi.mock('../../src/config/env.ts', () => ({ env: { PACKAGE_SIGNATURE_REQUIRED: false } }));
vi.mock('@stackpanel/sdk/paths', async (importOriginal) => ({
...await importOriginal<typeof import('@stackpanel/sdk/paths')>(),
...(await importOriginal<typeof SdkPaths>()),
resolveStackPanelDataDir: () => state.dataDir,
}));
vi.mock('../../src/plugins/prisma.ts', () => ({
Expand All @@ -28,7 +30,7 @@ vi.mock('../../src/plugins/prisma.ts', () => ({
}));

let root: string;
let market: typeof import('../../src/lib/market.ts');
let market: typeof MarketModule;
const source = { id: 'test-theme', name: 'Test Theme', version: '2.0.0', sourceDir: '' };

beforeAll(async () => {
Expand Down
63 changes: 40 additions & 23 deletions apps/web/src/components/theme-json-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,45 @@ const CodeMirror = dynamic(() => import('@uiw/react-codemirror').then((mod) => m
});
const print = (value: unknown) => JSON.stringify(value, null, 2);

/**
* Validate a JSON draft against the theme's settings schema.
*
* Extracted from the component body: React Compiler refuses to preserve a
* `useMemo` whose callback mixes `try/catch` with early returns, and fails with
* `react-hooks/preserve-manual-memoization`. A module-level pure function keeps
* the hook trivial and makes the validation independently testable, which the
* inline version was not.
*/
function validateDraft(
schema: FrontendSettingsSchema,
text: string,
t: (key: string) => string,
): { settings?: FrontendSettings; error?: string } {
try {
const value: unknown = JSON.parse(text);
const parsed = buildZodFromSettingsSchema(schema).safeParse(value);
if (!parsed.success)
return {
error: parsed.error.issues
.map((issue) => `${issue.path.join('.') || '$'}: ${issue.message}`)
.join('\n'),
};
const raw = migrateFrontendSettings(schema, value as FrontendSettings);
if (raw) {
for (const [group, fields] of Object.entries(raw)) {
const definition = schema.groups.find((item) => item.id === group);
if (!definition) return { error: `${group}: ${t('themeJson.unknownField')}` };
for (const name of Object.keys(fields))
if (!definition.fields.some((field) => field.name === name))
return { error: `${group}.${name}: ${t('themeJson.unknownField')}` };
}
}
return { settings: parsed.data };
} catch (cause) {
return { error: cause instanceof Error ? cause.message : t('themeJson.invalid') };
}
}

function describeFields(
fields: FrontendSettingsField[],
prefix: string,
Expand Down Expand Up @@ -78,29 +117,7 @@ export function ThemeJsonEditor({
return () => observer.disconnect();
}, []);

const validation = useMemo(() => {
try {
const value: unknown = JSON.parse(text);
const parsed = buildZodFromSettingsSchema(schema).safeParse(value);
if (!parsed.success)
return {
error: parsed.error.issues
.map((issue) => `${issue.path.join('.') || '$'}: ${issue.message}`)
.join('\n'),
};
const raw = migrateFrontendSettings(schema, value as FrontendSettings)!;
for (const [group, fields] of Object.entries(raw)) {
const definition = schema.groups.find((item) => item.id === group);
if (!definition) return { error: `${group}: ${t('themeJson.unknownField')}` };
for (const name of Object.keys(fields))
if (!definition.fields.some((field) => field.name === name))
return { error: `${group}.${name}: ${t('themeJson.unknownField')}` };
}
return { settings: parsed.data };
} catch (cause) {
return { error: cause instanceof Error ? cause.message : t('themeJson.invalid') };
}
}, [schema, t, text]);
const validation = useMemo(() => validateDraft(schema, text, t), [schema, t, text]);
const changes = useMemo(() => {
if (!validation.settings) return [];
return schema.groups.flatMap((group) =>
Expand Down
40 changes: 30 additions & 10 deletions apps/web/tests/cart-checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,30 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock('next/link', () => ({ default: 'a' }));
import { CartCheckout, type CartCheckoutProps } from '@/components/cart-checkout';

function nodes(value: unknown): ReactElement<Record<string, any>>[] {
/**
* Props this suite actually inspects.
*
* Listed explicitly instead of `Record<string, any>`: a typo in an assertion
* (`node.props.onClik`) must fail the typecheck rather than silently read
* `undefined` and make the test pass for the wrong reason.
*/
interface ElementProps {
children?: unknown;
name?: string;
value?: unknown;
type?: string;
role?: string;
action?: (data: FormData) => unknown;
onClick?: () => void;
onChange?: (event: unknown) => void;
onSubmit?: (event: { preventDefault: () => void }) => void;
disabled?: boolean;
}

function nodes(value: unknown): ReactElement<ElementProps>[] {
if (Array.isArray(value)) return value.flatMap(nodes);
if (!value || typeof value !== 'object' || !('props' in value)) return [];
const element = value as ReactElement<Record<string, any>>;
const element = value as ReactElement<ElementProps>;
return [element, ...nodes(element.props.children)];
}
function setup() {
Expand Down Expand Up @@ -58,7 +78,7 @@ function setup() {
const payment = () => {
render()
.find((node) => node.type === 'button' && node.props.onClick)!
.props.onClick();
.props.onClick!();
return render();
};
return { props, wallet, external, render, payment };
Expand All @@ -76,27 +96,27 @@ describe('checkout directly from cart', () => {
expect(tree.find((node) => node.props.name === 'providerId')).toBeUndefined();
const data = new FormData();
data.append('cartItemIds', 'one');
await tree.find((node) => node.type === 'form')!.props.action(data);
await tree.find((node) => node.type === 'form')!.props.action!(data);
expect(ui.wallet).toHaveBeenCalledWith(data);
expect(ui.external).not.toHaveBeenCalled();
});
it('switches external provider and method together with the payment action', async () => {
const ui = setup();
ui.payment()
.find((node) => node.type === 'select')!
.props.onChange({ target: { value: 'epay:alipay' } });
.props.onChange!({ target: { value: 'epay:alipay' } });
const tree = ui.render();
expect(tree.find((node) => node.props.name === 'providerId')?.props.value).toBe('epay');
expect(tree.find((node) => node.props.name === 'paymentMethod')?.props.value).toBe('alipay');
await tree.find((node) => node.type === 'form')!.props.action(new FormData());
await tree.find((node) => node.type === 'form')!.props.action!(new FormData());
expect(ui.external).toHaveBeenCalledOnce();
expect(ui.wallet).not.toHaveBeenCalled();
});
it('prevents checkout when selection is empty', () => {
const ui = setup();
ui.render()
.find((node) => node.props.type === 'checkbox')!
.props.onChange({ target: { checked: false } });
.props.onChange!({ target: { checked: false } });
const tree = ui.render();
expect(tree.find((node) => node.type === 'button')?.props.disabled).toBe(true);
expect(tree.some((node) => node.type === 'form')).toBe(false);
Expand All @@ -106,11 +126,11 @@ describe('checkout directly from cart', () => {
ui.wallet.mockRejectedValue(new Error('rejected'));
ui.payment()
.find((node) => node.type === 'form')!
.props.onSubmit({ preventDefault: vi.fn() });
.props.onSubmit!({ preventDefault: vi.fn() });
let tree = ui.render();
expect(tree.find((node) => node.type === 'button')?.props.disabled).toBe(true);
// Failures stay on the payment step with a localised notice; the raw error is not rethrown.
await tree.find((node) => node.type === 'form')!.props.action(new FormData());
await tree.find((node) => node.type === 'form')!.props.action!(new FormData());
tree = ui.render();
expect(tree.find((node) => node.type === 'button')?.props.disabled).toBe(false);
expect(tree.some((node) => node.props.role === 'alert')).toBe(true);
Expand All @@ -119,7 +139,7 @@ describe('checkout directly from cart', () => {
const ui = setup();
expect(ui.render().some((node) => node.type === 'select')).toBe(false);
const payment = ui.payment();
payment.find((node) => node.type === 'button' && node.props.onClick)!.props.onClick();
payment.find((node) => node.type === 'button' && node.props.onClick)!.props.onClick!();
expect(ui.render().some((node) => node.props.type === 'checkbox')).toBe(true);
expect(ui.wallet).not.toHaveBeenCalled();
});
Expand Down
23 changes: 13 additions & 10 deletions packages/sdk/src/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,16 +749,19 @@ export function migrateFrontendSettings(
const keys =
typeof source === 'string' ? [source] : source.flatMap((row) => Object.values(row));
if (values[field.name] === undefined && keys.some((key) => values[key] !== undefined)) {
values[field.name] =
typeof source === 'string'
? values[source]!
: source.map((mapping, index) => {
const row = { ...field.default?.[index] };
for (const [target, key] of Object.entries(mapping)) {
if (values[key] !== undefined) row[target] = values[key]!;
}
return row;
});
if (typeof source === 'string') {
const migrated = values[source];
if (migrated !== undefined) values[field.name] = migrated;
} else {
values[field.name] = source.map((mapping, index) => {
const row = { ...field.default?.[index] };
for (const [target, key] of Object.entries(mapping)) {
const value = values[key];
if (value !== undefined) row[target] = value;
}
return row;
});
}
}
for (const key of keys) if (key !== field.name) delete values[key];
}
Expand Down
Loading