Skip to content
Open
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
148 changes: 148 additions & 0 deletions backend/__tests__/rotation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Tests for secret rotation service and controller.
* Run with: node --test
*/
const { describe, it, before, after, mock } = require('node:test');
const assert = require('node:assert/strict');

// ── helpers ──────────────────────────────────────────────────────────────────

function makeMockCredential(overrides = {}) {
return {
_id: 'cred-1',
userId: 'user-1',
provider: 'slack',
accessToken: 'encrypted-old',
refreshToken: 'encrypted-old-refresh',
status: 'active',
updatedAt: new Date(),
save: async function () { this.updatedAt = new Date(); },
...overrides,
};
}

// ── Unit: generateSecret ──────────────────────────────────────────────────────

describe('generateSecret', () => {
it('returns a 64-char hex string', () => {
// Inline the logic to avoid DB deps
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
assert.equal(secret.length, 64);
assert.match(secret, /^[0-9a-f]+$/);
});

it('generates unique values each call', () => {
const crypto = require('crypto');
const a = crypto.randomBytes(32).toString('hex');
const b = crypto.randomBytes(32).toString('hex');
assert.notEqual(a, b);
});
});

// ── Unit: rotateCredential (mocked deps) ─────────────────────────────────────

describe('rotateCredential', () => {
let rotateCredential;
let mockCredential;

before(() => {
mockCredential = makeMockCredential();

// Stub modules before requiring the service
const Module = require('node:module');
const originalLoad = Module._load;

Module._load = function (request, parent, isMain) {
if (request.includes('credential.model')) {
return {
findById: async () => mockCredential,
};
}
if (request.includes('rotationPolicy.model')) {
return {
updateOne: async () => ({}),
};
}
if (request.includes('audit.model')) {
return {
createLog: async () => ({}),
};
}
if (request.includes('encryption')) {
return { encrypt: (v) => `enc:${v}` };
}
if (request.includes('logger')) {
return { info: () => {}, error: () => {}, warn: () => {} };
}
return originalLoad.apply(this, arguments);
};

// Clear cache so stubs take effect
Object.keys(require.cache).forEach((k) => {
if (k.includes('rotation.service')) delete require.cache[k];
});

({ rotateCredential } = require('../src/services/rotation.service'));
Module._load = originalLoad; // restore
});

it('returns credentialId and rotatedAt', async () => {
const result = await rotateCredential('cred-1', { userId: 'user-1' });
assert.equal(result.credentialId, 'cred-1');
assert.ok(result.rotatedAt instanceof Date);
});

it('updates accessToken on the credential', async () => {
await rotateCredential('cred-1', { userId: 'user-1' });
assert.match(mockCredential.accessToken, /^enc:/);
});
});

// ── Unit: processDueRotations ─────────────────────────────────────────────────

describe('processDueRotations', () => {
it('returns empty array when no policies are due', async () => {
const Module = require('node:module');
const originalLoad = Module._load;

Module._load = function (request, parent, isMain) {
if (request.includes('rotationPolicy.model')) {
return { find: async () => [] };
}
if (request.includes('credential.model')) return { findById: async () => null };
if (request.includes('audit.model')) return { createLog: async () => ({}) };
if (request.includes('encryption')) return { encrypt: (v) => `enc:${v}` };
if (request.includes('logger')) return { info: () => {}, error: () => {}, warn: () => {} };
return originalLoad.apply(this, arguments);
};

Object.keys(require.cache).forEach((k) => {
if (k.includes('rotation.service')) delete require.cache[k];
});

const { processDueRotations } = require('../src/services/rotation.service');
Module._load = originalLoad;

const results = await processDueRotations();
assert.deepEqual(results, []);
});
});

// ── Unit: rotation controller helpers ────────────────────────────────────────

describe('rotation controller – upsertPolicy validation', () => {
it('rejects intervalHours < 1', async () => {
const AppError = require('../src/utils/appError');
// Simulate the guard in the controller
const intervalHours = 0;
let threw = false;
try {
if (!intervalHours || intervalHours < 1) throw new AppError('intervalHours must be >= 1', 400);
} catch (e) {
threw = true;
assert.equal(e.statusCode, 400);
}
assert.ok(threw);
});
});
1 change: 1 addition & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ app.use('/api/invitations', require('./routes/invitation.routes'));
// app.use('/api/team', require('./routes/team.routes'));
app.use('/api/queue', require('./routes/queue.routes'));
app.use('/api/discovery', require('./routes/discovery.routes'));
app.use('/api/credentials', require('./routes/credential.routes'));
/**
* @openapi
* /api/health:
Expand Down
69 changes: 69 additions & 0 deletions backend/src/controllers/rotation.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const RotationPolicy = require('../models/rotationPolicy.model');
const Credential = require('../models/credential.model');
const { rotateCredential, processDueRotations } = require('../services/rotation.service');
const asyncHandler = require('../utils/asyncHandler');
const AppError = require('../utils/appError');

/**
* GET /api/credentials/:id/rotation-policy
*/
exports.getPolicy = asyncHandler(async (req, res) => {
const policy = await RotationPolicy.findOne({ credentialId: req.params.id, userId: req.user.id });
if (!policy) throw new AppError('No rotation policy found', 404);
res.json(policy);
});

