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,89 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
|
||||
const preferenceRepository = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/housekeeping-preferences-repository", () => ({
|
||||
housekeepingPreferencesRepository: preferenceRepository,
|
||||
}));
|
||||
|
||||
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
|
||||
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import {
|
||||
type HousekeepingPreferencesActionDependencies,
|
||||
loadHousekeepingPreferences,
|
||||
saveHousekeepingPreferences,
|
||||
} from "./housekeeping-preferences";
|
||||
|
||||
const registry: HousekeepingRegistry = { domains: [] };
|
||||
const allowedContext = {
|
||||
actor: { id: 42, username: "operator", rank: 0 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => slug === "admin.dashboard",
|
||||
hasAny: (...slugs: string[]) => slugs.includes("admin.dashboard"),
|
||||
hasAll: (...slugs: string[]) =>
|
||||
slugs.every((slug) => slug === "admin.dashboard"),
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
|
||||
function dependencies(
|
||||
context = allowedContext,
|
||||
): HousekeepingPreferencesActionDependencies & {
|
||||
repository: HousekeepingPreferencesRepository;
|
||||
} {
|
||||
return {
|
||||
repository: {
|
||||
read: vi.fn().mockResolvedValue(defaultHousekeepingPreferences()),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
registry,
|
||||
getContext: vi.fn().mockResolvedValue(context),
|
||||
};
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValue(allowedContext);
|
||||
preferenceRepository.read.mockResolvedValue(defaultHousekeepingPreferences());
|
||||
});
|
||||
|
||||
describe("housekeeping preference actions", () => {
|
||||
it("derives the read owner from request capability context", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
const result = await loadHousekeepingPreferences(deps);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(deps.repository.read).toHaveBeenCalledWith(42);
|
||||
expect(deps.getContext).toHaveBeenCalledOnce();
|
||||
it("does not expose dependency or user identity parameters to callers", () => {
|
||||
expect(loadHousekeepingPreferences).toHaveLength(0);
|
||||
expect(saveHousekeepingPreferences).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a denied operator without reading or writing another user preferences", async () => {
|
||||
const deps = dependencies({ ...allowedContext, hasAny: () => false });
|
||||
it("derives the read owner from the server capability context", async () => {
|
||||
const result = await loadHousekeepingPreferences();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(preferenceRepository.read).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("rejects a denied operator without reading or writing preferences", async () => {
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
||||
...allowedContext,
|
||||
hasAny: () => false,
|
||||
});
|
||||
|
||||
const result = await saveHousekeepingPreferences(
|
||||
defaultHousekeepingPreferences(),
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(deps.repository.read).not.toHaveBeenCalled();
|
||||
expect(deps.repository.upsert).not.toHaveBeenCalled();
|
||||
expect(preferenceRepository.read).not.toHaveBeenCalled();
|
||||
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates writes and persists them for the context actor only", async () => {
|
||||
const deps = dependencies();
|
||||
it("reconciles input before persisting or returning it", async () => {
|
||||
const value = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
pinnedRouteIds: ["removed.route"],
|
||||
pinnedCommandIds: ["removed.command"],
|
||||
};
|
||||
|
||||
const result = await saveHousekeepingPreferences(value, deps);
|
||||
const result = await saveHousekeepingPreferences(value);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, data: value });
|
||||
expect(deps.repository.upsert).toHaveBeenCalledWith(42, value);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { pinnedRouteIds: [], pinnedCommandIds: [] },
|
||||
});
|
||||
expect(preferenceRepository.upsert).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ pinnedRouteIds: [], pinnedCommandIds: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a validation result before an invalid payload reaches persistence", async () => {
|
||||
const deps = dependencies();
|
||||
|
||||
const result = await saveHousekeepingPreferences(
|
||||
{ schemaVersion: 2 },
|
||||
deps,
|
||||
);
|
||||
const result = await saveHousekeepingPreferences({ schemaVersion: 2 });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
|
||||
expect(deps.repository.upsert).not.toHaveBeenCalled();
|
||||
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
|
||||
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
|
||||
import {
|
||||
type HousekeepingPreferences,
|
||||
housekeepingPreferencesSchema,
|
||||
} from "@/features/housekeeping/foundation/preferences/schema";
|
||||
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
|
||||
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
export interface HousekeepingPreferencesActionDependencies {
|
||||
repository: HousekeepingPreferencesRepository;
|
||||
registry: HousekeepingRegistry;
|
||||
getContext?: () => Promise<HousekeepingCapabilityContext>;
|
||||
}
|
||||
|
||||
export async function loadHousekeepingPreferences(
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||
const context = await resolveContext(dependencies);
|
||||
export async function loadHousekeepingPreferences(): Promise<
|
||||
HousekeepingResult<HousekeepingPreferences>
|
||||
> {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
const correlationId = createCorrelationId();
|
||||
try {
|
||||
const stored = await dependencies.repository.read(context.actor.id);
|
||||
const stored = await housekeepingPreferencesRepository.read(
|
||||
context.actor.id,
|
||||
);
|
||||
return ok(
|
||||
reconcilePreferences(stored, dependencies.registry, context),
|
||||
reconcilePreferences(stored, housekeepingRegistry, context),
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
@@ -50,9 +49,8 @@ export async function loadHousekeepingPreferences(
|
||||
|
||||
export async function saveHousekeepingPreferences(
|
||||
input: unknown,
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||
const context = await resolveContext(dependencies);
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
@@ -66,9 +64,17 @@ export async function saveHousekeepingPreferences(
|
||||
);
|
||||
}
|
||||
|
||||
const reconciled = reconcilePreferences(
|
||||
parsed.data,
|
||||
housekeepingRegistry,
|
||||
context,
|
||||
);
|
||||
try {
|
||||
await dependencies.repository.upsert(context.actor.id, parsed.data);
|
||||
return ok(parsed.data, correlationId);
|
||||
await housekeepingPreferencesRepository.upsert(
|
||||
context.actor.id,
|
||||
reconciled,
|
||||
);
|
||||
return ok(reconciled, correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
@@ -77,11 +83,3 @@ export async function saveHousekeepingPreferences(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContext(
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingCapabilityContext> {
|
||||
return dependencies.getContext
|
||||
? dependencies.getContext()
|
||||
: getHousekeepingCapabilityContext();
|
||||
}
|
||||
Reference in new issue
Block a user