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
84 changes: 47 additions & 37 deletions backend/__tests__/unit/controllers/podController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,23 +217,19 @@ describe('podController', () => {

it('deletePod denies delete if user is not creator', async () => {
Pod.findById.mockResolvedValue({ createdBy: 'creator' });
const req = { params: { id: 'p1' }, userId: 'other' };
const req = { params: { id: 'p1' }, userId: 'other', user: { id: 'other', role: 'user' } };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
send: jest.fn(),
};
await podController.deletePod(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(User.findById).not.toHaveBeenCalled();
});

it('deletePod allows global admin to delete pod they did not create', async () => {
Pod.findById.mockResolvedValue({ createdBy: 'creator' });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue({ role: 'admin' }),
}),
});
Pod.deleteOne.mockResolvedValue({ deletedCount: 1 });
Message.deleteMany.mockResolvedValue({ deletedCount: 2 });
Post.deleteMany.mockResolvedValue({ deletedCount: 0 });
Expand All @@ -243,7 +239,7 @@ describe('podController', () => {
AgentInstallation.deleteMany.mockResolvedValue({ deletedCount: 0 });
AgentProfile.deleteMany.mockResolvedValue({ deletedCount: 0 });

const req = { params: { id: 'p1' }, userId: 'global-admin' };
const req = { params: { id: 'p1' }, userId: 'global-admin', user: { id: 'global-admin', role: 'admin' } };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
Expand All @@ -255,6 +251,7 @@ describe('podController', () => {
expect(Message.deleteMany).toHaveBeenCalledWith({ podId: 'p1' });
expect(Pod.deleteOne).toHaveBeenCalledWith({ _id: 'p1' });
expect(res.json).toHaveBeenCalledWith({ msg: 'Pod deleted' });
expect(User.findById).not.toHaveBeenCalled();
});

it('removeMember denies removal when user is not creator', async () => {
Expand Down Expand Up @@ -414,6 +411,40 @@ describe('podController', () => {
expect(res.status).not.toHaveBeenCalledWith(403);
});

it('joinPod admits an admin into an invite-only chat pod using req.user.role', async () => {
const pod = { _id: 'private-1', type: 'chat', members: ['creator'], joinPolicy: 'invite-only' };
Pod.findById.mockResolvedValueOnce(pod).mockReturnValueOnce({
populate: jest.fn().mockReturnValue({
populate: jest.fn().mockResolvedValue({ ...pod, members: ['creator', 'admin'] }),
}),
});
const req = { params: { id: 'private-1' }, userId: 'admin', user: { id: 'admin', role: 'admin' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };

await podController.joinPod(req, res);

expect(Pod.updateOne).toHaveBeenCalledWith(
{ _id: 'private-1' },
expect.objectContaining({ $addToSet: { members: 'admin' } }),
);
expect(res.status).not.toHaveBeenCalledWith(403);
});

it('joinPod refuses a non-admin for an invite-only chat pod', async () => {
Pod.findById.mockResolvedValue({
_id: 'private-1', type: 'chat', members: ['creator'], joinPolicy: 'invite-only',
});
const req = { params: { id: 'private-1' }, userId: 'member', user: { id: 'member', role: 'user' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };

await podController.joinPod(req, res);

expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'join_refused' }));
expect(User.findById).not.toHaveBeenCalled();
expect(Pod.updateOne).not.toHaveBeenCalled();
});

it('joinPod refuses a bot before membership when its installation cannot enter a protected pod', async () => {
const pod = {
_id: 'chat-1', type: 'chat', members: ['human-id'],
Expand Down Expand Up @@ -445,11 +476,7 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'user' }) }),
});

const req = { params: { type: 'agent-room' }, userId: 'me', user: {} };
const req = { params: { type: 'agent-room' }, userId: 'me', user: { id: 'me', role: 'user' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getPodsByType(req, res);
expect(res.json).toHaveBeenCalledWith([myPod]);
Expand All @@ -467,11 +494,7 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'admin' }) }),
});

