diff --git a/backend/__tests__/service/registryRuntimeAdmin.test.js b/backend/__tests__/service/registryRuntimeAdmin.test.js new file mode 100644 index 0000000000..8b98592e8f --- /dev/null +++ b/backend/__tests__/service/registryRuntimeAdmin.test.js @@ -0,0 +1,64 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { setupMongoDb, clearMongoDb, closeMongoDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const { AgentInstallation } = require('../../models/AgentRegistry'); + +jest.mock('../../services/agentProvisionerService', () => ({ + startAgentRuntime: jest.fn(async () => ({})), + stopAgentRuntime: jest.fn(async () => ({})), + restartAgentRuntime: jest.fn(async () => ({})), + getAgentRuntimeStatus: jest.fn(async () => ({})), + getAgentRuntimeLogs: jest.fn(async () => ({ logs: '' })), + clearAgentRuntimeSessions: jest.fn(async () => ({})), + resolveOpenClawAccountId: jest.fn(({ instanceId }) => instanceId), +})); +const adapters = require('../../services/agentProvisionerService'); +const router = require('../../routes/registry/runtime'); +const operations = [ + ['get', 'runtime-status'], ['post', 'runtime-start'], ['post', 'runtime-stop'], + ['post', 'runtime-restart'], ['post', 'runtime-clear-sessions'], ['get', 'runtime-logs'], +]; +const SECRET = 'registry-runtime-admin-test'; + +describe('registry runtime admin access', () => { + let app; + let pod; + let member; + let admin; + const call = (method, route, user) => request(app)[method]( + `/api/registry/pods/${pod._id}/agents/openclaw/${route}`, + ).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`); + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + [member, admin] = await Promise.all(['user', 'admin'].map((role) => User.create({ + username: `runtime-admin-${role}`, email: `runtime-admin-${role}@example.test`, + password: 'test-password', role, + }))); + pod = await Pod.create({ name: 'Runtime administration', createdBy: member._id, members: [member._id, admin._id] }); + await AgentInstallation.create({ + agentName: 'openclaw', instanceId: 'default', podId: pod._id, version: '1', installedBy: member._id, + }); + app = express(); + app.use(express.json()); + app.use('/api/registry', router); + }); + beforeEach(() => { jest.clearAllMocks(); }); + afterAll(async () => { await clearMongoDb(); await closeMongoDb(); }); + + it.each(operations)('refuses a non-admin member for %s %s', async (method, route) => { + const response = await call(method, route, member); + expect(response.status).toBe(403); + expect(response.body.message).toBe('Admin access required'); + for (const adapter of Object.values(adapters)) expect(adapter).not.toHaveBeenCalled(); + }); + + it.each(operations)('permits an admin member for %s %s', async (method, route) => { + expect((await call(method, route, admin)).status).toBe(200); + expect(Object.values(adapters).some((adapter) => adapter.mock.calls.length)).toBe(true); + }); +}); diff --git a/backend/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts index 9c33042536..b46fa68304 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -3,6 +3,7 @@ export {}; const express = require('express'); const auth = require('../../middleware/auth'); +const requireAdmin = require('../../middleware/adminAuth'); const Pod = require('../../models/Pod'); const { AgentInstallation } = require('../../models/AgentRegistry'); const AgentIdentityService = require('../../services/agentIdentityService'); @@ -32,7 +33,7 @@ const runtimeRouter = express.Router({ mergeParams: true }); * GET /api/registry/pods/:podId/agents/:name/runtime-status * Check local runtime status (docker). */ -runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: any, res: any) => { +runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const userId = getUserId(req); @@ -94,7 +95,7 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: /** * POST /api/registry/pods/:podId/agents/:name/runtime-start */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -156,7 +157,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: /** * POST /api/registry/pods/:podId/agents/:name/runtime-stop */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -218,7 +219,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: a /** * POST /api/registry/pods/:podId/agents/:name/runtime-restart */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -280,7 +281,8 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req /** * POST /api/registry/pods/:podId/agents/:name/runtime-clear-sessions */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', + auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId, restart = true } = req.body || {}; @@ -366,7 +368,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, asy /** * GET /api/registry/pods/:podId/agents/:name/runtime-logs */ -runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, async (req: any, res: any) => { +runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, requireAdmin, async (req: any, res: any) => { try { const { podId, name } = req.params; const lines = Number(req.query.lines || 200);