Files
TREK/server/tests/unit/nest/wiki.test.ts
T
Maurice 03493a2edd refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
2026-08-08 11:14:24 +02:00

198 lines
8.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'path';
// The wiki module picks its source (local disk vs GitHub) once at module load, so each
// mode needs a fresh import with TREK_WIKI_DIR already set.
const FIXTURE_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'wiki');
const MISSING_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'no-such-wiki');
type WikiModule = typeof import('../../../src/nest/help/wiki');
async function loadWiki(dir: string | undefined): Promise<WikiModule> {
vi.resetModules();
if (dir === undefined) delete process.env.TREK_WIKI_DIR;
else process.env.TREK_WIKI_DIR = dir;
return import('../../../src/nest/help/wiki');
}
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.TREK_WIKI_DIR;
});
describe('wiki — local wiki on disk', () => {
it('serves the sidebar from disk and never touches the network', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
expect(wiki.isLocalWiki()).toBe(true);
const { sections } = await wiki.getWikiIndex();
expect(sections.map((s) => s.title)).toEqual(['Getting Started', 'Planning']); // empty section dropped
expect(sections[0].pages).toEqual([
{ title: 'Home', slug: 'Home' },
{ title: 'Quick Start', slug: 'Quick-Start' },
]);
// `_private` fails SLUG_RE, so it is filtered out of the nav.
expect(sections[1].pages).toEqual([{ title: 'Sample Page', slug: 'Sample' }]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('renders a page, rewriting wikilinks and relative images', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
expect(page.title).toBe('Sample Page');
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start)');
expect(page.markdown).toContain('[Home](/help/Home)');
expect(page.markdown).toContain('![A picture](/api/help/asset/assets/pic.png)');
expect(page.markdown).toContain('![External](https://example.com/x.png)'); // absolute URLs left alone
expect(page.markdown).not.toContain('TODO: screenshot'); // HTML comments stripped
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rewrites bare relative links, the spelling most wiki pages actually use', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// These resolve against the wiki root on GitHub but used to fall through to
// HelpPage's external-link branch in-app and open a dead tab.
expect(page.markdown).toContain('[Currencies](/help/Currencies)');
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start#first-steps)');
expect(page.markdown).toContain('[with extension](/help/Home)'); // .md stripped
});
it('leaves links that are already resolvable alone', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
expect(page.markdown).toContain('[external](https://example.com)');
expect(page.markdown).toContain('[absolute](/dashboard)');
expect(page.markdown).toContain('[anchor](#section)');
expect(page.markdown).toContain('[mail](mailto:hi@example.com)');
// Images must not be caught by the link rewriter.
expect(page.markdown).toContain('![A picture](/api/help/asset/assets/pic.png)');
expect(page.markdown).not.toContain('/help/assets/pic.png');
});
it('leaves code samples alone', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// Plugin-Development.md documents `actions[key](ctx)`, which reads as a
// markdown link. Rewriting it would corrupt a verbatim snippet.
expect(page.markdown).toContain('`actions[key](ctx)`');
expect(page.markdown).toContain('`[x](y)`');
expect(page.markdown).toContain('const link = [label](Currencies)'); // fenced block
expect(page.markdown).not.toContain('/help/ctx');
expect(page.markdown).not.toContain('[label](/help/Currencies)');
});
it('does not render a wikilink anchor as visible link text', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// `[[Quick Start#first-steps|Quick-Start]]` — the anchor belongs in the href.
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start#first-steps)');
expect(page.markdown).not.toContain('[Quick Start#first-steps]');
});
it('404s an unknown page instead of falling back to GitHub', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
// A page missing from disk does not exist in *this* version — falling back to
// GitHub here is what would reintroduce version skew.
await expect(wiki.getWikiPage('Nope')).rejects.toBeInstanceOf(wiki.WikiNotFound);
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each([['_Sidebar'], ['../Home'], ['a/b'], ['']])('rejects the invalid slug %j', async (slug) => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiPage(slug)).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
it('serves an asset from disk with the right content type', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const { buf, type } = await wiki.getWikiAsset('assets/pic.png');
expect(type).toBe('image/png');
expect(buf.subarray(1, 4).toString()).toBe('PNG');
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each([
['../../src/config.ts'],
['../../../.env'],
['assets/../../package.json'],
['/etc/passwd'],
['assets/notes.txt'],
])('refuses the asset path %j', async (assetPath) => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiAsset(assetPath)).rejects.toBeInstanceOf(wiki.WikiNotFound);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('404s a missing asset', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiAsset('assets/ghost.png')).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
});
describe('wiki — GitHub fallback when no wiki is on disk', () => {
const ok = (body: string): Response =>
({ ok: true, status: 200, text: async () => body, arrayBuffer: async () => new ArrayBuffer(4) }) as unknown as Response;
it('fetches from GitHub and caches the result', async () => {
const wiki = await loadWiki(MISSING_WIKI);
expect(wiki.isLocalWiki()).toBe(false);
fetchSpy.mockResolvedValue(ok('## Docs\n- [[Home]]\n'));
const first = await wiki.getWikiIndex();
const second = await wiki.getWikiIndex();
expect(first.sections[0].pages).toEqual([{ title: 'Home', slug: 'Home' }]);
expect(second).toEqual(first);
expect(fetchSpy).toHaveBeenCalledTimes(1); // second read served from cache
expect(fetchSpy.mock.calls[0][0]).toContain('raw.githubusercontent.com');
});
it('serves a stale cache entry when GitHub is unreachable', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockResolvedValueOnce(ok('# Cached\n'));
const before = await wiki.getWikiPage('Home');
fetchSpy.mockRejectedValue(new Error('network down'));
const after = await wiki.getWikiPage('Home');
expect(after.title).toBe(before.title);
});
it('404s when GitHub is unreachable and nothing is cached', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockRejectedValue(new Error('network down'));
await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
});
describe('wiki — default path resolution', () => {
it("resolves the repo's wiki/ with no override, so a release never ships without docs", async () => {
// Guards the __dirname anchor and the Dockerfile COPY: if either breaks, help
// silently degrades to the GitHub fallback and nobody notices.
const wiki = await loadWiki(undefined);
expect(wiki.isLocalWiki()).toBe(true);
const page = await wiki.getWikiPage('Home');
expect(page.title.length).toBeGreaterThan(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});