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) => {
|
route.post('/', userMiddleware(), async (c) => {
|
||||||
const { conf, relay, user } = c.var;
|
const { conf, relay, user } = c.var;
|
||||||
|
|
||||||
const pubkey = await user!.signer.getPublicKey();
|
|
||||||
const result = nameRequestSchema.safeParse(await c.req.json());
|
const result = nameRequestSchema.safeParse(await c.req.json());
|
||||||
|
|
||||||
if (!result.success) {
|
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 { name, reason } = result.data;
|
||||||
|
const [_localpart, domain] = name.split('@');
|
||||||
|
|
||||||
const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
|
if (domain.toLowerCase() !== conf.url.host.toLowerCase()) {
|
||||||
if (existing) {
|
return c.json({ error: 'Unsupported domain' }, 422);
|
||||||
return c.json({ error: 'Name request already exists' }, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()) {
|
if (name !== name.toLowerCase()) {
|
||||||
r.push(['r', name.toLowerCase()]);
|
tags.push(['r', name.toLowerCase()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await createEvent({
|
const event = await createEvent({
|
||||||
kind: 3036,
|
kind: 3036,
|
||||||
content: reason,
|
content: reason,
|
||||||
tags: [
|
tags: [
|
||||||
...r,
|
...tags,
|
||||||
['L', 'nip05.domain'],
|
['L', 'nip05.domain'],
|
||||||
['l', name.split('@')[1], 'nip05.domain'],
|
['l', domain.toLowerCase(), 'nip05.domain'],
|
||||||
['p', await conf.signer.getPublicKey()],
|
['p', await conf.signer.getPublicKey()],
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
|
||||||
|
|
@ -358,19 +358,24 @@ export class DittoRelayStore implements NRelay {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === 3036 && tagsAdmin) {
|
if (event.kind === 3036 && tagsAdmin) {
|
||||||
const rel = await signer.signEvent({
|
const r = event.tags.find(([name]) => name === 'r')?.[1];
|
||||||
kind: 30383,
|
|
||||||
content: '',
|
|
||||||
tags: [
|
|
||||||
['d', event.id],
|
|
||||||
['p', event.pubkey],
|
|
||||||
['k', '3036'],
|
|
||||||
['n', 'pending'],
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.event(rel, { signal: AbortSignal.timeout(1000) });
|
if (r) {
|
||||||
|
const rel = await signer.signEvent({
|
||||||
|
kind: 30383,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['d', event.id],
|
||||||
|
['p', event.pubkey],
|
||||||
|
['k', '3036'],
|
||||||
|
['r', r.toLowerCase()],
|
||||||
|
['n', 'pending'],
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.event(rel, { signal: AbortSignal.timeout(1000) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,19 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
return result;
|
return result;
|
||||||
}, new Set<string>());
|
}, new Set<string>());
|
||||||
|
|
||||||
const favicons = (
|
const favicons: Record<string, string> = domains.size
|
||||||
await db.kysely
|
? (
|
||||||
.selectFrom('domain_favicons')
|
await db.kysely
|
||||||
.select(['domain', 'favicon'])
|
.selectFrom('domain_favicons')
|
||||||
.where('domain', 'in', [...domains])
|
.select(['domain', 'favicon'])
|
||||||
.execute()
|
.where('domain', 'in', [...domains])
|
||||||
)
|
.execute()
|
||||||
.reduce((result, { domain, favicon }) => {
|
)
|
||||||
result[domain] = favicon;
|
.reduce((result, { domain, favicon }) => {
|
||||||
return result;
|
result[domain] = favicon;
|
||||||
}, {} as Record<string, string>);
|
return result;
|
||||||
|
}, {} as Record<string, string>)
|
||||||
|
: {};
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
authors: authorStats,
|
authors: authorStats,
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ async function createEvent<E extends (DittoEnv & { Variables: { user?: User } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await user.signer.signEvent({
|
const event = await user.signer.signEvent({
|
||||||
content: '',
|
|
||||||
created_at: nostrNow(),
|
|
||||||
tags: [],
|
|
||||||
...t,
|
...t,
|
||||||
|
content: t.content ?? '',
|
||||||
|
created_at: t.created_at ?? nostrNow(),
|
||||||
|
tags: t.tags ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await relay.event(event, { signal, publish: true });
|
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 { HonoOptions } from '@hono/hono/hono-base';
|
||||||
import type { DittoEnv } from './DittoEnv.ts';
|
import type { DittoEnv } from './DittoEnv.ts';
|
||||||
|
|
||||||
|
export type DittoAppOpts = Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>;
|
||||||
|
|
||||||
export class DittoApp extends Hono<DittoEnv> {
|
export class DittoApp extends Hono<DittoEnv> {
|
||||||
// @ts-ignore Require a DittoRoute for type safety.
|
// @ts-ignore Require a DittoRoute for type safety.
|
||||||
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
|
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
|
||||||
|
|
||||||
constructor(opts: Omit<DittoEnv['Variables'], 'signal' | 'requestId'> & HonoOptions<DittoEnv>) {
|
constructor(protected opts: DittoAppOpts) {
|
||||||
super(opts);
|
super(opts);
|
||||||
|
|
||||||
this.use((c, next) => {
|
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 { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
|
||||||
import type { HonoOptions } from '@hono/hono/hono-base';
|
import type { HonoOptions } from '@hono/hono/hono-base';
|
||||||
|
|
@ -16,8 +16,6 @@ export class DittoRoute extends Hono<DittoEnv> {
|
||||||
this.assertVars(c.var);
|
this.assertVars(c.var);
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.onError(this._errorHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertVars(vars: Partial<DittoEnv['Variables']>): DittoEnv['Variables'] {
|
private assertVars(vars: Partial<DittoEnv['Variables']>): DittoEnv['Variables'] {
|
||||||
|
|
@ -40,16 +38,4 @@ export class DittoRoute extends Hono<DittoEnv> {
|
||||||
private throwMissingVar(name: string): never {
|
private throwMissingVar(name: string): never {
|
||||||
throw new HTTPException(500, { message: `Missing required variable: ${name}` });
|
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';
|
import type { User } from '@ditto/mastoapi/middleware';
|
||||||
|
|
||||||
|
export { TestApp } from './test/TestApp.ts';
|
||||||
|
|
||||||
export function testApp(): {
|
export function testApp(): {
|
||||||
app: DittoApp;
|
app: DittoApp;
|
||||||
relay: NRelay;
|
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