Files
webmail/lib/__tests__/connector-registry.test.ts
T
Linus Rath 10b4d2a9db feat: rework the admin panel around a settings-style sidebar and one URL per page
The old panel kept its active tab in a store, so every page sat at
/admin, the sidebar duplicated the webmail rail by hand, and each page
was a stack of boxed cards with their own save buttons. Plugin settings
were only reachable through a gear icon in a row of five.

- one catch-all route for the whole panel; pages switch through
  history.pushState, so `/admin/plugins/<id>`, the back button, bookmarks
  and connector links all work without a route transition
- sidebar laid out like the user settings: back to mail, search over
  pages and their sections, two group headings, change password and
  sign out at the bottom; the phone shows a list, then the page
- pages use SettingsSection rows instead of nested cards, with one page
  header, one flash message and one save bar at the bottom (Discard,
  Save, Ctrl+S); the unsaved-changes prompt still guards leaving
- plugins and themes are lists of rows with a toggle each; a row opens
  the extension's own page with its settings, force-on, permissions,
  kept versions and removal
- overview shows stat tiles, server facts and recent activity
- old `/admin?tab=x` links redirect; the tab store is gone and the
  connector registry resolves admin links to the page URL
- `NEXT_TURBOPACK_ROOT` overrides the Turbopack root so `next dev` runs
  from a worktree with a junctioned node_modules
2026-10-20 15:16:05 +02:00

