feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema) - Create shared KNOWN_TEAMS constant and validateTeams helper - Expose user teams in auth middleware, login, and /me responses - Add bu_teams CRUD to user management routes with audit logging - Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var - Add query-time team filtering to GET /findings and /findings/counts - Update AuthContext with teams helpers and admin scope toggle - Create AdminScopeToggle component (My Teams / All BUs) - Scope ReportingPage findings fetch by user teams - Scope CompliancePage team selector by user teams - Scope ExportsPage findings exports by user teams - Add BU teams multi-select to UserManagement create/edit forms - Display team badges in user list table
This commit is contained in:
108
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
108
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Property-Based Test: JQL Window Invariant
|
||||
*
|
||||
* Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync
|
||||
*
|
||||
* For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(),
|
||||
* the generated JQL string SHALL contain the substring `updated >= -72h` and
|
||||
* SHALL contain the substring `project =`.
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.3
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// Capture the JQL that flows through the HTTP layer.
|
||||
let capturedJql = null;
|
||||
|
||||
// Mock https to intercept the request URL (which contains the JQL) and return
|
||||
// a fake 200 response. This prevents real network calls while letting the
|
||||
// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute.
|
||||
jest.mock('https', () => ({
|
||||
request: jest.fn((options, callback) => {
|
||||
const fullPath = options.path || '';
|
||||
const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/);
|
||||
if (jqlMatch) {
|
||||
capturedJql = decodeURIComponent(jqlMatch[1]);
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(JSON.stringify({ total: 0, issues: [] }));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
// Use setImmediate so the callback fires on the same tick after promises
|
||||
// resolve, but still asynchronously as Node's http expects.
|
||||
setImmediate(() => callback(mockResponse));
|
||||
|
||||
return {
|
||||
on: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Set required env vars before requiring the module so the module-level
|
||||
// constants pick them up.
|
||||
process.env.JIRA_PROJECT_KEY = 'TESTPROJ';
|
||||
process.env.JIRA_BASE_URL = 'https://jira.example.com';
|
||||
process.env.JIRA_API_USER = 'testuser';
|
||||
process.env.JIRA_API_TOKEN = 'testtoken';
|
||||
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => {
|
||||
// Use fake timers so the rate-limiter's inter-request delays (1–2 seconds)
|
||||
// resolve instantly. We preserve setImmediate so the https mock callback
|
||||
// still fires asynchronously as expected.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
capturedJql = null;
|
||||
});
|
||||
|
||||
// Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc.
|
||||
const issueKeyArb = fc.tuple(
|
||||
fc.stringMatching(/^[A-Z]{2,10}$/),
|
||||
fc.integer({ min: 1, max: 99999 })
|
||||
).map(([prefix, num]) => `${prefix}-${num}`);
|
||||
|
||||
// Generator: non-empty array of issue keys (1 to 50 keys)
|
||||
const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 });
|
||||
|
||||
it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(issueKeysArb, async (issueKeys) => {
|
||||
capturedJql = null;
|
||||
|
||||
// Start the call — it will hit waitForDelay which uses setTimeout
|
||||
const promise = jiraApi.searchIssuesByKeys(issueKeys);
|
||||
|
||||
// Advance fake timers to resolve any pending setTimeout from the
|
||||
// rate limiter's waitForDelay function.
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(capturedJql).not.toBeNull();
|
||||
expect(capturedJql).toContain('updated >= -72h');
|
||||
expect(capturedJql).toContain('project =');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
146
backend/__tests__/jira-route-removal.test.js
Normal file
146
backend/__tests__/jira-route-removal.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Example-Based Tests: Route Removal and Remaining Routes
|
||||
*
|
||||
* Feature: jira-api-compliance-cleanup
|
||||
*
|
||||
* Property 2: Search route is absent from router (Example)
|
||||
* After the route removal, a POST request to /api/jira/search SHALL return HTTP 404.
|
||||
* Validates: Requirements 1.1, 1.2
|
||||
*
|
||||
* Property 3: Existing routes remain functional after search route removal (Example)
|
||||
* The routes GET /lookup/:issueKey, POST /sync-all, POST /:id/sync, and
|
||||
* POST /create-in-jira SHALL continue to respond with non-404 status codes.
|
||||
* Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock the audit log helper to be a no-op.
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the jiraApi helper — mark it as not configured so routes return 503
|
||||
// (which is fine; we only care that they are NOT 404).
|
||||
jest.mock('../helpers/jiraApi', () => ({
|
||||
isConfigured: false,
|
||||
getRateLimitStatus: jest.fn(() => ({
|
||||
burst: { remaining: 60, limit: 60 },
|
||||
daily: { remaining: 1440, limit: 1440 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||
|
||||
// Minimal db mock — callback-style methods that return empty results.
|
||||
function createMockDb() {
|
||||
return {
|
||||
get: jest.fn((_sql, _params, cb) => cb(null, null)),
|
||||
all: jest.fn((_sql, _params, cb) => cb(null, [])),
|
||||
run: jest.fn(function (_sql, _params, cb) {
|
||||
if (typeof cb === 'function') cb.call({ lastID: 1, changes: 0 }, null);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request to the test server and return { statusCode }.
|
||||
*/
|
||||
function request(server, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
// Consume the response body so the socket closes cleanly.
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve({ statusCode: res.statusCode });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Feature: jira-api-compliance-cleanup — route removal tests', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
const db = createMockDb();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||
|
||||
// Listen on a random available port.
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: POST /api/jira-tickets/search returns 404
|
||||
// Validates: Requirements 1.1, 1.2
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Property 2: Search route is absent', () => {
|
||||
it('POST /api/jira-tickets/search returns HTTP 404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/search', {
|
||||
jql: 'project = TEST',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Remaining routes respond with non-404 status codes
|
||||
// Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Property 3: Existing routes remain functional', () => {
|
||||
it('GET /api/jira-tickets/lookup/:issueKey returns non-404', async () => {
|
||||
const res = await request(server, 'GET', '/api/jira-tickets/lookup/TEST-1');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/sync-all returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/sync-all');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/:id/sync returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/sync');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/create-in-jira returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/create-in-jira', {
|
||||
cve_id: 'CVE-2024-12345',
|
||||
vendor: 'TestVendor',
|
||||
summary: 'Test summary',
|
||||
});
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user