Compare commits
6 Commits
b459b99fe9
...
chore/ui-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 |
@@ -325,9 +325,11 @@ async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: delete the default app (AppAdmin).
|
// Allowed: delete the default app (AppAdmin). ?force=true because
|
||||||
|
// the script we created above pushes us past the soft no-cascade
|
||||||
|
// guard — this test is about the capability, not the cascade.
|
||||||
s.server
|
s.server
|
||||||
.delete("/api/v1/admin/apps/default")
|
.delete("/api/v1/admin/apps/default?force=true")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|||||||
@@ -3,28 +3,40 @@ import { adminApi } from './api';
|
|||||||
|
|
||||||
// Resources to delete after a test, in LIFO order. Tests register
|
// Resources to delete after a test, in LIFO order. Tests register
|
||||||
// their creations and the registry tears everything down in
|
// their creations and the registry tears everything down in
|
||||||
// `cleanupRegistered` — typically called from `test.afterEach`.
|
// `run()` — typically called from `test.afterEach`.
|
||||||
|
//
|
||||||
|
// A non-2xx status (other than 404) is treated as a real failure and
|
||||||
|
// logged to stderr. The previous shape silently swallowed every
|
||||||
|
// error, so a backend that started returning 500 on cleanup would
|
||||||
|
// have leaked orphans invisibly across runs. 404 stays tolerated —
|
||||||
|
// the test may have already deleted the resource itself.
|
||||||
|
|
||||||
type Cleanup = (api: APIRequestContext) => Promise<void>;
|
interface CleanupItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CleanupRegistry {
|
export class CleanupRegistry {
|
||||||
private items: Cleanup[] = [];
|
private items: CleanupItem[] = [];
|
||||||
|
|
||||||
app(slugOrId: string): void {
|
app(slugOrId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
|
label: `app=${slugOrId}`,
|
||||||
|
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
adminUser(userId: string): void {
|
adminUser(userId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/admins/${userId}`);
|
label: `admin=${userId}`,
|
||||||
|
path: `/api/v1/admin/admins/${userId}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey(keyId: string): void {
|
apiKey(keyId: string): void {
|
||||||
this.items.push(async (api) => {
|
this.items.push({
|
||||||
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
|
label: `key=${keyId}`,
|
||||||
|
path: `/api/v1/admin/api-keys/${keyId}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +44,11 @@ export class CleanupRegistry {
|
|||||||
if (this.items.length === 0) return;
|
if (this.items.length === 0) return;
|
||||||
const api = await adminApi();
|
const api = await adminApi();
|
||||||
try {
|
try {
|
||||||
for (const item of this.items.reverse()) {
|
// Copy-then-reverse so a defensive double-`run()` (or a
|
||||||
try {
|
// caller that inspects the registry after a partial
|
||||||
await item(api);
|
// teardown) doesn't see the items in a re-reversed order.
|
||||||
} catch {
|
for (const item of [...this.items].reverse()) {
|
||||||
// Best-effort cleanup — a missing resource (already
|
await deleteAndReport(api, item);
|
||||||
// deleted by the test) shouldn't fail the suite.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await api.dispose();
|
await api.dispose();
|
||||||
@@ -46,3 +56,22 @@ export class CleanupRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAndReport(
|
||||||
|
api: APIRequestContext,
|
||||||
|
item: CleanupItem
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(item.path);
|
||||||
|
// 2xx and 404 are both "this resource is no longer here" — fine.
|
||||||
|
if (!res.ok() && res.status() !== 404) {
|
||||||
|
console.warn(
|
||||||
|
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network-level failure (request never reached the server,
|
||||||
|
// timeout, etc.). Log so a leak doesn't accumulate silently.
|
||||||
|
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ test('end-to-end: app + domain + script + route via dashboard → invoke via pub
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||||||
page
|
page,
|
||||||
|
uniqueUsername
|
||||||
}) => {
|
}) => {
|
||||||
const name = `e2e-cli-${Date.now()}`;
|
// Worker-aware unique helper instead of Date.now() — keeps two
|
||||||
|
// workers from minting the same name on the same millisecond.
|
||||||
|
const name = uniqueUsername('cli');
|
||||||
|
|
||||||
// 1. Mint the key from /profile and capture the revealed token.
|
// 1. Mint the key from /profile and capture the revealed token.
|
||||||
await page.goto('/admin/profile');
|
await page.goto('/admin/profile');
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ test.describe('B6 instance users', () => {
|
|||||||
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deactivate then reactivate toggles the inactive indicator', async ({
|
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
|
||||||
page,
|
page,
|
||||||
uniqueUsername
|
uniqueUsername
|
||||||
}) => {
|
}) => {
|
||||||
@@ -127,10 +127,25 @@ test.describe('B6 instance users', () => {
|
|||||||
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Deactivate opens the confirm modal.
|
||||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(username);
|
||||||
|
|
||||||
|
// Cancel leaves the user active.
|
||||||
|
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Open again and confirm — user becomes inactive.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
|
||||||
await expect(row).toContainText(/inactive/i);
|
await expect(row).toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Reactivate is still one-click (non-destructive — no modal).
|
||||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
||||||
await expect(row).not.toContainText(/inactive/i);
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PICLOUD_HOST_PORT:-8000}:80"
|
- "${PICLOUD_HOST_PORT:-8000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user