Move Pleroma permission_groups controller to its own routes file, add tests

This commit is contained in:
Alex Gleason 2025-03-03 16:33:28 -06:00
parent d29bc8c020
commit b7bf2fc76f
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
8 changed files with 109 additions and 48 deletions

View file

@ -79,7 +79,6 @@ import {
configController,
frontendConfigController,
pleromaAdminDeleteStatusController,
pleromaAdminPromoteController,
pleromaAdminSuggestController,
pleromaAdminTagController,
pleromaAdminUnsuggestController,
@ -149,6 +148,7 @@ import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
export interface AppEnv extends DittoEnv {
@ -440,11 +440,7 @@ app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMi
app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);
app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController);
app.post(
'/api/v1/pleroma/admin/users/permission_group/:group',
userMiddleware({ role: 'admin' }),
pleromaAdminPromoteController,
);
app.route('/api/v1/pleroma/admin/users/permission_group', pleromaAdminPermissionGroupsRoute);
app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController);
app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController);

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/utils/api.ts';
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
import { getPleromaConfigs } from '@/utils/pleroma.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
@ -62,36 +62,6 @@ const pleromaAdminTagSchema = z.object({
tags: z.string().array(),
});
const pleromaPromoteAdminSchema = z.object({
nicknames: z.string().array(),
});
const pleromaAdminPromoteController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = pleromaPromoteAdminSchema.safeParse(body);
const group = c.req.param('group');
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422);
}
if (!['admin', 'moderator'].includes(group)) {
return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422);
}
const { data } = result;
const { nicknames } = data;
for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname, c.var);
if (pubkey) {
await updateUser(pubkey, { [group]: true }, c);
}
}
return c.json({ is_admin: true }, 200);
};
const pleromaAdminTagController: AppController = async (c) => {
const { conf } = c.var;
const params = pleromaAdminTagSchema.parse(await c.req.json());
@ -180,7 +150,6 @@ export {
configController,
frontendConfigController,
pleromaAdminDeleteStatusController,
pleromaAdminPromoteController,
pleromaAdminSuggestController,
pleromaAdminTagController,
pleromaAdminUnsuggestController,

View file

@ -4,11 +4,10 @@ import { assertEquals } from '@std/assert';
import route from './dittoNamesRoute.ts';
Deno.test('POST / creates a name request event', async () => {
await using app = new TestApp();
await using app = new TestApp(route);
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' });
@ -28,10 +27,9 @@ Deno.test('POST / creates a name request event', async () => {
});
Deno.test('POST / can be called multiple times with the same name', async () => {
await using app = new TestApp();
await using app = new TestApp(route);
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' });
@ -41,11 +39,10 @@ Deno.test('POST / can be called multiple times with the same name', async () =>
});
Deno.test('POST / returns 400 if the name has already been granted', async () => {
await using app = new TestApp();
await using app = new TestApp(route);
const { conf, relay } = app.var;
app.user();
app.route('/', route);
const grant = await conf.signer.signEvent({
kind: 30360,

View file

@ -0,0 +1,35 @@
import { TestApp } from '@ditto/mastoapi/test';
import { assertEquals } from '@std/assert';
import { nip19 } from 'nostr-tools';
import route from './pleromaAdminPermissionGroupsRoute.ts';
Deno.test('POST /admin returns 403 if user is not an admin', async () => {
await using app = new TestApp(route);
app.user();
const response = await app.api.post('/admin', { nicknames: ['alex@ditto.pub'] });
assertEquals(response.status, 403);
});
Deno.test('POST /admin promotes to admin', async () => {
await using app = new TestApp(route);
const { conf, relay } = app.var;
await app.admin();
const pawn = app.createUser();
const pubkey = await pawn.signer.getPublicKey();
const response = await app.api.post('/admin', { nicknames: [nip19.npubEncode(pubkey)] });
const json = await response.json();
assertEquals(response.status, 200);
assertEquals(json, { is_admin: true });
const [event] = await relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey] }]);
assertEquals(event.tags, [['d', pubkey], ['n', 'admin']]);
});

View file

@ -0,0 +1,40 @@
import { userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoRoute } from '@ditto/mastoapi/router';
import { z } from 'zod';
import { parseBody, updateUser } from '@/utils/api.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
const route = new DittoRoute();
const pleromaPromoteAdminSchema = z.object({
nicknames: z.string().array(),
});
route.post('/:group', userMiddleware({ role: 'admin' }), async (c) => {
const body = await parseBody(c.req.raw);
const result = pleromaPromoteAdminSchema.safeParse(body);
const group = c.req.param('group');
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422);
}
if (!['admin', 'moderator'].includes(group)) {
return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422);
}
const { data } = result;
const { nicknames } = data;
for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname, c.var);
if (pubkey) {
await updateUser(pubkey, { [group]: true }, c);
}
}
return c.json({ is_admin: true }, 200);
});
export default route;

View file

@ -118,7 +118,7 @@ async function updateAdminEvent<E extends EventStub>(
return createAdminEvent(fn(prev), c);
}
function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
function updateUser(pubkey: string, n: Record<string, boolean>, c: Context): Promise<NostrEvent> {
return updateNames(30382, pubkey, n, c);
}

View file

@ -38,7 +38,7 @@ export class DittoRoute extends Hono<DittoEnv> {
}
private throwMissingVar(name: string): never {
throw new HTTPException(500, { message: `Missing required variable: ${name}` });
throw new Error(`Missing required variable: ${name}`);
}
private _errorHandler: ErrorHandler = (error, c) => {

View file

@ -2,13 +2,14 @@ import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db';
import { HTTPException } from '@hono/hono/http-exception';
import { type NRelay, NSecSigner } from '@nostrify/nostrify';
import { MockRelay } from '@nostrify/nostrify/test';
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';
import type { DittoRoute } from '../router/DittoRoute.ts';
interface DittoVars {
db: DittoDB;
@ -19,7 +20,7 @@ interface DittoVars {
export class TestApp extends DittoApp implements AsyncDisposable {
private _user?: User;
constructor(opts?: Partial<DittoAppOpts>) {
constructor(route?: DittoRoute, opts?: Partial<DittoAppOpts>) {
const nsec = nip19.nsecEncode(generateSecretKey());
const conf = opts?.conf ?? new DittoConf(
@ -44,6 +45,10 @@ export class TestApp extends DittoApp implements AsyncDisposable {
await next();
});
if (route) {
this.route('/', route);
}
this.onError((err, c) => {
if (err instanceof HTTPException) {
if (err.res) {
@ -65,6 +70,25 @@ export class TestApp extends DittoApp implements AsyncDisposable {
};
}
async admin(user?: User): Promise<User> {
const { conf, relay } = this.opts;
user ??= this.createUser();
const event = await conf.signer.signEvent({
kind: 30382,
content: '',
tags: [
['d', await user.signer.getPublicKey()],
['n', 'admin'],
],
created_at: Math.floor(Date.now() / 1000),
});
await relay.event(event);
return this.user(user);
}
user(user?: User): User {
user ??= this.createUser();
this._user = user;