Upload an OPML file to import; tests for every way in and out
- The import screen has a file picker beside the paste box. The page reads the file, checks it looks like OPML before sending, and clears the picker when it is refused and after it is imported. The file is sent as text and never written to disk on the server. - The server parses the OPML before touching anything and answers 400 "that is not an OPML file" (was a 500). subscribe_opml takes a parsed document, so ipx import also refuses a non-OPML file by name. - Tests: Settings' Export OPML download and paste import; uploading an RSS file (refused) and a real OPML; the server's 400; the admin's export round-tripped into a second account; ipx import/export in a scratch config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
@@ -82,6 +82,7 @@ const drive = [
|
||||
['removeFeed', () => ctx.removeFeed(feed)],
|
||||
['prefsModal', () => ctx.prefsModal()],
|
||||
['usersModal', () => ctx.usersModal()],
|
||||
['opmlModal', () => ctx.opmlModal()],
|
||||
['logsModal', () => ctx.logsModal()],
|
||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||
// of a vm context the way var and function declarations do.
|
||||
|
||||
@@ -413,9 +413,67 @@ test('the only admin cannot be demoted or removed', async ({ page }) => {
|
||||
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
||||
});
|
||||
|
||||
test('importing an OPML subscribes you, and export lists only your feeds', async ({ browser }) => {
|
||||
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so a feed
|
||||
// someone else already had imported as nothing at all.
|
||||
test('Settings exports your OPML and imports a pasted one', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
const [dl] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('#modalCard a', { hasText: 'Export OPML' }).click(),
|
||||
]);
|
||||
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
|
||||
const out = require('fs').readFileSync(await dl.path(), 'utf8');
|
||||
for (const f of ['show.xml', 'pics.xml', 'multi.xml', 'subs.opml']) expect(out).toContain(f);
|
||||
// A feed from an OPML subscription comes back with the OPML itself, not on its own.
|
||||
expect(out).not.toContain('other.xml');
|
||||
|
||||
// One feed new to everyone, one the admin already has.
|
||||
await page.locator('#gopml').click();
|
||||
await page.locator('#opmlText').fill('<opml version="2.0"><head><title>t</title></head><body>' +
|
||||
'<outline text="Imported Show" xmlUrl="http://127.0.0.1:8792/imported.xml"/>' +
|
||||
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>');
|
||||
await page.locator('#oimp').click();
|
||||
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
|
||||
.toHaveText('Subscribed to 1 feed(s), 1 you already had');
|
||||
// Named from the OPML's id until the first scan reads the feed's own title.
|
||||
await expect(page.locator('#feedlist .feed', { hasText: /Imported Show|imported-show/ }))
|
||||
.toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test('an uploaded OPML file imports, and a file that is not OPML is refused', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
await page.locator('#gopml').click();
|
||||
const pick = page.locator('#opmlFile');
|
||||
|
||||
// An RSS feed is XML but not OPML: refused in the page, and the picker lets go of it.
|
||||
await pick.setInputFiles({
|
||||
name: 'feed.xml', mimeType: 'application/xml',
|
||||
buffer: require('fs').readFileSync(require('path').join(__dirname, 'fixtures', 'show.xml')),
|
||||
});
|
||||
await page.locator('#oimp').click();
|
||||
await expect(page.locator('.toast.bad', { hasText: 'feed.xml is not an OPML file' })).toBeVisible();
|
||||
expect(await pick.evaluate(i => i.files.length)).toBe(0);
|
||||
|
||||
// Something that gets past the page's quick look is still refused by the server, untouched.
|
||||
const sneaky = await page.request.post('/api/opml', {
|
||||
data: { xml: '<rss version="2.0"><channel><title><opml></title></channel></rss>' },
|
||||
});
|
||||
expect(sneaky.status()).toBe(400);
|
||||
expect(await sneaky.text()).toContain('not an OPML file');
|
||||
|
||||
// A real one. Multi Show is already the admin's, so it counts as already had.
|
||||
await pick.setInputFiles({
|
||||
name: 'subs.opml', mimeType: 'text/x-opml',
|
||||
buffer: Buffer.from('<opml version="2.0"><head><title>t</title></head><body>' +
|
||||
'<outline text="Multi Show" xmlUrl="http://127.0.0.1:8792/multi.xml"/></body></opml>'),
|
||||
});
|
||||
await page.locator('#oimp').click();
|
||||
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
|
||||
.toHaveText('Subscribed to 0 feed(s), 1 you already had');
|
||||
await expect(page.locator('#modal.on')).toBeHidden();
|
||||
});
|
||||
|
||||
test('an export from one account imports into another', async ({ page, browser }) => {
|
||||
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so importing
|
||||
// the admin's export into a second account did nothing at all.
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const env = {
|
||||
@@ -429,26 +487,60 @@ test('importing an OPML subscribes you, and export lists only your feeds', async
|
||||
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
||||
}
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login');
|
||||
await page.locator('#name').fill('opal');
|
||||
await page.locator('#pw').fill('opalpassword');
|
||||
await page.locator('button[type=submit]').click();
|
||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||
const opal = await ctx.newPage();
|
||||
await opal.goto('/login');
|
||||
await opal.locator('#name').fill('opal');
|
||||
await opal.locator('#pw').fill('opalpassword');
|
||||
await opal.locator('button[type=submit]').click();
|
||||
await expect(opal.locator('#feedlist')).toContainText('No feeds.');
|
||||
|
||||
// Test Show is already in the catalogue, because the admin reads it.
|
||||
const xml = '<opml version="2.0"><head><title>t</title></head><body>' +
|
||||
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>';
|
||||
expect(await (await page.request.post('/api/opml', { data: { xml } })).json())
|
||||
.toEqual({ added: 1, already: 0 });
|
||||
expect(await (await page.request.post('/api/opml', { data: { xml } })).json())
|
||||
.toEqual({ added: 0, already: 1 });
|
||||
await page.reload();
|
||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||
// Export used to hand anyone the whole catalogue. Opal has nothing yet, so gets nothing.
|
||||
const empty = await opal.request.get('/api/opml');
|
||||
expect(empty.status()).toBe(200);
|
||||
expect(await empty.text()).not.toContain('xmlUrl');
|
||||
|
||||
// The admin also reads Picture Blog; that is not opal's to export.
|
||||
const out = await (await page.request.get('/api/opml')).text();
|
||||
expect(out).toContain('show.xml');
|
||||
expect(out).not.toContain('pics.xml');
|
||||
const urlsIn = xml => [...xml.matchAll(/xmlUrl="([^"]+)"/g)].map(m => m[1]).sort();
|
||||
const exported = await (await page.request.get('/api/opml')).text(); // the admin's
|
||||
const urls = urlsIn(exported);
|
||||
expect(urls.length).toBeGreaterThan(2);
|
||||
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
|
||||
.toEqual({ added: urls.length, already: 0 });
|
||||
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
|
||||
.toEqual({ added: 0, already: urls.length });
|
||||
|
||||
await opal.reload();
|
||||
await expect(opal.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||
// The round trip closes: opal's own export now lists what the admin's did.
|
||||
expect(urlsIn(await (await opal.request.get('/api/opml')).text())).toEqual(urls);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => {
|
||||
// Its own config and database. The CLI works in-process, and the suite's running daemon
|
||||
// reads config.toml once at start, so it would not see what the CLI added anyway.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const dir = path.join(setup.root, 'cli');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'config.toml'),
|
||||
`[general]\ndownload_dir = "${dir}/downloads"\nsocket = "${dir}/ipx.sock"\n`);
|
||||
const env = { ...process.env, IPX_CONFIG: path.join(dir, 'config.toml'), IPX_DATA_DIR: path.join(dir, 'data') };
|
||||
const ipx = (args, input) => execFileSync('./target/debug/ipx', args, { env, input, encoding: 'utf8' });
|
||||
|
||||
ipx(['user', 'add', 'boss'], 'bosspassword'); // the first account is the admin
|
||||
const opml = path.join(dir, 'in.opml');
|
||||
fs.writeFileSync(opml, '<opml version="2.0"><head><title>t</title></head><body>' +
|
||||
'<outline text="One" xmlUrl="http://127.0.0.1:8792/one.xml"/>' +
|
||||
'<outline text="Two" xmlUrl="http://127.0.0.1:8792/two.xml"/></body></opml>');
|
||||
expect(ipx(['import', opml])).toContain('subscribed boss to 2 feed(s); 0 already there');
|
||||
expect(ipx(['import', opml])).toContain('subscribed boss to 0 feed(s); 2 already there');
|
||||
|
||||
const out = path.join(dir, 'out.opml');
|
||||
ipx(['export', out]);
|
||||
const xml = fs.readFileSync(out, 'utf8');
|
||||
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
|
||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||
});
|
||||
|
||||
5
tests/ui/fixtures/imported.xml
Normal file
5
tests/ui/fixtures/imported.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Imported Show</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Only ever arrives through an OPML import.</description>
|
||||
<item><title>Imported Ep</title><guid>imp-1</guid><description>x</description></item>
|
||||
</channel></rss>
|
||||
Reference in New Issue
Block a user