const req = { params: { type: 'agent-room' }, userId: 'admin-id', user: {} };
const req = { params: { type: 'agent-room' }, userId: 'admin-id', user: { id: 'admin-id', role: 'admin' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getPodsByType(req, res);
expect(res.json).toHaveBeenCalledWith([myPod]);
Expand All @@ -485,11 +508,7 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ populate: populateThird, sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'admin' }) }),
});

const req = { query: {}, userId: 'admin-id', user: {} };
const req = { query: {}, userId: 'admin-id', user: { id: 'admin-id', role: 'admin' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getAllPods(req, res);
// 'admin-id' is not a castable ObjectId, so this exercises the JS fallback.
Expand Down Expand Up @@ -579,17 +598,14 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ populate: populateThird, sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'admin' }) }),
});

const me = new mongoose.Types.ObjectId();
const req = { query: { scope: 'all' }, userId: String(me), user: {} };
const req = { query: { scope: 'all' }, userId: String(me), user: { id: String(me), role: 'admin' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getAllPods(req, res);

expect(Pod.find).toHaveBeenCalledWith({ type: { $nin: [...DEFAULT_LISTING_HIDDEN_POD_TYPES] } });
expect(res.json).toHaveBeenCalledWith([otherPod]);
expect(User.findById).not.toHaveBeenCalled();
});

it('getAllPods survives a member whose User row is gone (populate leaves null)', async () => {
Expand Down Expand Up @@ -666,14 +682,11 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ populate: populateThird, sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'admin' }) }),
});

const req = { query: { scope: 'all' }, userId: 'admin-id', user: {} };
const req = { query: { scope: 'all' }, userId: 'admin-id', user: { id: 'admin-id', role: 'admin' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getAllPods(req, res);
expect(res.json).toHaveBeenCalledWith([otherPod, myPod]);
expect(User.findById).not.toHaveBeenCalled();
});

it('getAllPods scope=all is silently downgraded to scope=mine for non-admins', async () => {
Expand All @@ -684,14 +697,11 @@ describe('podController', () => {
const populateSecond = jest.fn(() => ({ populate: populateThird, sort }));
const populateFirst = jest.fn(() => ({ populate: populateSecond, sort }));
Pod.find.mockReturnValue({ populate: populateFirst });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ role: 'user' }) }),
});

const req = { query: { scope: 'all' }, userId: 'me', user: {} };
const req = { query: { scope: 'all' }, userId: 'me', user: { id: 'me', role: 'user' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.getAllPods(req, res);
expect(res.json).toHaveBeenCalledWith([myPod]);
expect(User.findById).not.toHaveBeenCalled();
});

it('getPodById returns 404 for personal pod types when caller is not a member', async () => {
Expand Down
20 changes: 4 additions & 16 deletions backend/__tests__/unit/routes/agentProfile.avatar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

jest.mock('../../../models/User', () => ({
findOneAndUpdate: jest.fn(),
findById: jest.fn(),
}));
jest.mock('../../../models/Pod', () => ({}));
jest.mock('../../../models/AgentMemory', () => ({}));
Expand Down Expand Up @@ -54,20 +53,13 @@ const installationLookup = (result) => {
});
};

const callerRoleLookup = (role) => {
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(role ? { role } : null) }),
});
};

const putAvatar = getHandler('put', '/:agentName/:instanceId?/avatar');
const canEdit = getHandler('get', '/:agentName/:instanceId?/avatar/can-edit');

