Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"test": "vitest run",
"test:e2e": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"clean": "rm -rf main.js *.map coverage",
Expand Down
131 changes: 131 additions & 0 deletions test/fakes/boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { vi } from 'vitest';
import type { App, PluginManifest, Platform as PlatformType } from 'obsidian';
import * as obsidian from 'obsidian';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FakeVault } from './fake-vault';
import { fakeSecrets, type FakeSecrets } from './fake-secrets';
import { FakeCouch } from './fake-couch';
import { FakeAuthServer, type FakeMemory } from './fake-auth-server';
import { Router } from './router';
import { requestUrlMock, makeFakeApp } from './obsidian';

// bootPlugin(): assemble the REAL AgentageMemoryPlugin against the fully-mocked host + fakes and
// run its actual onload(). The ONLY injection seams are requestUrl (mocked) + the property-shaped
// fake app - main.ts has no DI constructor, so this file is the single point that must change if
// the plugin's Obsidian wiring changes. Returns handles + a teardown.

export interface BootOptions {
fqdn?: string;
files?: Record<string, string>;
memories?: FakeMemory[];
memoryName?: string; // the couch-channel memory the resolution advertises
couchDb?: string;
desktop?: boolean; // Platform.isDesktopApp; false -> obsidian:// deep-link sign-in path
}

export interface Handles {
plugin: import('../../src/main').default;
couch: FakeCouch;
auth: FakeAuthServer;
vault: FakeVault;
router: Router;
secrets: FakeSecrets;
configDir: string;
/** URLs passed to window.open (the authorize redirect). */
openedUrls: string[];
teardown: () => Promise<void>;
}

type WindowStub = {
open: (url: string) => void;
setInterval: (fn: () => void, ms: number) => number;
clearInterval: (id: number) => void;
setTimeout: (fn: () => void, ms: number) => number;
clearTimeout: (id: number) => void;
};

export async function bootPlugin(opts: BootOptions = {}): Promise<Handles> {
const fqdn = opts.fqdn ?? 'test.local';
const memoryName = opts.memoryName ?? 'work';
const couchDb = opts.couchDb ?? 'mem_work';

const vault = new FakeVault(opts.files ?? {});
const secrets = fakeSecrets();
const couch = new FakeCouch(couchDb);
const auth = new FakeAuthServer({ memories: opts.memories ?? [], couchDb });
const router = new Router({ fqdn, auth, couch, memoryName });

requestUrlMock.mockImplementation(router.requestUrl);

// A throwaway config dir so auth.json + vaults.json writes never touch ~/.agentage.
const configDir = await fs.mkdtemp(join(tmpdir(), 'ams-e2e-'));
const prevConfigDir = process.env.AGENTAGE_CONFIG_DIR;
const prevSiteFqdn = process.env.AGENTAGE_SITE_FQDN;
process.env.AGENTAGE_CONFIG_DIR = configDir;
process.env.AGENTAGE_SITE_FQDN = fqdn;

// Desktop drives loopback (a real node:http listener); the deep-link path is simpler + fully
// in-memory, so the smoke forces isDesktopApp=false and drives handleCallback via the router.
const desktop = opts.desktop ?? false;
(obsidian.Platform as unknown as typeof PlatformType & { isDesktopApp: boolean }).isDesktopApp =
desktop;

const openedUrls: string[] = [];
const timers = new Set<ReturnType<typeof setInterval>>();
const windowStub: WindowStub = {
open: (url: string) => void openedUrls.push(url),
setInterval: (fn, ms) => {
const id = setInterval(fn, ms);
timers.add(id);
return id as unknown as number;
},
clearInterval: (id) => clearInterval(id as unknown as ReturnType<typeof setInterval>),
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
clearTimeout: (id) => clearTimeout(id as unknown as ReturnType<typeof setTimeout>),
};
const prevWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = windowStub;

const { default: AgentageMemoryPlugin } = await import('../../src/main');
const app = makeFakeApp(vault, secrets) as unknown as App;
const manifest = { id: 'agentage-memory', version: '0.0.0' } as unknown as PluginManifest;
const plugin = new AgentageMemoryPlugin(app, manifest);
await plugin.onload();

const teardown = async (): Promise<void> => {
for (const id of timers) clearInterval(id);
(globalThis as { window?: unknown }).window = prevWindow;
if (prevConfigDir === undefined) delete process.env.AGENTAGE_CONFIG_DIR;
else process.env.AGENTAGE_CONFIG_DIR = prevConfigDir;
if (prevSiteFqdn === undefined) delete process.env.AGENTAGE_SITE_FQDN;
else process.env.AGENTAGE_SITE_FQDN = prevSiteFqdn;
requestUrlMock.mockReset();
await fs.rm(configDir, { recursive: true, force: true });
};

return { plugin, couch, auth, vault, router, secrets, configDir, openedUrls, teardown };
}

// Drive a full obsidian:// sign-in against the fakes: startSignIn -> capture the authorize URL ->
// mint a code at the fake AS -> feed the callback back through the plugin's protocol handler.
export async function signIn(h: Handles): Promise<void> {
type SignInPlugin = {
openSignIn: () => void;
auth: { handleCallback: (p: Record<string, string>) => Promise<void> };
};
const p = h.plugin as unknown as SignInPlugin;
const before = h.openedUrls.length;
p.openSignIn();
await vi.waitFor(() => {
if (h.openedUrls.length <= before) throw new Error('no authorize URL yet');
});
const authorizeUrl = new URL(h.openedUrls[h.openedUrls.length - 1]);
const clientId = authorizeUrl.searchParams.get('client_id') ?? '';
const state = authorizeUrl.searchParams.get('state') ?? '';
const codeChallenge = authorizeUrl.searchParams.get('code_challenge') ?? '';
const redirectUri = authorizeUrl.searchParams.get('redirect_uri') ?? '';
const { code } = h.auth.authorize({ clientId, codeChallenge, state, redirectUri });
await p.auth.handleCallback({ code, state });
}
157 changes: 157 additions & 0 deletions test/fakes/fake-auth-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// In-memory Better Auth AS + management API: OAuth 2.1 discovery / DCR / token / refresh /
// revoke, the couch-token minter, and GET/POST /api/memories. Contracts mirror the real
// wire the plugin's src/auth/* + src/couch/couch-token + main.ts:listVaults speak.

