312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { JSDOM } = require('jsdom');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
|
|
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
|
|
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
|
|
|
|
function loadVault() {
|
|
const window = { location: { origin: 'https://status.sami' } };
|
|
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
|
|
vm.runInContext(handoffSource, context);
|
|
return window.DCCredentialVault;
|
|
}
|
|
|
|
async function exerciseFailedModalWrite({
|
|
service,
|
|
fetchJson,
|
|
setupInputs,
|
|
expectedEndpoint,
|
|
writeResponse,
|
|
expectedError = /vault write rejected/,
|
|
}) {
|
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
|
url: 'https://status.sami/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const { window } = dom;
|
|
const writeUrls = [];
|
|
let resumeCalls = 0;
|
|
window.ErrorHandler = class { logError() {} };
|
|
window.SITE = { tld: '.sami' };
|
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
|
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
|
|
window.secureFetch = async (url) => {
|
|
writeUrls.push(url);
|
|
return writeResponse || {
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
|
};
|
|
};
|
|
window.DCCredentialVault = {
|
|
isAllowedReturnUrl: () => true,
|
|
resume: async () => { resumeCalls++; return true; },
|
|
};
|
|
window.confirm = () => true;
|
|
window.eval(formSource);
|
|
|
|
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
|
|
setupInputs(window.document);
|
|
window.document.getElementById('svc-creds-save').click();
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
assert.equal(writeUrls[0], expectedEndpoint);
|
|
assert.equal(resumeCalls, 0);
|
|
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
|
|
}
|
|
|
|
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
|
|
const vault = loadVault();
|
|
const calls = [];
|
|
const replacements = [];
|
|
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
|
|
fetch: async (url, options) => {
|
|
calls.push({ url, options });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
|
|
};
|
|
},
|
|
location: { replace: (target) => replacements.push(target) },
|
|
});
|
|
|
|
assert.equal(resumed, true);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
|
assert.equal(calls[0].options.credentials, 'include');
|
|
assert.equal(calls[0].options.cache, 'no-store');
|
|
assert.equal(
|
|
replacements[0],
|
|
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
|
|
);
|
|
});
|
|
|
|
test('vault handoff rejects an external return URL before minting a token', async () => {
|
|
const vault = loadVault();
|
|
let fetchCalled = false;
|
|
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
|
|
fetch: async () => { fetchCalled = true; },
|
|
location: { replace: () => assert.fail('must not navigate') },
|
|
});
|
|
|
|
assert.equal(resumed, false);
|
|
assert.equal(fetchCalled, false);
|
|
});
|
|
|
|
test('credential request opens the form and save path calls the tested handoff helper', () => {
|
|
assert.match(formSource, /params\.get\('credentials'\)/);
|
|
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
|
|
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
|
|
assert.match(initSource, /openRequestedCredentialForm\(\)/);
|
|
assert.match(formSource, /Save to vault & open service/);
|
|
});
|
|
|
|
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
|
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
|
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const { window } = dom;
|
|
let stored = false;
|
|
const writes = [];
|
|
const resumed = [];
|
|
window.ErrorHandler = class { logError() {} };
|
|
window.SITE = { tld: '.sami' };
|
|
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
|
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
|
window.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
success: true,
|
|
hasApiKey: false,
|
|
hasBasicAuth: stored,
|
|
username: stored ? 'vault-user' : null,
|
|
}),
|
|
});
|
|
window.secureFetch = async (url, options) => {
|
|
writes.push({ url, body: JSON.parse(options.body) });
|
|
stored = true;
|
|
return { ok: true, json: async () => ({ success: true }) };
|
|
};
|
|
window.DCCredentialVault = {
|
|
isAllowedReturnUrl: () => true,
|
|
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
|
|
};
|
|
window.confirm = () => true;
|
|
window.eval(formSource);
|
|
|
|
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
|
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
|
window.document.getElementById('svc-creds-save').click();
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
assert.deepEqual(writes, [{
|
|
url: '/api/v1/services/plex/credentials',
|
|
body: { username: 'vault-user', password: 'vault-password' },
|
|
}]);
|
|
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
|
|
});
|
|
|
|
test('failed credential write does not mint a handoff or navigate', async () => {
|
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
|
url: 'https://status.sami/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const { window } = dom;
|
|
let resumeCalls = 0;
|
|
window.ErrorHandler = class { logError() {} };
|
|
window.SITE = { tld: '.sami' };
|
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
|
window.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
|
});
|
|
window.secureFetch = async () => ({
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
|
});
|
|
window.DCCredentialVault = {
|
|
isAllowedReturnUrl: () => true,
|
|
resume: async () => { resumeCalls++; return true; },
|
|
};
|
|
window.confirm = () => true;
|
|
window.eval(formSource);
|
|
|
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
|
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
|
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
|
window.document.getElementById('svc-creds-save').click();
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
assert.equal(resumeCalls, 0);
|
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
|
|
});
|
|
|
|
test('failed ARR credential write does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
|
fetchJson: (url) => url.includes('/services/')
|
|
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
|
|
: { success: true, profiles: [] },
|
|
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
|
|
expectedEndpoint: '/api/v1/arr/credentials',
|
|
});
|
|
});
|
|
|
|
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
|
fetchJson: (url) => url.includes('/services/')
|
|
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
|
|
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
|
|
setupInputs: () => {},
|
|
expectedEndpoint: '/api/v1/arr/quality-profiles',
|
|
});
|
|
});
|
|
|
|
test('failed seedhost write does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
|
|
fetchJson: (url) => url.includes('/seedhost-creds')
|
|
? { success: true, hasCredentials: false, username: null }
|
|
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
|
|
setupInputs: (document) => {
|
|
document.getElementById('svc-seedhost-user').value = 'seed-user';
|
|
document.getElementById('svc-seedhost-pass').value = 'seed-password';
|
|
},
|
|
expectedEndpoint: '/api/v1/seedhost-creds',
|
|
});
|
|
});
|
|
|
|
test('failed generic API-key write does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
|
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
|
setupInputs: (document) => {
|
|
document.getElementById('svc-apikey-input').value = 'custom-key';
|
|
document.getElementById('svc-basic-user').value = 'user';
|
|
document.getElementById('svc-basic-pass').value = 'password';
|
|
},
|
|
expectedEndpoint: '/api/v1/services/custom/credentials',
|
|
});
|
|
});
|
|
|
|
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
|
setupInputs: (document) => {
|
|
document.getElementById('svc-basic-user').value = 'user';
|
|
document.getElementById('svc-basic-pass').value = 'password';
|
|
},
|
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
|
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
|
|
expectedError: /invalid server response/,
|
|
});
|
|
});
|
|
|
|
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
|
|
await exerciseFailedModalWrite({
|
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
|
setupInputs: (document) => {
|
|
document.getElementById('svc-basic-user').value = 'user';
|
|
document.getElementById('svc-basic-pass').value = 'password';
|
|
},
|
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
|
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
|
|
expectedError: /failed \(200\)/,
|
|
});
|
|
});
|
|
|
|
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
|
|
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
|
|
url: 'https://status.sami/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const { window } = dom;
|
|
window.ErrorHandler = class { logError() {} };
|
|
window.SITE = { tld: '.sami' };
|
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
|
window.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
|
|
});
|
|
window.secureFetch = async () => ({
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ success: false, error: 'clear rejected' }),
|
|
});
|
|
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
|
|
window.confirm = () => true;
|
|
window.eval(formSource);
|
|
|
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
|
await window.openServiceCredsModal(service);
|
|
window.document.getElementById('svc-creds-clear').click();
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
|
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
|
|
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
|
|
});
|
|
|
|
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
|
|
const vault = loadVault();
|
|
let fetchCalled = false;
|
|
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
|
|
fetch: async () => { fetchCalled = true; },
|
|
location: { replace: () => assert.fail('must not navigate') },
|
|
});
|
|
|
|
assert.equal(resumed, false);
|
|
assert.equal(fetchCalled, false);
|
|
});
|