Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
37 changes: 34 additions & 3 deletions src/model/admin-user/admin-user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<unknown> {
const { body } = req;
delete body._id;
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down
60 changes: 60 additions & 0 deletions test/unit/admin-user/admin-user-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down