export interface FakeMemory {
name: string;
entries: number;
folderCount: number;
updated: string | null;
}

export interface AuthReply {
status: number;
json: unknown;
}

interface IssuedTokens {
access: string;
refresh: string;
clientId: string;
}

export class FakeAuthServer {
private clients = new Set<string>();
private codes = new Map<string, { clientId: string; challenge: string; redirectUri: string }>();
private tokens = new Map<string, IssuedTokens>(); // accessToken -> issued
private refreshIndex = new Map<string, string>(); // refreshToken -> accessToken
private clientSeq = 0;
private tokenSeq = 0;
readonly memories: FakeMemory[];
readonly couchDb: string;
private failNextStatus?: number;

constructor(opts: { memories?: FakeMemory[]; couchDb?: string } = {}) {
this.memories = opts.memories ?? [];
this.couchDb = opts.couchDb ?? 'mem_default';
}

failNext(status: number): void {
this.failNextStatus = status;
}

/** Is `access` a live (unrevoked) access token? Used by the api + couch-token guards. */
isValidAccess(access: string): boolean {
return this.tokens.has(access);
}

/** Drive the authorize step: mint a one-time code bound to the client + PKCE challenge. */
authorize(params: {
clientId: string;
codeChallenge: string;
state: string;
redirectUri: string;
}): { code: string; state: string } {
if (!this.clients.has(params.clientId)) throw new Error('authorize: unknown client');
const code = `code-${++this.tokenSeq}`;
this.codes.set(code, {
clientId: params.clientId,
challenge: params.codeChallenge,
redirectUri: params.redirectUri,
});
return { code, state: params.state };
}

private issue(clientId: string): AuthReply {
const access = `at-${++this.tokenSeq}`;
const refresh = `rt-${this.tokenSeq}`;
this.tokens.set(access, { access, refresh, clientId });
this.refreshIndex.set(refresh, access);
return {
status: 200,
json: { access_token: access, refresh_token: refresh, expires_in: 3600 },
};
}

handle(method: string, url: string, body?: string): AuthReply {
if (this.failNextStatus !== undefined) {
const status = this.failNextStatus;
this.failNextStatus = undefined;
return { status, json: { error: 'scripted failure' } };
}
const { pathname } = new URL(url);
if (pathname.endsWith('/.well-known/oauth-authorization-server')) return this.discovery(url);
if (pathname.endsWith('/register')) return this.register();
if (pathname.endsWith('/token')) return this.token(body);
if (pathname.endsWith('/revoke')) return { status: 200, json: {} };
if (pathname.endsWith('/account/couch-token')) return this.couchToken(body);
return { status: 404, json: { error: 'not_found' } };
}

private discovery(url: string): AuthReply {
const origin = new URL(url).origin;
return {
status: 200,
json: {
issuer: origin,
authorization_endpoint: `${origin}/api/auth/mcp/authorize`,
token_endpoint: `${origin}/api/auth/mcp/token`,
registration_endpoint: `${origin}/api/auth/mcp/register`,
revocation_endpoint: `${origin}/api/auth/mcp/revoke`,
},
};
}

private register(): AuthReply {
const clientId = `client-${++this.clientSeq}`;
this.clients.add(clientId);
return { status: 201, json: { client_id: clientId } };
}

private token(body?: string): AuthReply {
const p = new URLSearchParams(body ?? '');
const grant = p.get('grant_type');
if (grant === 'authorization_code') {
const code = p.get('code') ?? '';
const rec = this.codes.get(code);
if (!rec) return { status: 400, json: { error: 'invalid_grant' } };
this.codes.delete(code);
return this.issue(rec.clientId);
}
if (grant === 'refresh_token') {
const rt = p.get('refresh_token') ?? '';
const priorAccess = this.refreshIndex.get(rt);
if (!priorAccess) return { status: 400, json: { error: 'invalid_grant' } };
const prior = this.tokens.get(priorAccess);
this.refreshIndex.delete(rt);
if (prior) this.tokens.delete(prior.access); // rotate: the old access token dies
return this.issue(prior?.clientId ?? p.get('client_id') ?? 'client');
}
return { status: 400, json: { error: 'unsupported_grant_type' } };
}

private couchToken(body?: string): AuthReply {
const memory = (JSON.parse(body ?? '{}') as { memory?: string }).memory ?? 'default';
return {
status: 200,
json: {
success: true,
data: { jwt: `couch-jwt-${memory}`, db: this.couchDb, sub: `u/${memory}`, expSec: 3600 },
},
};
}

/** GET /api/memories -> { data:[{name,entries,folderCount,updated}] }. */
listMemories(): AuthReply {
return { status: 200, json: { data: this.memories } };
}

/** POST /api/memories { name } -> 201 (adds an empty memory). */
createMemory(body?: string): AuthReply {
const name = (JSON.parse(body ?? '{}') as { name?: string }).name ?? '';
if (!name) return { status: 400, json: { error: { message: 'name required' } } };
if (!this.memories.some((m) => m.name === name))
this.memories.push({ name, entries: 0, folderCount: 0, updated: null });
return { status: 201, json: { data: { name } } };
}
}
Loading
Loading