/**
* POST /api/credentials/:id/rotation-policy
* Body: { intervalHours }
*/
exports.upsertPolicy = asyncHandler(async (req, res) => {
const { intervalHours } = req.body;
if (!intervalHours || intervalHours < 1) throw new AppError('intervalHours must be >= 1', 400);

const credential = await Credential.findOne({ _id: req.params.id, userId: req.user.id });
if (!credential) throw new AppError('Credential not found', 404);

const nextRotationAt = new Date(Date.now() + intervalHours * 3600 * 1000);

const policy = await RotationPolicy.findOneAndUpdate(
{ credentialId: req.params.id, userId: req.user.id },
{ intervalHours, nextRotationAt, enabled: true },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);

res.status(200).json(policy);
});

/**
* DELETE /api/credentials/:id/rotation-policy
*/
exports.deletePolicy = asyncHandler(async (req, res) => {
const result = await RotationPolicy.findOneAndDelete({ credentialId: req.params.id, userId: req.user.id });
if (!result) throw new AppError('No rotation policy found', 404);
res.json({ message: 'Rotation policy deleted' });
});

/**
* POST /api/credentials/:id/rotate — manual rotation
*/
exports.rotateNow = asyncHandler(async (req, res) => {
const credential = await Credential.findOne({ _id: req.params.id, userId: req.user.id });
if (!credential) throw new AppError('Credential not found', 404);

const result = await rotateCredential(req.params.id, {
userId: req.user.id,
ipAddress: req.ip,
userAgent: req.get('User-Agent'),
});

res.json(result);
});

/**
* POST /api/credentials/rotate/process — trigger due rotations (admin/cron)
*/
exports.processDue = asyncHandler(async (req, res) => {
const results = await processDueRotations();
res.json({ processed: results.length, results });
});
39 changes: 39 additions & 0 deletions backend/src/models/rotationPolicy.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const mongoose = require('mongoose');

const rotationPolicySchema = new mongoose.Schema({
credentialId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Credential',
required: true,
index: true,
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
// Rotation interval in hours (e.g. 24 = daily, 168 = weekly)
intervalHours: {
type: Number,
required: true,
min: 1,
},
lastRotatedAt: {
type: Date,
default: null,
},
nextRotationAt: {
type: Date,
required: true,
index: true,
},
enabled: {
type: Boolean,
default: true,
},
}, { timestamps: true });

rotationPolicySchema.index({ nextRotationAt: 1, enabled: 1 });

module.exports = mongoose.model('RotationPolicy', rotationPolicySchema);
107 changes: 107 additions & 0 deletions backend/src/routes/credential.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const express = require('express');
const router = express.Router();
const rotationController = require('../controllers/rotation.controller');
const authMiddleware = require('../middleware/auth.middleware');

router.use(authMiddleware);

/**
* @openapi
* /api/credentials/{id}/rotation-policy:
* get:
* summary: Get rotation policy for a credential
* tags: [Credentials, Rotation]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Rotation policy
* 404:
* description: Not found
* post:
* summary: Create or update rotation policy
* tags: [Credentials, Rotation]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [intervalHours]
* properties:
* intervalHours:
* type: integer
* minimum: 1
* description: Rotation interval in hours
* responses:
* 200:
* description: Policy created/updated
* delete:
* summary: Delete rotation policy
* tags: [Credentials, Rotation]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Policy deleted
*/
router.route('/:id/rotation-policy')
.get(rotationController.getPolicy)
.post(rotationController.upsertPolicy)
.delete(rotationController.deletePolicy);

/**
* @openapi
* /api/credentials/{id}/rotate:
* post:
* summary: Manually rotate a credential's secret
* tags: [Credentials, Rotation]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Credential rotated
*/
router.post('/:id/rotate', rotationController.rotateNow);

/**
* @openapi
* /api/credentials/rotate/process:
* post:
* summary: Process all due credential rotations (admin/cron)
* tags: [Credentials, Rotation]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Rotation results
*/
router.post('/rotate/process', rotationController.processDue);

module.exports = router;
8 changes: 8 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ mongoose
});
}, 24 * 60 * 60 * 1000); // Run daily

// Secret rotation scheduler — check every hour
const { processDueRotations } = require('./services/rotation.service');
setInterval(() => {
processDueRotations().catch(error => {
logger.error('Secret rotation scheduler failed', { error: error.message });
});
}, 60 * 60 * 1000);

app.listen(PORT, () => {
logger.info('Server started successfully', {
port: PORT,
Expand Down
Loading