User admin in the web UI, admin-only log, unread-first OPML feeds
- Settings > Manage users: add an account (password, or none for proxy
sign-in), toggle admin, remove. Backed by GET/POST /api/users and
PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
the ipodderx service of the Arcane project content; CLAUDE.md and the
README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
non-admin and the unread ordering (new Aardvark Radio fixture); a unit
test for last_admin; the smoke test drives usersModal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
@@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => {
|
||||
test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Three top-level feeds in the fixture config; the OPML's child is inside a closed folder.
|
||||
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
||||
await expect(page.getByText('Test Show')).toBeVisible();
|
||||
const errors = [];
|
||||
@@ -178,16 +178,50 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Closed by default: the child is not listed until the folder is opened.
|
||||
// Closed by default: the children are not listed until the folder is opened.
|
||||
const before = await page.locator('.feed').count();
|
||||
await chev.click();
|
||||
await expect(page.locator('.feed')).toHaveCount(before + 1);
|
||||
await expect(page.locator('.feed')).toHaveCount(before + 2);
|
||||
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
||||
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
||||
|
||||
// The subscription's own page lists what is inside it.
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow')).toHaveCount(1);
|
||||
await expect(page.locator('.childrow')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
fetch('/api/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||
}));
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
await chev.click();
|
||||
const side = page.locator('#feedlist');
|
||||
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
|
||||
// Opening an item reads it; the toggle in the pane below flips it back.
|
||||
await side.getByText('Aardvark Radio').click();
|
||||
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
|
||||
await aa.click();
|
||||
await expect(aa).toHaveClass(/read/);
|
||||
await side.getByText('Grouped Show').click();
|
||||
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
||||
await gs.click();
|
||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||
await expect(gs).not.toHaveClass(/read/);
|
||||
|
||||
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
||||
// Grouped Show above it. The folder stays open across the reload (localStorage).
|
||||
await page.reload();
|
||||
const want = ['Grouped Show', 'Aardvark Radio'];
|
||||
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||
await expect(page.locator('.childrow b')).toHaveText(want);
|
||||
});
|
||||
|
||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||
@@ -283,6 +317,10 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
||||
// Hiding the button is not the guard; the server is.
|
||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||
await expect(page.locator('#logs')).toBeHidden();
|
||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||
|
||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||
// files, but Sam's own read state.
|
||||
@@ -333,3 +371,44 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||
const userRow = (page, name) =>
|
||||
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||
|
||||
async function openUsers(page) {
|
||||
await page.locator('#prefs').click();
|
||||
await page.locator('#gusers').click();
|
||||
await expect(userRow(page, 'admin')).toBeVisible();
|
||||
}
|
||||
|
||||
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
|
||||
await openUsers(page);
|
||||
await page.locator('#uname').fill('pat');
|
||||
await page.locator('#upass').fill('patpassword');
|
||||
await page.locator('#uadd').click();
|
||||
const row = userRow(page, 'pat');
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
|
||||
|
||||
await row.locator('[data-a="admin"]').check();
|
||||
// Not just the box: it has to have reached the database.
|
||||
await expect.poll(async () =>
|
||||
(await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin
|
||||
).toBe(true);
|
||||
|
||||
page.once('dialog', d => d.accept());
|
||||
await row.locator('[data-a="rm"]').click();
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the only admin cannot be demoted or removed', async ({ page }) => {
|
||||
await openUsers(page);
|
||||
await userRow(page, 'admin').locator('[data-a="admin"]').click();
|
||||
await expect(page.locator('.toast.bad')).toContainText('only admin');
|
||||
// Redrawn from the server, so the box is back.
|
||||
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
|
||||
|
||||
const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin');
|
||||
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
||||
});
|
||||
|
||||
5
tests/ui/fixtures/aardvark.xml
Normal file
5
tests/ui/fixtures/aardvark.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Aardvark Radio</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Inside the OPML, and first in it and alphabetically.</description>
|
||||
<item><title>Aardvark Ep</title><guid>aa-1</guid><description>x</description></item>
|
||||
</channel></rss>
|
||||
@@ -1,4 +1,5 @@
|
||||
<opml version="2.0"><head><title>Test Subscriptions</title></head>
|
||||
<body><outline text="Folder">
|
||||
<outline type="rss" text="Aardvark Radio" xmlUrl="http://127.0.0.1:8792/aardvark.xml"/>
|
||||
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
|
||||
</outline></body></opml>
|
||||
</outline></body></opml>
|
||||
|
||||
Reference in New Issue
Block a user