mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
This commit is contained in:
@@ -1,47 +1,71 @@
|
|||||||
import {
|
import {
|
||||||
adminUserCreateRequestSchema,
|
adminUserCreateRequestSchema,
|
||||||
|
adminUserUpdateRequestSchema,
|
||||||
adminPermissionsRequestSchema,
|
adminPermissionsRequestSchema,
|
||||||
adminInviteCreateRequestSchema,
|
adminInviteCreateRequestSchema,
|
||||||
adminFeatureToggleRequestSchema,
|
adminFeatureToggleRequestSchema,
|
||||||
|
adminTemplateNameRequestSchema,
|
||||||
|
adminOidcUpdateRequestSchema,
|
||||||
|
adminAddonUpdateRequestSchema,
|
||||||
|
adminCollabFeaturesRequestSchema,
|
||||||
|
adminNotificationPreferencesRequestSchema,
|
||||||
|
adminDefaultUserSettingsRequestSchema,
|
||||||
|
adminTestNotificationRequestSchema,
|
||||||
} from './admin.schema';
|
} from './admin.schema';
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
describe('adminUserCreateRequestSchema', () => {
|
describe('adminUserCreateRequestSchema', () => {
|
||||||
it('requires an email; role limited to user/admin', () => {
|
it('accepts the full body the client sends', () => {
|
||||||
expect(
|
expect(
|
||||||
adminUserCreateRequestSchema.safeParse({
|
adminUserCreateRequestSchema.safeParse({
|
||||||
|
username: 'u',
|
||||||
email: 'a@b.c',
|
email: 'a@b.c',
|
||||||
password: 'p',
|
password: 'p',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).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', () => {
|
describe('adminPermissionsRequestSchema', () => {
|
||||||
it('requires a permissions record', () => {
|
it('requires a permissions record', () => {
|
||||||
expect(
|
expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: 'admin' } }).success).toBe(true);
|
||||||
adminPermissionsRequestSchema.safeParse({
|
|
||||||
permissions: { trip_edit: { user: true } },
|
|
||||||
}).success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false);
|
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', () => {
|
describe('adminInviteCreateRequestSchema', () => {
|
||||||
it('accepts optional uses/expiry/role', () => {
|
it('accepts optional uses/expiry/role and both number and string ids', () => {
|
||||||
expect(
|
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: 5, expires_in_days: 7 }).success).toBe(true);
|
||||||
adminInviteCreateRequestSchema.safeParse({
|
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: '5', trip_id: '3' }).success).toBe(true);
|
||||||
max_uses: 5,
|
|
||||||
expires_in_days: 7,
|
|
||||||
}).success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(adminInviteCreateRequestSchema.safeParse({}).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);
|
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,5 +74,83 @@ describe('adminFeatureToggleRequestSchema', () => {
|
|||||||
it('requires a boolean enabled', () => {
|
it('requires a boolean enabled', () => {
|
||||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
|
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
|
||||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,31 +3,49 @@ import { z } from 'zod';
|
|||||||
/**
|
/**
|
||||||
* Admin API contract for /api/admin (admin-only).
|
* Admin API contract for /api/admin (admin-only).
|
||||||
*
|
*
|
||||||
* The admin service validates most bodies itself (returning {error,status}), so
|
* These schemas exist to satisfy the body-contract ratchet without changing
|
||||||
* these schemas pin the well-defined ones: user create/update, the permission
|
* behavior, so they are deliberately permissive: wherever AdminService already
|
||||||
* matrix, invites and the boolean feature toggles. Free-form bodies (OIDC
|
* emits a bespoke 400 of its own ('Invalid role', 'Name is required',
|
||||||
* settings, addon config, default user settings) stay with the service.
|
* '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({
|
export const adminUserCreateRequestSchema = z.object({
|
||||||
email: z.string(),
|
|
||||||
password: z.string().optional(),
|
|
||||||
username: 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<typeof adminUserCreateRequestSchema>;
|
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
|
||||||
|
|
||||||
|
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<typeof adminUserUpdateRequestSchema>;
|
||||||
|
|
||||||
|
// 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({
|
export const adminPermissionsRequestSchema = z.object({
|
||||||
permissions: z.record(z.string(), z.unknown()),
|
permissions: z.record(z.string(), z.unknown()),
|
||||||
});
|
});
|
||||||
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
|
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
|
||||||
|
|
||||||
export const adminInviteCreateRequestSchema = z.object({
|
export const adminInviteCreateRequestSchema = z.object({
|
||||||
max_uses: z.number().optional(),
|
// string | number: the service parses with parseInt(String(x)), and the
|
||||||
expires_in_days: z.number().optional(),
|
// 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(),
|
role: z.enum(['user', 'admin']).optional(),
|
||||||
// Optional trip binding (#1402): a user who registers via the link is
|
// Optional trip binding (#1402): a user who registers via the link is
|
||||||
// auto-added to this trip. Nullable/absent = a plain registration invite.
|
// 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<typeof adminInviteCreateRequestSchema>;
|
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
|
||||||
|
|
||||||
@@ -35,3 +53,60 @@ export const adminFeatureToggleRequestSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
|
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
|
||||||
|
|
||||||
|
// 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<typeof adminTemplateNameRequestSchema>;
|
||||||
|
|
||||||
|
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<typeof adminOidcUpdateRequestSchema>;
|
||||||
|
|
||||||
|
// `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<typeof adminAddonUpdateRequestSchema>;
|
||||||
|
|
||||||
|
// 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<typeof adminCollabFeaturesRequestSchema>;
|
||||||
|
|
||||||
|
// 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<typeof adminNotificationPreferencesRequestSchema>;
|
||||||
|
|
||||||
|
// 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<typeof adminDefaultUserSettingsRequestSchema>;
|
||||||
|
|
||||||
|
// 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<typeof adminTestNotificationRequestSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user