diff --git a/shared/src/admin/admin.schema.spec.ts b/shared/src/admin/admin.schema.spec.ts index d7b8b3303..b3b6d8a81 100644 --- a/shared/src/admin/admin.schema.spec.ts +++ b/shared/src/admin/admin.schema.spec.ts @@ -1,47 +1,71 @@ import { adminUserCreateRequestSchema, + adminUserUpdateRequestSchema, adminPermissionsRequestSchema, adminInviteCreateRequestSchema, adminFeatureToggleRequestSchema, + adminTemplateNameRequestSchema, + adminOidcUpdateRequestSchema, + adminAddonUpdateRequestSchema, + adminCollabFeaturesRequestSchema, + adminNotificationPreferencesRequestSchema, + adminDefaultUserSettingsRequestSchema, + adminTestNotificationRequestSchema, } from './admin.schema'; import { describe, it, expect } from 'vitest'; describe('adminUserCreateRequestSchema', () => { - it('requires an email; role limited to user/admin', () => { + it('accepts the full body the client sends', () => { expect( adminUserCreateRequestSchema.safeParse({ + username: 'u', email: 'a@b.c', password: 'p', role: 'admin', }).success, ).toBe(true); - expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true); - expect(adminUserCreateRequestSchema.safeParse({ password: 'p' }).success).toBe(false); - expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(false); + }); + + it('leaves the missing-field and invalid-role 400s to the service', () => { + // All fields optional: the service answers 'Username, email and password are required'. + expect(adminUserCreateRequestSchema.safeParse({}).success).toBe(true); + // role is z.string(), not an enum: the service answers 'Invalid role'. + expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(true); + }); + + it('rejects wrongly-typed fields and strips unknown keys', () => { + expect(adminUserCreateRequestSchema.safeParse({ email: 42 }).success).toBe(false); + expect(adminUserCreateRequestSchema.parse({ email: 'a@b.c', extra: 1 })).toEqual({ email: 'a@b.c' }); + }); +}); + +describe('adminUserUpdateRequestSchema', () => { + it('accepts partial updates, including an empty body', () => { + expect(adminUserUpdateRequestSchema.safeParse({ role: 'user' }).success).toBe(true); + expect(adminUserUpdateRequestSchema.safeParse({}).success).toBe(true); + expect(adminUserUpdateRequestSchema.safeParse({ username: 5 }).success).toBe(false); }); }); describe('adminPermissionsRequestSchema', () => { it('requires a permissions record', () => { - expect( - adminPermissionsRequestSchema.safeParse({ - permissions: { trip_edit: { user: true } }, - }).success, - ).toBe(true); + expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: 'admin' } }).success).toBe(true); expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false); + expect(adminPermissionsRequestSchema.safeParse({ permissions: null }).success).toBe(false); + }); + + it('keeps values unknown so bad levels land in the service `skipped` list, not a 400', () => { + expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: 'nonsense' } }).success).toBe(true); }); }); describe('adminInviteCreateRequestSchema', () => { - it('accepts optional uses/expiry/role', () => { - expect( - adminInviteCreateRequestSchema.safeParse({ - max_uses: 5, - expires_in_days: 7, - }).success, - ).toBe(true); + it('accepts optional uses/expiry/role and both number and string ids', () => { + expect(adminInviteCreateRequestSchema.safeParse({ max_uses: 5, expires_in_days: 7 }).success).toBe(true); + expect(adminInviteCreateRequestSchema.safeParse({ max_uses: '5', trip_id: '3' }).success).toBe(true); expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true); + expect(adminInviteCreateRequestSchema.safeParse({ trip_id: null }).success).toBe(true); expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false); }); }); @@ -50,5 +74,83 @@ describe('adminFeatureToggleRequestSchema', () => { it('requires a boolean enabled', () => { expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true); expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false); + expect(adminFeatureToggleRequestSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('adminTemplateNameRequestSchema', () => { + it('keeps name optional so the service owns the "required" 400, and strips extras', () => { + expect(adminTemplateNameRequestSchema.safeParse({ name: 'Beach' }).success).toBe(true); + expect(adminTemplateNameRequestSchema.safeParse({}).success).toBe(true); + expect(adminTemplateNameRequestSchema.parse({ name: 'Beach', description: 'x' })).toEqual({ name: 'Beach' }); + expect(adminTemplateNameRequestSchema.safeParse({ name: 7 }).success).toBe(false); + }); +}); + +describe('adminOidcUpdateRequestSchema', () => { + it('accepts the client body and strips the extra oidc_only key', () => { + const body = { issuer: 'https://i', client_id: 'c', display_name: 'd', discovery_url: 'u' }; + expect(adminOidcUpdateRequestSchema.safeParse(body).success).toBe(true); + expect(adminOidcUpdateRequestSchema.parse({ ...body, oidc_only: false })).toEqual(body); + // Empty strings must survive — they are the "clear SSO config" signal. + expect(adminOidcUpdateRequestSchema.parse({ issuer: '', client_id: '' })).toEqual({ issuer: '', client_id: '' }); + }); +}); + +describe('adminAddonUpdateRequestSchema', () => { + it('accepts either shape and keeps config free-form', () => { + expect(adminAddonUpdateRequestSchema.safeParse({ enabled: true }).success).toBe(true); + const config = { provider: 'openai', model: 'm', apiKey: '••••', multimodal: true, nested: { a: 1 } }; + expect(adminAddonUpdateRequestSchema.parse({ config })).toEqual({ config }); + expect(adminAddonUpdateRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false); + }); +}); + +describe('adminCollabFeaturesRequestSchema', () => { + it('accepts a single computed key', () => { + expect(adminCollabFeaturesRequestSchema.safeParse({ chat: false }).success).toBe(true); + expect(adminCollabFeaturesRequestSchema.safeParse({}).success).toBe(true); + expect(adminCollabFeaturesRequestSchema.safeParse({ polls: 'no' }).success).toBe(false); + }); +}); + +describe('adminNotificationPreferencesRequestSchema', () => { + it('accepts arbitrary event types and channel ids, including plugin channels', () => { + expect( + adminNotificationPreferencesRequestSchema.safeParse({ + trip_reminder: { email: true, 'plugin:acme': false }, + }).success, + ).toBe(true); + expect(adminNotificationPreferencesRequestSchema.safeParse({}).success).toBe(true); + expect(adminNotificationPreferencesRequestSchema.safeParse({ x: { email: 'yes' } }).success).toBe(false); + }); +}); + +describe('adminDefaultUserSettingsRequestSchema', () => { + it('keeps null as the reset sentinel and rejects non-objects', () => { + expect(adminDefaultUserSettingsRequestSchema.parse({ theme: 'dark', density: null, compact: true })).toEqual({ + theme: 'dark', + density: null, + compact: true, + }); + expect(adminDefaultUserSettingsRequestSchema.safeParse([]).success).toBe(false); + expect(adminDefaultUserSettingsRequestSchema.safeParse(null).success).toBe(false); + }); +}); + +describe('adminTestNotificationRequestSchema', () => { + it('accepts an empty body and both inApp shapes', () => { + expect(adminTestNotificationRequestSchema.safeParse({}).success).toBe(true); + expect(adminTestNotificationRequestSchema.safeParse({ event: 'trip_reminder', inApp: true }).success).toBe(true); + expect( + adminTestNotificationRequestSchema.safeParse({ + event: 'x', + scope: 'admin', + targetId: 1, + params: { a: 'b' }, + inApp: { type: 'boolean', positiveCallback: { action: 'test_approve', payload: {} } }, + }).success, + ).toBe(true); + expect(adminTestNotificationRequestSchema.safeParse({ targetId: 'one' }).success).toBe(false); }); }); diff --git a/shared/src/admin/admin.schema.ts b/shared/src/admin/admin.schema.ts index 08c54b315..d0482557e 100644 --- a/shared/src/admin/admin.schema.ts +++ b/shared/src/admin/admin.schema.ts @@ -3,31 +3,49 @@ import { z } from 'zod'; /** * Admin API contract for /api/admin (admin-only). * - * The admin service validates most bodies itself (returning {error,status}), so - * these schemas pin the well-defined ones: user create/update, the permission - * matrix, invites and the boolean feature toggles. Free-form bodies (OIDC - * settings, addon config, default user settings) stay with the service. + * These schemas exist to satisfy the body-contract ratchet without changing + * behavior, so they are deliberately permissive: wherever AdminService already + * emits a bespoke 400 of its own ('Invalid role', 'Name is required', + * 'Username, email and password are required'), the field stays optional and + * loosely typed so that error survives byte-identically. Every object schema is + * non-strict — several clients and tests send extra keys and expect success. */ + export const adminUserCreateRequestSchema = z.object({ - email: z.string(), - password: z.string().optional(), username: z.string().optional(), - role: z.enum(['user', 'admin']).optional(), + email: z.string().optional(), + password: z.string().optional(), + // Deliberately z.string(), not z.enum: the service's own 'Invalid role' 400 + // is the pinned contract for a bad role. + role: z.string().optional(), }); export type AdminUserCreateRequest = z.infer; +export const adminUserUpdateRequestSchema = z.object({ + username: z.string().optional(), + email: z.string().optional(), + password: z.string().optional(), + role: z.string().optional(), +}); +export type AdminUserUpdateRequest = z.infer; + +// Values stay z.unknown(): PermissionsService.savePermissions reports unknown +// levels through its `skipped` list (a 200 response field the admin UI shows), +// so narrowing to z.string() would turn that into a 400. export const adminPermissionsRequestSchema = z.object({ permissions: z.record(z.string(), z.unknown()), }); export type AdminPermissionsRequest = z.infer; export const adminInviteCreateRequestSchema = z.object({ - max_uses: z.number().optional(), - expires_in_days: z.number().optional(), + // string | number: the service parses with parseInt(String(x)), and the + // admin UI sends numbers while older clients sent strings. + max_uses: z.union([z.number(), z.string()]).optional(), + expires_in_days: z.union([z.number(), z.string()]).optional(), role: z.enum(['user', 'admin']).optional(), // Optional trip binding (#1402): a user who registers via the link is // auto-added to this trip. Nullable/absent = a plain registration invite. - trip_id: z.number().int().positive().nullable().optional(), + trip_id: z.union([z.number(), z.string()]).nullable().optional(), }); export type AdminInviteCreateRequest = z.infer; @@ -35,3 +53,60 @@ export const adminFeatureToggleRequestSchema = z.object({ enabled: z.boolean(), }); export type AdminFeatureToggleRequest = z.infer; + +// Shared by all six packing-template create/update routes. `name` is optional so +// the service's 'Name is required' / 'Category name is required' / 'Item name is +// required' 400s stay the contract, and so the update routes keep treating a +// blank name as a no-op. +export const adminTemplateNameRequestSchema = z.object({ + name: z.string().optional(), +}); +export type AdminTemplateNameRequest = z.infer; + +export const adminOidcUpdateRequestSchema = z.object({ + issuer: z.string().optional(), + client_id: z.string().optional(), + client_secret: z.string().optional(), + display_name: z.string().optional(), + discovery_url: z.string().optional(), +}); +export type AdminOidcUpdateRequest = z.infer; + +// `config` must stay free-form: each addon stores its own keys, and the AI addon +// round-trips an apiKey mask sentinel that a narrower shape would strip. +export const adminAddonUpdateRequestSchema = z.object({ + enabled: z.boolean().optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); +export type AdminAddonUpdateRequest = z.infer; + +// Fully partial — the client PUTs one computed key at a time. +export const adminCollabFeaturesRequestSchema = z.object({ + chat: z.boolean().optional(), + notes: z.boolean().optional(), + polls: z.boolean().optional(), + whatsnext: z.boolean().optional(), +}); +export type AdminCollabFeaturesRequest = z.infer; + +// event type -> channel id -> enabled. Anything narrower would silently drop +// plugin-contributed channels, since the client echoes back the whole matrix. +export const adminNotificationPreferencesRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean())); +export type AdminNotificationPreferencesRequest = z.infer; + +// Heterogeneous values, and `null` is meaningful — SettingsService treats it as +// "reset to the built-in default". z.record also rejects arrays and null bodies, +// preserving the route's object-only guard. +export const adminDefaultUserSettingsRequestSchema = z.record(z.string(), z.unknown()); +export type AdminDefaultUserSettingsRequest = z.infer; + +// Dev-only test sender: every field optional (callers POST {}), and `inApp` is a +// boolean OR the in-app action descriptor the dev panel sends. +export const adminTestNotificationRequestSchema = z.object({ + event: z.string().optional(), + scope: z.string().optional(), + targetId: z.number().optional(), + params: z.record(z.string(), z.unknown()).optional(), + inApp: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(), +}); +export type AdminTestNotificationRequest = z.infer;