mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Add tests for dittoNameRoute
This commit is contained in:
parent
14b809b1e8
commit
9be9f7c9d0
9 changed files with 220 additions and 51 deletions
62
packages/ditto/routes/dittoNamesRoute.test.ts
Normal file
62
packages/ditto/routes/dittoNamesRoute.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
88
packages/mastoapi/test/TestApp.ts
Normal file
88
packages/mastoapi/test/TestApp.ts
Normal 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]();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue