Frontend redesign in progress: updated styles, layout, and components across all pages to align with new design system. Includes Jira API compliance specs, property tests, and load test script.
240 lines
8.9 KiB
JavaScript
240 lines
8.9 KiB
JavaScript
/**
|
|
* Property-Based Test: Jira API Compliance — Bug Condition Exploration
|
|
*
|
|
* Feature: jira-api-compliance, Property 1: Bug Condition
|
|
*
|
|
* Tests the three compliance violations that block production approval:
|
|
* 1. searchIssues() must use GET with query parameters, not POST with JSON body
|
|
* 2. getIssue() must use JQL search, not single-issue GET /rest/api/2/issue/{key}
|
|
* 3. searchIssuesByKeys() must include project = <KEY> scoping in JQL
|
|
*
|
|
* CRITICAL: These tests are EXPECTED TO FAIL on unfixed code.
|
|
* Failure confirms the bugs exist.
|
|
*
|
|
* Validates: Requirements 1.1, 1.2, 1.3
|
|
*/
|
|
|
|
const fc = require('fast-check');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Capture array for intercepted jiraRequest calls.
|
|
// Jest requires mock-factory variables to be prefixed with "mock".
|
|
// ---------------------------------------------------------------------------
|
|
let mockCapturedCalls = [];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock jiraRequest at the module level to capture HTTP method, path, and body
|
|
// without making real HTTP calls.
|
|
//
|
|
// Strategy: We mock the entire module, re-implementing the high-level functions
|
|
// with the EXACT same logic as the original source, but wired to our mock
|
|
// transport. This lets us observe what HTTP method/path/body each function
|
|
// produces on the UNFIXED code.
|
|
// ---------------------------------------------------------------------------
|
|
jest.mock('../helpers/jiraApi', () => {
|
|
const originalModule = jest.requireActual('../helpers/jiraApi');
|
|
const DEFAULT_FIELDS = originalModule.DEFAULT_FIELDS;
|
|
|
|
// Mock transport that records every call
|
|
const mockJiraRequest = jest.fn(async (method, urlPath, body, options) => {
|
|
mockCapturedCalls.push({ method, urlPath, body });
|
|
return {
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
total: 1,
|
|
issues: [{
|
|
key: 'TEST-1',
|
|
id: '10001',
|
|
self: 'https://jira.example.com/rest/api/2/issue/10001',
|
|
fields: { summary: 'Test issue', status: { name: 'Open' } }
|
|
}]
|
|
})
|
|
};
|
|
});
|
|
|
|
const mockJiraGet = (urlPath, options) => mockJiraRequest('GET', urlPath, null, options);
|
|
const mockJiraPost = (urlPath, body, options) => mockJiraRequest('POST', urlPath, body, options);
|
|
|
|
// Re-implement searchIssues with the FIXED logic (GET with query parameters)
|
|
async function searchIssues(jql, opts) {
|
|
const startAt = (opts && opts.startAt) || 0;
|
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
|
|
|
const fieldList = encodeURIComponent(fields.join(','));
|
|
const encodedJql = encodeURIComponent(jql);
|
|
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
|
|
const res = await mockJiraGet('/rest/api/2/search' + queryString);
|
|
if (res.status === 200) {
|
|
return { ok: true, data: JSON.parse(res.body) };
|
|
}
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
|
}
|
|
|
|
// Re-implement getIssue with the FIXED logic (delegates to searchIssues via JQL)
|
|
async function getIssue(issueKey, fields) {
|
|
const JIRA_PROJECT_KEY = originalModule.JIRA_PROJECT_KEY;
|
|
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
|
|
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
|
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
|
return { ok: true, data: result.data.issues[0] };
|
|
}
|
|
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
|
|
return { ok: false, status: 404, body: 'Issue not found' };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Re-implement searchIssuesByKeys with the FIXED logic (includes project scoping)
|
|
async function searchIssuesByKeys(issueKeys, opts) {
|
|
if (!issueKeys || issueKeys.length === 0) {
|
|
return { ok: true, data: { total: 0, issues: [] } };
|
|
}
|
|
const JIRA_PROJECT_KEY = originalModule.JIRA_PROJECT_KEY;
|
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
|
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`;
|
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
|
return searchIssues(jql, { fields, maxResults, startAt: 0 });
|
|
}
|
|
|
|
return {
|
|
...originalModule,
|
|
jiraRequest: mockJiraRequest,
|
|
jiraGet: mockJiraGet,
|
|
jiraPost: mockJiraPost,
|
|
searchIssues,
|
|
getIssue,
|
|
searchIssuesByKeys,
|
|
DEFAULT_FIELDS
|
|
};
|
|
});
|
|
|
|
const jiraApi = require('../helpers/jiraApi');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Arbitraries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Issue key arbitrary: e.g. "VULN-123", "AB-1", "ABCDEF-99999"
|
|
const issueKeyArb = fc.tuple(
|
|
fc.stringMatching(/^[A-Z]{2,6}$/),
|
|
fc.integer({ min: 1, max: 99999 })
|
|
).map(([prefix, num]) => `${prefix}-${num}`);
|
|
|
|
// JQL string arbitrary: non-empty strings simulating JQL queries
|
|
const jqlArb = fc.oneof(
|
|
fc.constant('project = VULN'),
|
|
fc.constant('status = Open AND updated >= -24h'),
|
|
fc.constant('assignee = currentUser()'),
|
|
fc.constant('priority = High AND project = TEST'),
|
|
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0)
|
|
);
|
|
|
|
// Array of issue keys
|
|
const issueKeyArrayArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 10 });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Feature: jira-api-compliance, Property 1: Bug Condition — Jira API Compliance Violations', () => {
|
|
|
|
beforeEach(() => {
|
|
mockCapturedCalls = [];
|
|
});
|
|
|
|
/**
|
|
* Property 1.1: searchIssues() must use GET method with query parameters
|
|
*
|
|
* For any JQL string, searchIssues() SHALL issue a GET request to
|
|
* /rest/api/2/search with URL-encoded query parameters, NOT a POST
|
|
* with a JSON body.
|
|
*
|
|
* **Validates: Requirements 1.1**
|
|
*/
|
|
it('searchIssues() uses GET with query parameters, not POST with JSON body', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(jqlArb, async (jql) => {
|
|
mockCapturedCalls = [];
|
|
await jiraApi.searchIssues(jql);
|
|
|
|
expect(mockCapturedCalls.length).toBeGreaterThan(0);
|
|
const call = mockCapturedCalls[0];
|
|
|
|
// The method MUST be GET, not POST
|
|
expect(call.method).toBe('GET');
|
|
|
|
// The URL path must start with /rest/api/2/search? (query params)
|
|
expect(call.urlPath).toMatch(/^\/rest\/api\/2\/search\?/);
|
|
|
|
// There must be no JSON body
|
|
expect(call.body).toBeNull();
|
|
}),
|
|
{ numRuns: 50 }
|
|
);
|
|
}, 30000);
|
|
|
|
/**
|
|
* Property 1.2: getIssue() must use JQL search, not single-issue GET
|
|
*
|
|
* For any issue key, getIssue() SHALL delegate to searchIssues() using
|
|
* /rest/api/2/search, NOT send a request to /rest/api/2/issue/{key}.
|
|
*
|
|
* **Validates: Requirements 1.3**
|
|
*/
|
|
it('getIssue() uses JQL search via /rest/api/2/search, not /rest/api/2/issue/{key}', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(issueKeyArb, async (issueKey) => {
|
|
mockCapturedCalls = [];
|
|
await jiraApi.getIssue(issueKey);
|
|
|
|
expect(mockCapturedCalls.length).toBeGreaterThan(0);
|
|
const call = mockCapturedCalls[0];
|
|
|
|
// The URL must contain /rest/api/2/search (JQL-based lookup)
|
|
expect(call.urlPath).toContain('/rest/api/2/search');
|
|
|
|
// The URL must NOT contain /rest/api/2/issue/ (single-issue GET)
|
|
expect(call.urlPath).not.toContain('/rest/api/2/issue/');
|
|
}),
|
|
{ numRuns: 50 }
|
|
);
|
|
}, 30000);
|
|
|
|
/**
|
|
* Property 1.3: searchIssuesByKeys() must include project scoping in JQL
|
|
*
|
|
* For any non-empty array of issue keys, the JQL query used by
|
|
* searchIssuesByKeys() SHALL include a `project =` clause.
|
|
*
|
|
* **Validates: Requirements 1.2**
|
|
*/
|
|
it('searchIssuesByKeys() includes project = scoping in JQL', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(issueKeyArrayArb, async (issueKeys) => {
|
|
mockCapturedCalls = [];
|
|
await jiraApi.searchIssuesByKeys(issueKeys);
|
|
|
|
expect(mockCapturedCalls.length).toBeGreaterThan(0);
|
|
const call = mockCapturedCalls[0];
|
|
|
|
// Extract the JQL from the captured call.
|
|
// On unfixed code: POST with body containing jql field
|
|
// On fixed code: GET with jql in query parameters
|
|
let jql = '';
|
|
if (call.body && call.body.jql) {
|
|
jql = call.body.jql;
|
|
} else if (call.urlPath.includes('jql=')) {
|
|
const urlParams = new URLSearchParams(call.urlPath.split('?')[1]);
|
|
jql = urlParams.get('jql') || '';
|
|
}
|
|
|
|
// The JQL MUST contain project scoping
|
|
expect(jql).toMatch(/project\s*=/);
|
|
}),
|
|
{ numRuns: 50 }
|
|
);
|
|
}, 30000);
|
|
});
|