/** * 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 = 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); });