fix(housekeeping): harden preference persistence
This commit is contained in:
1 parent
64bb230de5
commit
4e0bf598ed
7 files changed
+272
-82
No files matched your search
@@ -1,4 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const drizzleMocks = vi.hoisted(() => ({
|
||||
eq: vi.fn((column: unknown, value: unknown) => ({ column, value })),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("drizzle-orm")>()),
|
||||
eq: drizzleMocks.eq,
|
||||
}));
|
||||
|
||||
import { HousekeepingUserPreferences } from "@/lib/db";
|
||||
import { createDrizzleHousekeepingPreferencesStorage } from "@/lib/housekeeping-preferences-repository";
|
||||
import {
|
||||
createHousekeepingPreferencesRepository,
|
||||
type HousekeepingPreferencesStorage,
|
||||
@@ -39,4 +51,90 @@ describe("housekeeping preferences repository", () => {
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a fresh default for malformed or incompatible stored values", async () => {
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ schemaVersion: 1, payload: "{broken" })
|
||||
.mockResolvedValueOnce({ schemaVersion: 2, payload: "{}" })
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify({ schemaVersion: 1 }),
|
||||
}),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
const repository = createHousekeepingPreferencesRepository(storage);
|
||||
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
await expect(repository.read(42)).resolves.toEqual(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves genuine storage failures instead of treating them as corrupt preferences", async () => {
|
||||
const storage: HousekeepingPreferencesStorage = {
|
||||
findByUserId: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("database unavailable")),
|
||||
upsert: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
createHousekeepingPreferencesRepository(storage).read(42),
|
||||
).rejects.toThrow("database unavailable");
|
||||
});
|
||||
|
||||
it("uses the preferences table and user ID for the typed Drizzle read and upsert", async () => {
|
||||
const limit = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn().mockReturnValue({ onDuplicateKeyUpdate });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
const storage = createDrizzleHousekeepingPreferencesStorage({
|
||||
select,
|
||||
insert,
|
||||
} as never);
|
||||
const value = defaultHousekeepingPreferences();
|
||||
|
||||
await storage.findByUserId(42);
|
||||
await storage.upsert({
|
||||
userId: 42,
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
|
||||
expect(select).toHaveBeenCalledWith({
|
||||
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||
payload: HousekeepingUserPreferences.payload,
|
||||
});
|
||||
expect(from).toHaveBeenCalledWith(HousekeepingUserPreferences);
|
||||
expect(drizzleMocks.eq).toHaveBeenCalledWith(
|
||||
HousekeepingUserPreferences.userId,
|
||||
42,
|
||||
);
|
||||
expect(where).toHaveBeenCalledWith({
|
||||
column: HousekeepingUserPreferences.userId,
|
||||
value: 42,
|
||||
});
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
userId: 42,
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
});
|
||||
expect(onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||
set: expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
payload: JSON.stringify(value),
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,11 +32,15 @@ export function createHousekeepingPreferencesRepository(
|
||||
async read(userId) {
|
||||
const row = await storage.findByUserId(userId);
|
||||
if (!row) return defaultHousekeepingPreferences();
|
||||
if (row.schemaVersion !== 1) {
|
||||
throw new Error("unsupported housekeeping preferences schema version");
|
||||
}
|
||||
|
||||
return parseHousekeepingPreferences(row.payload);
|
||||
try {
|
||||
if (row.schemaVersion !== 1) {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
return parseHousekeepingPreferences(row.payload);
|
||||
} catch {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
},
|
||||
async upsert(userId, value) {
|
||||
await storage.upsert({
|
||||
|
||||
@@ -43,4 +43,32 @@ describe("housekeeping preferences schema", () => {
|
||||
|
||||
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
|
||||
});
|
||||
|
||||
it("rejects identifiers and lists that exceed the bounded presentation payload", () => {
|
||||
const value = defaultHousekeepingPreferences();
|
||||
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: ["a".repeat(129)],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from(
|
||||
{ length: 65 },
|
||||
(_, index) => `route.${index}`,
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from({ length: 33 }, (_, index) =>
|
||||
`${index}`.padEnd(128, "a"),
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH = 128;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY = 64;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES = 4096;
|
||||
|
||||
export interface HousekeepingPreferences {
|
||||
schemaVersion: 1;
|
||||
pinnedRouteIds: string[];
|
||||
@@ -10,7 +14,10 @@ export interface HousekeepingPreferences {
|
||||
}
|
||||
|
||||
const uniqueIdentifiers = z
|
||||
.array(z.string().trim().min(1))
|
||||
.array(
|
||||
z.string().trim().min(1).max(HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH),
|
||||
)
|
||||
.max(HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY)
|
||||
.superRefine((values, context) => {
|
||||
const seen = new Set<string>();
|
||||
for (const [index, value] of values.entries()) {
|
||||
@@ -35,7 +42,18 @@ export const housekeepingPreferencesSchema: z.ZodType<HousekeepingPreferences> =
|
||||
widgetOrder: uniqueIdentifiers,
|
||||
enabledOptionalWidgetIds: uniqueIdentifiers,
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine((preferences, context) => {
|
||||
if (
|
||||
JSON.stringify(preferences).length >
|
||||
HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "housekeeping preferences payload is too large",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user