257 lines
9.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TARGETS, TARGET_NAMES, getTarget, resolveTarget } from '@/lib/connector/registry';
import { safeAdminNext } from '@/lib/connector/admin-next';
const resolve = (name: string, search: Record<string, string> = {}) =>
resolveTarget(name, search);
const path = (name: string, search: Record<string, string> = {}) => {
const result = resolve(name, search);
if (result.kind !== 'ok') throw new Error(`${name} did not resolve: ${result.kind}`);
return result.resolution.path;
};
describe('the registry', () => {
it('has unique names that are safe as one path segment', () => {
expect(new Set(TARGET_NAMES).size).toBe(TARGET_NAMES.length);
for (const name of TARGET_NAMES) expect(name).toMatch(/^[a-z][a-z0-9_]*$/);
});
it('labels every target for the "unknown link" card', () => {
for (const target of TARGETS) expect(target.label.length).toBeGreaterThan(0);
});
it('does not inherit names from Object.prototype', () => {
for (const name of ['constructor', '__proto__', 'toString']) {
expect(getTarget(name)).toBeNull();
expect(resolve(name).kind).toBe('unknown-target');
}
});
it('reports an unknown target rather than throwing or 404ing', () => {
expect(resolve('plugin_sandbox_settings').kind).toBe('unknown-target');
expect(resolveTarget(null, {}).kind).toBe('unknown-target');
expect(resolveTarget('', {}).kind).toBe('unknown-target');
});
});
describe('resolving to app paths', () => {
it('resolves the bare surfaces', () => {
expect(path('app')).toBe('/mail');
expect(path('contacts')).toBe('/contacts');
expect(path('settings')).toBe('/settings');
expect(path('setup')).toBe('/setup');
});
it('resolves a settings tab', () => {
expect(path('settings', { tab: 'filters' })).toBe('/settings/filters');
});
it('resolves a calendar view and date', () => {
expect(path('calendar')).toBe('/calendar/month');
expect(path('calendar', { view: 'week' })).toBe('/calendar/week');
expect(path('calendar', { view: 'day', date: '2026-09-20' })).toBe('/calendar/day/2026-09-20');
});
it('resolves a mail folder by alias and by opaque id', () => {
expect(path('mail_folder', { ref: 'inbox' })).toBe('/mail/folder/inbox');
expect(path('mail_folder', { ref: 'a1b2c3' })).toBe('/mail/folder/a1b2c3');
expect(path('mail_folder')).toBe('/mail');
});
it('resolves a drive path', () => {
expect(path('files', { path: 'Documents/Invoices' })).toBe('/files/Documents/Invoices');
expect(path('files')).toBe('/files');
});
it('marks the admin targets and resolves a tab to that page\'s URL', () => {
const admin = resolve('admin', { tab: 'policy' });
expect(admin.kind).toBe('ok');
if (admin.kind === 'ok') {
expect(admin.resolution).toEqual({ path: '/admin/policy', admin: true });
}
const auth = resolve('admin_auth');
if (auth.kind === 'ok') expect(auth.resolution).toEqual({ path: '/admin/auth', admin: true });
const ext = resolve('admin_extension', { slug: 'quick-reply' });
if (ext.kind === 'ok') {
expect(ext.resolution).toEqual({ path: '/admin/marketplace/quick-reply', admin: true });
}
});
it('carries the version an install link asked for into the marketplace page', () => {
expect(path('admin_extension', { slug: 'quick-reply', version: '1.2.0' })).toBe(
'/admin/marketplace/quick-reply?version=1.2.0',
);
expect(path('admin_extension', { slug: 'quick-reply', version: '2.0.0-beta.1' })).toBe(
'/admin/marketplace/quick-reply?version=2.0.0-beta.1',
);
});
it('survives the admin login round-trip as a whole', () => {
// The admin layout parks the resolved path in ?next=; the gate there must
// let exactly what the registry produces through, or a versioned install
// link would drop its version (or the whole path) on the way.
const versioned = path('admin_extension', { slug: 'quick-reply', version: '1.2.0' });
expect(safeAdminNext(versioned)).toBe(versioned);
expect(safeAdminNext(encodeURIComponent(versioned))).toBe(versioned);
});
it('opens the panel front page when the link named no tab', () => {
const admin = resolve('admin');
expect(admin.kind).toBe('ok');
if (admin.kind === 'ok') expect(admin.resolution).toEqual({ path: '/admin', admin: true });
});
it('leaves the user targets unmarked, so they take the normal login path', () => {
for (const name of ['app', 'settings', 'calendar', 'contacts', 'files', 'mail_folder']) {
const result = resolve(name);
if (result.kind === 'ok') expect(result.resolution.admin).toBeUndefined();
}
});
});
describe('parameter validation', () => {
it('refuses a settings tab that is not a real tab', () => {
const result = resolve('settings', { tab: 'root' });
expect(result.kind).toBe('bad-param');
if (result.kind === 'bad-param') expect(result.param).toBe('tab');
});
it('requires a required parameter', () => {
expect(resolve('admin_extension').kind).toBe('bad-param');
expect(resolve('admin_extension', { slug: '' }).kind).toBe('bad-param');
});
it.each([
'../../etc/passwd',
'//evil.example',
'Quick-Reply',
'quick reply',
'javascript:alert(1)',
'a'.repeat(65),
])('refuses the extension slug %j', (slug) => {
expect(resolve('admin_extension', { slug }).kind).toBe('bad-param');
});
it.each(['../admin', 'a/b', 'a\\b', 'a?b', 'a#b', '.', '..'])(
'refuses the mailbox ref %j',
(ref) => {
expect(resolve('mail_folder', { ref }).kind).toBe('bad-param');
},
);
it.each(['1.2', 'v1.2.3', '1.2.3+build.5', 'latest', '1.2.3/..', '1.2.3?x', '1.2.3-'])(
'refuses the extension version %j',
(version) => {
const result = resolve('admin_extension', { slug: 'quick-reply', version });
expect(result.kind).toBe('bad-param');
if (result.kind === 'bad-param') expect(result.param).toBe('version');
},
);
it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])(
'refuses the date %j',
(date) => {
expect(resolve('calendar', { date }).kind).toBe('bad-param');
},
);
it.each(['../secrets', 'Documents/../../etc', 'a?b'])('refuses the drive path %j', (p) => {
expect(resolve('files', { path: p }).kind).toBe('bad-param');
});
it('refuses an over-long value whatever its shape', () => {
expect(resolve('mail_folder', { ref: 'a'.repeat(513) }).kind).toBe('bad-param');
});
it('drops a parameter the target does not declare, rather than forwarding it', () => {
// A connector newer than this instance may carry something extra; the link
// should still open the surface it named.
expect(path('settings', { tab: 'filters', redirect: 'https://evil.example' })).toBe(
'/settings/filters',
);
});
it('never lets a parameter escape its path segment', () => {
for (const hostile of ['../admin', '..%2Fadmin', 'a/../../b']) {
const result = resolve('mail_folder', { ref: hostile });
if (result.kind === 'ok') {
// If it was accepted at all, it must still be a single segment.
const tail = result.resolution.path.replace('/mail/folder/', '');
expect(tail).not.toContain('/');
expect(decodeURIComponent(tail)).not.toContain('..');
}
}
});
});
describe('safeAdminNext', () => {
it.each([
['/admin', '/admin'],
['/admin/', '/admin/'],
['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'],
['/admin?tab=policy', '/admin?tab=policy'],
['/admin/marketplace/quick-reply?version=1.2.0', '/admin/marketplace/quick-reply?version=1.2.0'],
['/admin/marketplace/quick-reply?version=2.0.0-beta.1', '/admin/marketplace/quick-reply?version=2.0.0-beta.1'],
['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'],
])('accepts %j', (raw, expected) => {
expect(safeAdminNext(raw)).toBe(expected);
});
it.each([
null,
undefined,
'',
'/mail',
'/settings/filters',
'//evil.example',
'/\\evil.example',
'https://evil.example',
'javascript:alert(1)',
'/admin/../mail',
'/adminfoo',
'/admin?tab=policy&x=1',
'/admin/marketplace/x?version=1.2.0&tab=policy',
'/admin/marketplace/x?version=../../etc',
'/admin/marketplace/x?version=' + '1'.repeat(70),
'/admin?next=https://evil.example',
'%2f%2fevil.example',
'/admin/' + 'a'.repeat(600),
])('refuses %j', (raw) => {
expect(safeAdminNext(raw as string | null)).toBeNull();
});
});
describe('parity with the connector', () => {
// The connector (github.com/bulwarkmail/connector) owns the names and the
// parameter schemas; this side owns the paths. When both checkouts are
// present, the names must match exactly - a target this side cannot resolve
// would be a published link that 404s, and one the connector never emits is
// dead code here.
const connectorRegistry = join(process.cwd(), 'repos', 'connector', 'src', 'lib', 'registry.ts');
it.skipIf(!existsSync(connectorRegistry))('names the same targets', () => {
const source = readFileSync(connectorRegistry, 'utf8');
const theirs = [...source.matchAll(/^\s{4}name: "([a-z0-9_]+)",$/gm)].map((m) => m[1]);
expect(theirs.length).toBeGreaterThan(0);
expect([...theirs].sort()).toEqual([...TARGET_NAMES].sort());
});
it.skipIf(!existsSync(connectorRegistry))(
'never names a target after a path the connector site owns',
() => {
const source = readFileSync(connectorRegistry, 'utf8');
const reserved = /RESERVED_PATHS = \[([^\]]+)\]/
.exec(source)?.[1]
.match(/"([a-z-]+)"/g)
?.map((s) => s.replace(/"/g, '')) ?? [];
expect(reserved.length).toBeGreaterThan(0);
for (const name of TARGET_NAMES) expect(reserved).not.toContain(name);
},
);
});