super-productivity/electron/jira.test.cjs
Johannes Millan 95d3b212bc
fix(electron): gate Jira IPC behind a one-shot capability (#9008)
* docs(plugins): add microsoft 365 calendar provider plan

* fix(jira): gate Electron requests behind one-shot capability

Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.

* fix(jira): enforce Electron request capability

Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.

* fix(electron): handle payload-only IPC lifecycle

Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.

* fix(electron): address Jira IPC capability review findings

- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
  payload-only IPC refactor (was [1], now undefined -> TypeError on every
  download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
  that reuses the WebFrameMain object is not permanently locked out of
  Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
  main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
  when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
  _handleResponse instead of leaking a dangling request-log entry

* test(electron): cover ANY_FILE_DOWNLOADED payload parsing

Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.

* fix(electron): restore Node ambient globals for frontend build
2026-07-14 21:02:27 +02:00

420 lines
10 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
require('ts-node/register/transpile-only');
const {
executeJiraRequest,
} = require(
path.resolve(__dirname, 'jira.ts'),
);
const {
applyJiraImageAuth,
clearRequestHeadersForImages,
setupRequestHeadersForImages,
} = require(path.resolve(__dirname, 'jira-image-auth.ts'));
const makeRequest = (overrides = {}) => ({
requestId: 'request-1',
url: 'https://jira.example.com/rest/api/latest/myself',
requestInit: {
method: 'GET',
headers: {
authorization: 'Basic secret',
'Content-Type': 'application/json',
},
},
allowSelfSignedCertificate: false,
...overrides,
});
test('allows Jira hosted on localhost and applies non-overridable fetch limits', async () => {
let fetchedUrl;
let fetchedInit;
const fetchStub = async (url, init) => {
fetchedUrl = url;
fetchedInit = init;
return {
ok: true,
text: async () => '{"ok":true}',
};
};
const result = await executeJiraRequest(
makeRequest({
url: 'http://127.0.0.1:8080/jira/rest/api/latest/myself',
requestInit: {
method: 'POST',
headers: { authorization: 'Bearer secret' },
body: '{"query":"test"}',
redirect: 'follow',
timeout: 0,
size: 0,
},
}),
fetchStub,
() => undefined,
);
assert.equal(fetchedUrl, 'http://127.0.0.1:8080/jira/rest/api/latest/myself');
assert.deepEqual(
{
method: fetchedInit.method,
headers: fetchedInit.headers,
body: fetchedInit.body,
redirect: fetchedInit.redirect,
timeout: fetchedInit.timeout,
size: fetchedInit.size,
},
{
method: 'POST',
headers: { authorization: 'Bearer secret' },
body: '{"query":"test"}',
redirect: 'manual',
timeout: 20_000,
size: 25 * 1024 * 1024,
},
);
assert.deepEqual(result, {
requestId: 'request-1',
response: { ok: true },
});
});
test('rejects non-HTTP Jira URLs before fetch', async () => {
let fetchCalled = false;
const result = await executeJiraRequest(
makeRequest({ url: 'file:///etc/passwd' }),
async () => {
fetchCalled = true;
throw new Error('must not run');
},
() => undefined,
);
assert.equal(fetchCalled, false);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Jira URL must use HTTP or HTTPS' },
});
});
test('rejects methods outside the Jira API contract', async () => {
let fetchCalled = false;
const result = await executeJiraRequest(
makeRequest({ requestInit: { method: 'DELETE', headers: {} } }),
async () => {
fetchCalled = true;
throw new Error('must not run');
},
() => undefined,
);
assert.equal(fetchCalled, false);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Invalid Jira request method' },
});
});
test('returns an HTTP error without copying the remote response body', async () => {
let responseBodyDestroyed = false;
const result = await executeJiraRequest(
makeRequest(),
async () => ({
ok: false,
status: 401,
statusText: 'Unauthorized',
body: { destroy: () => (responseBodyDestroyed = true) },
text: async () => 'Access denied',
}),
() => undefined,
);
assert.deepEqual(result, {
requestId: 'request-1',
error: {
message: 'HTTP 401',
status: 401,
},
});
assert.equal(responseBodyDestroyed, true);
assert.equal('stack' in result.error, false);
});
test('treats a missing legacy certificate setting as false', async () => {
let allowSelfSignedCertificate;
const request = makeRequest();
delete request.allowSelfSignedCertificate;
const result = await executeJiraRequest(
request,
async () => ({
ok: true,
text: async () => '{"ok":true}',
}),
(_url, allowSelfSigned) => {
allowSelfSignedCertificate = allowSelfSigned;
return undefined;
},
);
assert.equal(allowSelfSignedCertificate, false);
assert.deepEqual(result.response, { ok: true });
});
test('follows a same-origin Jira redirect', async () => {
const fetchedUrls = [];
let redirectBodyDestroyed = false;
const result = await executeJiraRequest(
makeRequest(),
async (url) => {
fetchedUrls.push(url);
if (fetchedUrls.length === 1) {
return {
ok: false,
status: 302,
statusText: 'Found',
body: { destroy: () => (redirectBodyDestroyed = true) },
headers: { get: (name) => (name === 'location' ? '/jira/login' : null) },
text: async () => '',
};
}
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => '{"redirected":true}',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'https://jira.example.com/rest/api/latest/myself',
'https://jira.example.com/jira/login',
]);
assert.deepEqual(result.response, { redirected: true });
assert.equal(redirectBodyDestroyed, true);
});
test('allows an HTTP to HTTPS redirect on the configured Jira hostname', async () => {
const fetchedUrls = [];
const result = await executeJiraRequest(
makeRequest({ url: 'http://jira.example.com:8080/rest/api/latest/myself' }),
async (url) => {
fetchedUrls.push(url);
if (fetchedUrls.length === 1) {
return {
ok: false,
status: 308,
statusText: 'Permanent Redirect',
headers: {
get: (name) =>
name === 'location'
? 'https://jira.example.com:8443/rest/api/latest/myself'
: null,
},
text: async () => '',
};
}
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => '{"upgraded":true}',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'http://jira.example.com:8080/rest/api/latest/myself',
'https://jira.example.com:8443/rest/api/latest/myself',
]);
assert.deepEqual(result.response, { upgraded: true });
});
test('rejects redirects to a different hostname', async () => {
const fetchedUrls = [];
const result = await executeJiraRequest(
makeRequest(),
async (url) => {
fetchedUrls.push(url);
return {
ok: false,
status: 302,
statusText: 'Found',
headers: {
get: (name) =>
name === 'location' ? 'https://internal.example.test/secret' : null,
},
text: async () => '',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'https://jira.example.com/rest/api/latest/myself',
]);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Unsafe Jira redirect blocked' },
});
});
test('stops after five safe Jira redirects', async () => {
let fetchCalls = 0;
const result = await executeJiraRequest(
makeRequest(),
async () => {
fetchCalls += 1;
return {
ok: false,
status: 302,
statusText: 'Found',
headers: { get: () => `/redirect-${fetchCalls}` },
text: async () => '',
};
},
() => undefined,
);
assert.equal(fetchCalls, 6);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Too many Jira redirects' },
});
});
test('does not expose thrown error details beyond the message', async () => {
const thrown = Object.assign(new Error('network failed'), {
secret: 'do not return',
});
const result = await executeJiraRequest(
makeRequest(),
async () => {
throw thrown;
},
() => undefined,
);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'network failed' },
});
});
test('scopes image authentication to a custom Jira origin with port and base path', () => {
setupRequestHeadersForImages({
host: 'http://localhost:8080/jira',
userName: 'user',
password: 'pass',
usePAT: false,
});
const matchingHeaders = { accept: 'image/png' };
applyJiraImageAuth(
'http://localhost:8080/jira/secure/attachment/1/image.png',
matchingHeaders,
'image',
);
assert.deepEqual(matchingHeaders, {
accept: 'image/png',
authorization: `Basic ${Buffer.from('user:pass').toString('base64')}`,
});
const outsidePathHeaders = {};
applyJiraImageAuth(
'http://localhost:8080/other/image.png',
outsidePathHeaders,
'image',
);
assert.deepEqual(outsidePathHeaders, {});
const prefixCollisionHeaders = {};
applyJiraImageAuth(
'http://localhost:8080/jira-evil/image.png',
prefixCollisionHeaders,
'image',
);
assert.deepEqual(prefixCollisionHeaders, {});
const xhrHeaders = {};
applyJiraImageAuth('http://localhost:8080/jira/rest/api/latest/issue/1', xhrHeaders, 'xhr');
assert.deepEqual(xhrHeaders, {});
});
test('treats a missing legacy PAT setting as basic authentication', () => {
setupRequestHeadersForImages({
host: 'https://jira.example.com/jira/',
userName: 'user',
password: 'pass',
});
const requestHeaders = {};
applyJiraImageAuth(
'https://jira.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.equal(
requestHeaders.authorization,
`Basic ${Buffer.from('user:pass').toString('base64')}`,
);
});
test('clears Jira image authentication when it is no longer needed', () => {
clearRequestHeadersForImages();
const requestHeaders = {};
applyJiraImageAuth(
'https://jira.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.deepEqual(requestHeaders, {});
});
test('clears previous image authentication when replacement config is invalid', () => {
setupRequestHeadersForImages({
host: 'https://jira-a.example.com/jira',
userName: 'user',
password: 'pass',
usePAT: false,
});
assert.throws(
() =>
setupRequestHeadersForImages({
host: null,
userName: 'other-user',
password: 'other-pass',
usePAT: false,
}),
/Invalid Jira image authentication config/,
);
const requestHeaders = {};
applyJiraImageAuth(
'https://jira-a.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.deepEqual(requestHeaders, {});
});
test('rejects a non-HTTP Jira image authentication origin', () => {
assert.throws(
() =>
setupRequestHeadersForImages({
host: 'file:///tmp/jira',
userName: 'user',
password: 'pass',
usePAT: false,
}),
/HTTP or HTTPS/,
);
});