Add tests for dittoNameRoute

This commit is contained in:
Alex Gleason 2025-03-03 14:39:01 -06:00
parent 14b809b1e8
commit 9be9f7c9d0
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
9 changed files with 220 additions and 51 deletions

View file

@ -0,0 +1,62 @@
import { TestApp } from '@ditto/mastoapi/test';
import { assertEquals } from '@std/assert';
import route from './dittoNamesRoute.ts';
Deno.test('POST / creates a name request event', async () => {
await using app = new TestApp();
const { conf, relay } = app.var;
const user = app.user();
app.route('/', route);
const response = await app.api.post('/', { name: 'Alex@Ditto.pub', reason: 'for testing' });
assertEquals(response.status, 200);
const [event] = await relay.query([{ kinds: [3036], authors: [await user.signer.getPublicKey()] }]);
assertEquals(event?.tags, [
['r', 'Alex@Ditto.pub'],
['r', 'alex@ditto.pub'],
['L', 'nip05.domain'],
['l', 'ditto.pub', 'nip05.domain'],
['p', await conf.signer.getPublicKey()],
]);
assertEquals(event?.content, 'for testing');
});
Deno.test('POST / can be called multiple times with the same name', async () => {
await using app = new TestApp();
app.user();
app.route('/', route);
const response1 = await app.api.post('/', { name: 'alex@ditto.pub' });
const response2 = await app.api.post('/', { name: 'alex@ditto.pub' });
assertEquals(response1.status, 200);
assertEquals(response2.status, 200);
});
Deno.test('POST / returns 400 if the name has already been granted', async () => {
await using app = new TestApp();
const { conf, relay } = app.var;
app.user();
app.route('/', route);
const grant = await conf.signer.signEvent({
kind: 30360,
tags: [['d', 'alex@ditto.pub']],
content: '',
created_at: 0,
});
await relay.event(grant);
const response = await app.api.post('/', { name: 'alex@ditto.pub' });
assertEquals(response.status, 400);
});

View file

