From dda4550fc6736f9d0dbc19da8593c998a7f71573 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Thu, 4 Jun 2026 17:28:22 -0400 Subject: [PATCH] feat(admin-user): accept + guard userStatus on create/update Supports JaMmusic's new "edit Type" UI (Maria review #11). The admin user PUT/POST now accept userStatus and validate it: - userStatus must be one of human / ai-agent (rejects legacy values like "enabled" with 400) - ai-agent is only allowed when the resulting role (userType) is web-jam-llm, enforced server-side so the UI rule can't be bypassed findByIdAndUpdate now fetches the existing record once when either userType or userStatus changes, and uses the resulting role for the guard. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/model/admin-user/admin-user-controller.ts | 37 +++++++++++- .../admin-user/admin-user-controller.spec.ts | 60 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 93166f5..03eb398 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-jam-back", - "version": "2.0.13", + "version": "2.0.14", "description": "web-jam.com", "type": "module", "main": "build/src/index.js", diff --git a/src/model/admin-user/admin-user-controller.ts b/src/model/admin-user/admin-user-controller.ts index 3979ee6..ab9a924 100644 --- a/src/model/admin-user/admin-user-controller.ts +++ b/src/model/admin-user/admin-user-controller.ts @@ -9,6 +9,10 @@ import { canGrantRole } from '../../auth/roleGrants.js'; // ensureAuthenticated populates req.userType with the acting admin's role. type ActingRequest = Request & { userType?: string }; +const USER_STATUS_OPTIONS = ['human', 'ai-agent']; +// Only the AI-agent bot role may be marked ai-agent. +const AI_AGENT_ROLE = 'web-jam-llm'; + class AdminUserController extends Controller { constructor(uModel: typeof userModel) { super(uModel); @@ -47,6 +51,24 @@ class AdminUserController extends Controller { return null; } + // Validate a userStatus (Type) value and enforce that 'ai-agent' is only + // allowed when the resulting role is the AI-agent bot role. resultingRole is + // the userType the record will have after this update. Returns an error to + // send back, or null when allowed. + userStatusError( // eslint-disable-line class-methods-use-this + newStatus: string | undefined, + resultingRole: string | undefined, + ): { status: number; message: string } | null { + if (newStatus === undefined || newStatus === '') return null; + if (USER_STATUS_OPTIONS.indexOf(newStatus) === -1) { + return { status: 400, message: 'userStatus not valid' }; + } + if (newStatus === 'ai-agent' && resultingRole !== AI_AGENT_ROLE) { + return { status: 400, message: `userStatus 'ai-agent' requires the '${AI_AGENT_ROLE}' role` }; + } + return null; + } + async create(req: Request, res: Response): Promise { const { body } = req; delete body._id; @@ -57,6 +79,8 @@ class AdminUserController extends Controller { } const roleErr = this.roleTransitionError((req as ActingRequest).userType, undefined, body.userType); if (roleErr) return res.status(roleErr.status).json({ message: roleErr.message }); + const statusErr = this.userStatusError(body.userStatus, body.userType); + if (statusErr) return res.status(statusErr.status).json({ message: statusErr.message }); if (!body.name) return res.status(400).json({ message: 'Name is required' }); if (!body.email) return res.status(400).json({ message: 'Email is required' }); let doc; @@ -73,13 +97,20 @@ class AdminUserController extends Controller { if (!result.ok) return res.status(400).json({ message: result.message }); req.body.privileges = result.privileges; } - if ('userType' in req.body) { - let existing; + let existing; + if ('userType' in req.body || 'userStatus' in req.body) { try { existing = await this.model.findById(req.params.id); } catch (e) { return this.resErr(res, e as Error); } - const existingRole = (existing as { userType?: string } | null)?.userType; + } + const existingRole = (existing as { userType?: string } | null)?.userType; + if ('userType' in req.body) { const roleErr = this.roleTransitionError((req as ActingRequest).userType, existingRole, req.body.userType); if (roleErr) return res.status(roleErr.status).json({ message: roleErr.message }); } + if ('userStatus' in req.body) { + const resultingRole = 'userType' in req.body ? req.body.userType : existingRole; + const statusErr = this.userStatusError(req.body.userStatus, resultingRole); + if (statusErr) return res.status(statusErr.status).json({ message: statusErr.message }); + } return this.contFBIandU(req, res); } diff --git a/test/unit/admin-user/admin-user-controller.spec.ts b/test/unit/admin-user/admin-user-controller.spec.ts index 72fe080..f26b342 100644 --- a/test/unit/admin-user/admin-user-controller.spec.ts +++ b/test/unit/admin-user/admin-user-controller.spec.ts @@ -86,6 +86,31 @@ describe('Admin User Controller', () => { await controller.create({ body: { name: 'Bot', email: 'a@b.com' } } as any, resStub); expect(status).toBe(500); }); + + it('rejects an invalid userStatus', async () => { + await controller.create({ body: { name: 'Bot', email: 'a@b.com', userStatus: 'enabled' } } as any, resStub); + expect(status).toBe(400); + expect(testObj.message).toContain('userStatus not valid'); + }); + + it('rejects ai-agent without the web-jam-llm role', async () => { + lib.userRoles = ['JaM-admin', 'web-jam-llm']; + await controller.create({ body: { name: 'Bot', email: 'a@b.com', userStatus: 'ai-agent' } } as any, resStub); + expect(status).toBe(400); + expect(testObj.message).toContain('web-jam-llm'); + }); + + it('allows ai-agent with the web-jam-llm role', async () => { + lib.userRoles = ['web-jam-llm']; + lib.model.create = vi.fn(() => Promise.resolve({ _id: 'bot', name: 'Bot' })) as any; + await controller.create({ + userType: 'Developer', + body: { + name: 'Bot', email: 'a@b.com', userType: 'web-jam-llm', userStatus: 'ai-agent', + }, + } as any, resStub); + expect(status).toBe(201); + }); }); describe('findByIdAndUpdate', () => { @@ -154,6 +179,41 @@ describe('Admin User Controller', () => { await controller.findByIdAndUpdate({ params: { id }, userType: 'Developer', body: { userType: 'web-jam-llm' } } as any, resStub); expect(status).toBe(500); }); + + it('rejects an invalid userStatus in update', async () => { + lib.model.findById = vi.fn(() => Promise.resolve({ userType: 'JaM-admin' })) as any; + const id = new mongoose.Types.ObjectId().toString(); + await controller.findByIdAndUpdate({ params: { id }, body: { userStatus: 'enabled' } } as any, resStub); + expect(status).toBe(400); + expect(testObj.message).toContain('userStatus not valid'); + }); + + it('rejects setting ai-agent on a non web-jam-llm user', async () => { + lib.model.findById = vi.fn(() => Promise.resolve({ userType: 'JaM-admin' })) as any; + const id = new mongoose.Types.ObjectId().toString(); + await controller.findByIdAndUpdate({ params: { id }, body: { userStatus: 'ai-agent' } } as any, resStub); + expect(status).toBe(400); + expect(testObj.message).toContain('web-jam-llm'); + }); + + it('corrects a legacy userStatus to human', async () => { + lib.model.findById = vi.fn(() => Promise.resolve({ userType: 'JaM-admin' })) as any; + lib.model.findByIdAndUpdate = vi.fn(() => Promise.resolve({ _id: 'id', userStatus: 'human' })) as any; + const id = new mongoose.Types.ObjectId().toString(); + await controller.findByIdAndUpdate({ params: { id }, body: { userStatus: 'human' } } as any, resStub); + expect(status).toBe(200); + }); + + it('allows ai-agent when the same update assigns the web-jam-llm role', async () => { + lib.userRoles = ['JaM-admin', 'web-jam-llm']; + lib.model.findById = vi.fn(() => Promise.resolve({ userType: '' })) as any; + lib.model.findByIdAndUpdate = vi.fn(() => Promise.resolve({ _id: 'id' })) as any; + const id = new mongoose.Types.ObjectId().toString(); + await controller.findByIdAndUpdate({ + params: { id }, userType: 'Developer', body: { userType: 'web-jam-llm', userStatus: 'ai-agent' }, + } as any, resStub); + expect(status).toBe(200); + }); }); describe('mintToken', () => {