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);
|
||
|
|
});
|