mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(admin): persist the assistant flags sent to the settings route
`adminSettingsSchema` validates five `assistant*` booleans and the strict schema refuses anything else, but the route's `booleanFields` list never carried them. Sending one alone answered 422 "No valid fields"; sending one alongside a real field answered 200 and dropped it. The schema comment says the entry exists so an operator scripting their settings over the single route can carry the values, so the route is brought up to the contract rather than the contract cut down to the route. The flags are echoed in the GET and PUT payloads as raw column values — the master-over-sub resolution stays with `/settings/assistant-flags`, which owns that shape and was never affected.
This commit is contained in:
1 parent
de0876b215
commit
4dbad48d89
2 files changed
+158
-1
No files matched your search
@@ -482,3 +482,134 @@ describe("PUT /api/admin/settings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The five assistant flags were accepted by `adminSettingsSchema` and then
|
||||
* dropped on the floor: sending one alone answered 422 ("No valid fields"),
|
||||
* sending it alongside a real field answered 200 and stored nothing. The
|
||||
* schema comment promised the opposite, so the route was brought up to the
|
||||
* contract rather than the contract down to the route.
|
||||
*/
|
||||
describe("PUT /api/admin/settings — assistant flags", () => {
|
||||
const STORED = {
|
||||
registrationEnabled: true,
|
||||
mfaRequired: false,
|
||||
defaultLocale: "de",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
documentMaxFileBytes: 26_214_400,
|
||||
documentQuotaBytes: BigInt(1_073_741_824),
|
||||
defaultUserTimezone: null,
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue(STORED as never);
|
||||
});
|
||||
|
||||
it("persists a lone assistant flag instead of answering 'No valid fields'", async () => {
|
||||
const res = await PUT(jsonReq({ assistantCoachEnabled: false }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0] as {
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
expect(args.update).toEqual({ assistantCoachEnabled: false });
|
||||
});
|
||||
|
||||
it("persists an assistant flag sent alongside an unrelated field", async () => {
|
||||
const res = await PUT(
|
||||
jsonReq({ registrationEnabled: false, assistantCoachEnabled: false }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0] as {
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
expect(args.update).toEqual({
|
||||
registrationEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("echoes the stored flags so a scripted write reads back", async () => {
|
||||
const res = await PUT(jsonReq({ assistantCoachEnabled: false }));
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("audits the flag it changed", async () => {
|
||||
await PUT(jsonReq({ assistantCorrelationsEnabled: false }));
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.settings.update",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
assistantCorrelationsEnabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses an unknown flag-shaped key", async () => {
|
||||
const res = await PUT(jsonReq({ assistantNotAThing: false }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings — assistant flags", () => {
|
||||
it("defaults every flag to enabled when the row is absent", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the stored flags", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,16 @@ export const GET = apiHandler(async () => {
|
||||
// resolver"; surfacing the raw value lets the admin UI render
|
||||
// an empty picker placeholder until they opt in.
|
||||
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
|
||||
// Raw column values, not the resolved matrix — the master flag is
|
||||
// applied to the sub-flags by `/settings/assistant-flags`, which owns
|
||||
// that shape. Echoed here so a write over this route reads back.
|
||||
assistantEnabled: settings?.assistantEnabled ?? true,
|
||||
assistantCoachEnabled: settings?.assistantCoachEnabled ?? true,
|
||||
assistantBriefingEnabled: settings?.assistantBriefingEnabled ?? true,
|
||||
assistantInsightStatusEnabled:
|
||||
settings?.assistantInsightStatusEnabled ?? true,
|
||||
assistantCorrelationsEnabled:
|
||||
settings?.assistantCorrelationsEnabled ?? true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +93,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
|
||||
// Boolean fields — direct mapping
|
||||
// Boolean fields — direct mapping.
|
||||
//
|
||||
// The five assistant flags belong here and not only on the dedicated
|
||||
// `/settings/assistant-flags` endpoint: `adminSettingsSchema` accepts them
|
||||
// so an operator scripting their settings over a single route can carry
|
||||
// them, and a schema that accepts a field and then drops it is worse than
|
||||
// one that refuses it.
|
||||
const booleanFields = [
|
||||
"registrationEnabled",
|
||||
"mfaRequired",
|
||||
@@ -93,6 +109,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"apiGlobal",
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"assistantEnabled",
|
||||
"assistantCoachEnabled",
|
||||
"assistantBriefingEnabled",
|
||||
"assistantInsightStatusEnabled",
|
||||
"assistantCorrelationsEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -258,5 +279,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: Number(settings.documentQuotaBytes),
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
assistantEnabled: settings.assistantEnabled,
|
||||
assistantCoachEnabled: settings.assistantCoachEnabled,
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user