describe('agent avatar editing', () => {
beforeEach(() => {
jest.clearAllMocks();
User.findOneAndUpdate.mockResolvedValue({ _id: 'bot-1', profilePicture: 'bottts:theo:v3' });
callerRoleLookup(null);
AgentRegistry.updateOne.mockResolvedValue({});
AgentIdentityService.syncUserToPostgreSQL.mockResolvedValue(undefined);
});
Expand Down Expand Up @@ -113,7 +105,7 @@ describe('agent avatar editing', () => {
expect(AgentRegistry.updateOne).not.toHaveBeenCalled();
});

it('lets an admin edit without owning an installation (API-token shape: role on req.user)', async () => {
it('lets an admin edit without owning an installation using req.user.role', async () => {
const res = response();

await putAvatar(reqFor({ id: 'admin-1', role: 'admin' }), res);
Expand All @@ -123,18 +115,14 @@ describe('agent avatar editing', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ ok: true }));
});

it('lets an admin edit on a JWT session, where req.user carries NO role', async () => {
// middleware/auth.ts sets req.user = { id } for JWT logins — the browser
// shape. The gate must load the role from DB or the admin path is dead for
// every human session (the bug found live on fc-verify, 2026-08-21).
callerRoleLookup('admin');
it('can-edit admits an admin without an installation', async () => {
installationLookup(null);
const res = response();

await putAvatar(reqFor({ id: 'admin-1' }), res);
await canEdit(reqFor({ id: 'admin-1', role: 'admin' }), res);

expect(AgentInstallation.findOne).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ ok: true }));
expect(res.json).toHaveBeenCalledWith({ canEdit: true });
});

it('400s anything that is not a bottts preset or an upload reference', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ const getRouteHandler = (path, method) => {
return layer.route.stack[layer.route.stack.length - 1].handle;
};

const publish = async (components) => {
const publish = async (components, user = { id: 'user-1', username: 'nova' }) => {
const handler = getRouteHandler('/publish', 'post');
const req = {
userId: 'user-1',
user: { id: 'user-1', username: 'nova' },
user,
body: {
installableId: '@nova/my-agent',
name: 'My Agent',
Expand Down Expand Up @@ -96,6 +96,25 @@ describe('marketplace publish validates inside the components array', () => {
expect(Installable.create).toHaveBeenCalledTimes(1);
});

it('publishes from an admin session using req.user.username', async () => {
const res = await publish([], { id: 'user-1', username: 'nova', role: 'admin' });
expect(res.status).toHaveBeenCalledWith(201);
expect(Installable.create).toHaveBeenCalledTimes(1);
});

it('rejects a caller without a username instead of re-reading the user row', async () => {
const res = await publish([], { id: 'user-1', role: 'user' });
expect(res.status).toHaveBeenCalledWith(401);
expect(Installable.create).not.toHaveBeenCalled();
});

it('refuses a non-admin publishing into another username namespace', async () => {
const res = await publish([], { id: 'user-1', username: 'mallory', role: 'user' });
expect(res.status).toHaveBeenCalledWith(400);
expect(errorOf(res)).toMatch(/namespace/i);
expect(Installable.create).not.toHaveBeenCalled();
});

it('accepts the ADR-001 McpServer component and bounds its variables field', async () => {
const res = await publish([{
name: 'Calendar',
Expand Down
45 changes: 15 additions & 30 deletions backend/__tests__/unit/routes/registry.publisherNameJwtPath.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
// `middleware/auth.ts` dispatches on the token and the two branches leave
// DIFFERENT shapes on `req.user`: the `cm_` API-token path assigns
// `{ id, username, email, role }` (`:51`); the browser-JWT path assigns
// `{ id }` (`:81`). Neither errors. So the registry writers, which read
// `req.user.username` directly, persisted `publisher.name: undefined` for
// every browser-session publish — a silent data defect with nothing to go red.
//
// Per AX audit entry 42, the auth middleware is mocked to `next()` here on
// purpose and the shape is CONSTRUCTED on the request instead: the subject is
// the consumer's handling of each shape, not the dispatcher's choice between
// them, and a test that let the route pick would only ever exercise one.
// The auth middleware now supplies username on both browser JWT and API-token
// requests. These tests pin publisher.name on both shapes; the model mock has
// no findById, so a redundant user lookup would fail the suite.

jest.mock('../../../middleware/auth', () => (req, res, next) => next());

const mockFindById = jest.fn();
jest.mock('../../../models/User', () => ({ findById: (...a) => mockFindById(...a) }));
jest.mock('../../../models/User', () => ({}));

const mockCreate = jest.fn();
jest.mock('../../../models/AgentRegistry', () => ({
Expand All @@ -34,11 +25,12 @@ const handler = (() => {

const manifest = { name: 'demo-agent', version: '1.0.0', description: 'd' };

const run = async (user) => {
const run = async (user, agentUser) => {
mockCreate.mockClear();
mockCreate.mockResolvedValue({ agentName: 'demo-agent' });
const req = {
user,
agentUser,
body: {
manifest, displayName: 'Demo', readme: null, categories: [], tags: [],
},
Expand All @@ -50,31 +42,24 @@ const run = async (user) => {
};

describe('registry publish — publisher.name across both auth shapes', () => {
beforeEach(() => {
mockFindById.mockReset();
mockFindById.mockReturnValue({ select: () => ({ lean: async () => ({ username: 'lily' }) }) });
});

// The regression case. Pre-fix this yielded `name: undefined`.
it('resolves the username on the browser-JWT shape, which carries only { id }', async () => {
const publisher = await run({ id: 'u1' });
it('uses the browser-JWT username without a DB read', async () => {
const publisher = await run({ id: 'u1', username: 'lily', email: 'lily@example.com', role: 'admin' });
expect(publisher.name).toBe('lily');
expect(mockFindById).toHaveBeenCalledWith('u1');
});

it('uses the token-supplied username on the cm_ shape without a DB read', async () => {
it('uses the API-token username without a DB read', async () => {
const publisher = await run({
id: 'u1', username: 'sam', email: 'e', role: 'user',
});
expect(publisher.name).toBe('sam');
expect(mockFindById).not.toHaveBeenCalled();
});

// A publisher row whose name is `undefined` is the defect itself, so no
// shape may produce one: an unresolvable user must persist null, not a
// missing key that reads as "not yet written".
it('persists null rather than undefined when the user cannot be resolved', async () => {
mockFindById.mockReturnValue({ select: () => ({ lean: async () => null }) });
it('keeps the explicit agentUser username fallback', async () => {
const publisher = await run({ id: 'u1' }, { username: 'agent-one' });
expect(publisher.name).toBe('agent-one');
});

it('persists null rather than undefined when no username is supplied', async () => {
const publisher = await run({ id: 'u1' });
expect(publisher.name).toBeNull();
expect('name' in publisher).toBe(true);
Expand Down
14 changes: 4 additions & 10 deletions backend/controllers/podController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,7 @@ const buildDefaultAgentProfileId = (agentName: any, instanceId = 'default') => (
`${agentName.toLowerCase()}:${instanceId || 'default'}`
);

const isGlobalAdminRequest = async (req: any) => {
if (req.user?.role === 'admin') return true;
const userId = req.userId || req.user?.id || req.user?._id;
if (!userId) return false;
const user = await User.findById(userId).select('role').lean();
return Boolean(user && user.role === 'admin');
};
const isGlobalAdminRequest = (req: any) => req.user?.role === 'admin';

const ensureDefaultAgentRegistryEntry = async (agentName: any) => {
const normalized = String(agentName || '').trim().toLowerCase();
Expand Down Expand Up @@ -232,7 +226,7 @@ exports.getAllPods = async (req: any, res: any) => {
let memberId: any = null;
if (req.userId && !isCommunityScope && !isDiscoverScope) {
const wantsAll = scope === 'all';
const isAdmin = wantsAll ? await isGlobalAdminRequest(req) : false;
const isAdmin = wantsAll ? isGlobalAdminRequest(req) : false;
membershipFilterToMine = isPersonal || !wantsAll || !isAdmin;
if (membershipFilterToMine) {
const uid = String(req.userId);
Expand Down Expand Up @@ -543,7 +537,7 @@ exports.joinPod = async (req: any, res: any) => {
// Repeated self-join requests are successful and side-effect free. This
// keeps optimistic clients and retried requests idempotent.
if (!isMember) {
const isAdmin = await isGlobalAdminRequest(req);
const isAdmin = isGlobalAdminRequest(req);
const isJoinable = isDirectlyJoinable(pod);
if (!isAdmin && !isJoinable) {
return res.status(403).json({
Expand Down Expand Up @@ -722,7 +716,7 @@ exports.deletePod = async (req: any, res: any) => {

const requesterId = req.userId || req.user?.id || req.user?._id;
const isCreator = pod.createdBy.toString() === requesterId?.toString();
const isGlobalAdmin = await isGlobalAdminRequest(req);
const isGlobalAdmin = isGlobalAdminRequest(req);

// Check if user is the creator or global admin
if (!isCreator && !isGlobalAdmin) {
Expand Down
Loading
Loading