@ -18,33 +18,55 @@ const route = new DittoRoute();
route.post('/', userMiddleware(), async (c) => {
const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const result = nameRequestSchema.safeParse(await c.req.json());
if (!result.success) {
return c.json({ error: 'Invalid username', schema: result.error }, 400);
return c.json({ error: 'Invalid username', schema: result.error }, 422);
}
const pubkey = await user.signer.getPublicKey();
const adminPubkey = await conf.signer.getPublicKey();
const { name, reason } = result.data;
const [_localpart, domain] = name.split('@');
const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
if (existing) {
return c.json({ error: 'Name request already exists' }, 400);
if (domain.toLowerCase() !== conf.url.host.toLowerCase()) {
return c.json({ error: 'Unsupported domain' }, 422);
}
const r: string[][] = [['r', name]];
const d = name.toLowerCase();
const [grant] = await relay.query([{ kinds: [30360], authors: [adminPubkey], '#d': [d] }]);
if (grant) {
return c.json({ error: 'Name has already been granted' }, 400);
}
const [pending] = await relay.query([{
kinds: [30383],
authors: [adminPubkey],
'#p': [pubkey],
'#k': ['3036'],
'#r': [d],
'#n': ['pending'],
limit: 1,
}]);
if (pending) {
return c.json({ error: 'You have already requested that name, and it is pending approval by staff' }, 400);
}
const tags: string[][] = [['r', name]];
if (name !== name.toLowerCase()) {
r.push(['r', name.toLowerCase()]);
tags.push(['r', name.toLowerCase()]);
}
const event = await createEvent({
kind: 3036,
content: reason,
tags: [
...r,
...tags,
['L', 'nip05.domain'],
['l', name.split('@')[1], 'nip05.domain'],
['l', domain.toLowerCase(), 'nip05.domain'],
['p', await conf.signer.getPublicKey()],
],
}, c);

View file

@ -358,6 +358,9 @@ export class DittoRelayStore implements NRelay {
}
if (event.kind === 3036 && tagsAdmin) {
const r = event.tags.find(([name]) => name === 'r')?.[1];
if (r) {
const rel = await signer.signEvent({
kind: 30383,
content: '',
@ -365,6 +368,7 @@ export class DittoRelayStore implements NRelay {
['d', event.id],
['p', event.pubkey],
['k', '3036'],
['r', r.toLowerCase()],
['n', 'pending'],
],
created_at: Math.floor(Date.now() / 1000),
@ -373,6 +377,7 @@ export class DittoRelayStore implements NRelay {
await this.event(rel, { signal: AbortSignal.timeout(1000) });
}
}
}
private async webPush(event: NostrEvent): Promise<void> {
if (!this.isFresh(event)) {

View file

@ -58,7 +58,8 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
return result;
}, new Set<string>());
const favicons = (
const favicons: Record<string, string> = domains.size
? (
await db.kysely
.selectFrom('domain_favicons')
.select(['domain', 'favicon'])
@ -68,7 +69,8 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
.reduce((result, { domain, favicon }) => {
result[domain] = favicon;
return result;
}, {} as Record<string, string>);
}, {} as Record<string, string>)
: {};
const stats = {
authors: authorStats,

View file

@ -27,10 +27,10 @@ async function createEvent<E extends (DittoEnv & { Variables: { user?: User } })
}
const event = await user.signer.signEvent({
content: '',
created_at: nostrNow(),
tags: [],
...t,
content: t.content ?? '',
created_at: t.created_at ?? nostrNow(),
tags: t.tags ?? [],
});
await relay.event(event, { signal, publish: true });

View file

@ -3,11 +3,13 @@ import { Hono } from '@hono/hono';
import type { HonoOptions } from '@hono/hono/hono-base';
import type { DittoEnv } from './DittoEnv.ts';
export type DittoAppOpts = Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>;
export class DittoApp extends Hono<DittoEnv> {
// @ts-ignore Require a DittoRoute for type safety.
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
constructor(opts: Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>) {
constructor(protected opts: DittoAppOpts) {
super(opts);
this.use((c, next) => {

View file

@ -1,4 +1,4 @@
import { type ErrorHandler, Hono } from '@hono/hono';
import { Hono } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception';
import type { HonoOptions } from '@hono/hono/hono-base';
@ -16,8 +16,6 @@ export class DittoRoute extends Hono<DittoEnv> {
this.assertVars(c.var);
return next();
});
this.onError(this._errorHandler);
}
private assertVars(vars: Partial<DittoEnv['Variables']>): DittoEnv['Variables'] {
@ -40,16 +38,4 @@ export class DittoRoute extends Hono<DittoEnv> {
private throwMissingVar(name: string): never {
throw new HTTPException(500, { message: `Missing required variable: ${name}` });
}
private _errorHandler: ErrorHandler = (error, c) => {
if (error instanceof HTTPException) {
if (error.res) {
return error.res;
} else {
return c.json({ error: error.message }, error.status);
}
}
return c.json({ error: 'Something went wrong' }, 500);
};
}

View file

@ -7,6 +7,8 @@ import { generateSecretKey, nip19 } from 'nostr-tools';
import type { User } from '@ditto/mastoapi/middleware';
export { TestApp } from './test/TestApp.ts';
export function testApp(): {
app: DittoApp;
relay: NRelay;

View file

@ -0,0 +1,88 @@
import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db';
import { type NRelay, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts';
import type { Context } from '@hono/hono';
import type { User } from '../middleware/User.ts';
import { MockRelay } from '@nostrify/nostrify/test';
interface DittoVars {
db: DittoDB;
conf: DittoConf;
relay: NRelay;
}
export class TestApp extends DittoApp implements AsyncDisposable {
private _user?: User;
constructor(opts?: Partial<DittoAppOpts>) {
const nsec = nip19.nsecEncode(generateSecretKey());
const conf = opts?.conf ?? new DittoConf(
new Map([
['DITTO_NSEC', nsec],
['LOCAL_DOMAIN', 'https://ditto.pub'],
]),
);
const db = opts?.db ?? new DummyDB();
const relay = opts?.relay ?? new MockRelay();
super({
db,
conf,
relay,
...opts,
});
this.use(async (c: Context<{ Variables: { user?: User } }>, next) => {
c.set('user', this._user);
await next();
});
this.onError((err) => {
throw err;
});
}
get var(): DittoVars {
return {
db: this.opts.db,
conf: this.opts.conf,
relay: this.opts.relay,
};
}
user(user?: User): User {
user ??= this.createUser();
this._user = user;
return user;
}
createUser(sk?: Uint8Array): User {
return {
relay: this.opts.relay,
signer: new NSecSigner(sk ?? generateSecretKey()),
};
}
api = {
get: async (path: string): Promise<Response> => {
return await this.request(path);
},
post: async (path: string, body: unknown): Promise<Response> => {
return await this.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
};
async [Symbol.asyncDispose](): Promise<void> {
await this.opts.db[Symbol.asyncDispose]();
}
}