From 94309eb15a80d367e1eddd21de279a2d05125b80 Mon Sep 17 00:00:00 2001 From: mrkvon Date: Tue, 7 Jan 2025 09:00:48 +0100 Subject: [PATCH] feat: implement and test leaving a group --- .env.sample | 5 +- CHANGELOG.md | 4 + src/app.ts | 5 +- src/config/helpers.ts | 10 +-- src/config/index.ts | 6 +- src/controllers/inbox.ts | 81 ++++++++++++++--- src/index.ts | 10 ++- src/schema.ts | 4 +- src/test/leave.spec.ts | 183 +++++++++++++++++++++++++++++++++++++-- src/test/setup.ts | 1 + src/utils/errors.ts | 14 ++- 11 files changed, 288 insertions(+), 35 deletions(-) diff --git a/.env.sample b/.env.sample index 509b629..2f8fb44 100644 --- a/.env.sample +++ b/.env.sample @@ -9,4 +9,7 @@ PORT= ## URI of the group to which the joining person should be added (required) ## The service will need read and write access to the group -GROUP_TO_JOIN= +GROUP_TO_JOIN="" + +## comma-separated list of URIs of the groups from which the person can be removed if present (required) +GROUPS_TO_LEAVE="" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d78f02..0549900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Implement and test leaving a group + ## [0.1.0] - 2025-01-07 ### Added diff --git a/src/app.ts b/src/app.ts index 982ee38..5139ff5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import { solidIdentity } from '@soid/koa' import Koa from 'koa' import helmet from 'koa-helmet' import serve from 'koa-static' -import { processRequest, verifyReqest } from './controllers/inbox.js' +import { processRequest, verifyRequest } from './controllers/inbox.js' import { loadConfig } from './middlewares/loadConfig.js' import { solidAuth } from './middlewares/solidAuth.js' import { validateBody } from './middlewares/validate.js' @@ -16,6 +16,7 @@ export interface AppConfig { readonly webId: string readonly isBehindProxy?: boolean readonly groupToJoin: string + readonly groupsToLeave: string[] } const createApp = async (config: AppConfig) => { @@ -40,7 +41,7 @@ const createApp = async (config: AppConfig) => { } */ validateBody(schema.notification), - verifyReqest, + verifyRequest, processRequest, ) diff --git a/src/config/helpers.ts b/src/config/helpers.ts index d7376ef..8ba3ce7 100644 --- a/src/config/helpers.ts +++ b/src/config/helpers.ts @@ -1,10 +1,10 @@ -export const stringToBoolean = (value: string | undefined): boolean => { +export const envToBoolean = (value: string | undefined): boolean => { if (value === 'false') return false if (value === '0') return false return !!value } -// export const stringToArray = (value: string) => { -// if (!value) return [] -// return value.split(/\s*,\s*/) -// } +export const envToArray = (value: string | undefined) => { + if (!value) return [] + return value.split(/\s*,\s*/) +} diff --git a/src/config/index.ts b/src/config/index.ts index 11652dc..544c19b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,5 @@ import { ConfigError } from '../utils/errors.js' -import { stringToBoolean } from './helpers.js' +import { envToArray, envToBoolean } from './helpers.js' // define environment variables via .env file, or via environment variables directly @@ -16,7 +16,7 @@ const baseUrl = export const webId = new URL('/profile/card#bot', baseUrl).toString() -export const isBehindProxy = stringToBoolean(process.env.BEHIND_PROXY) +export const isBehindProxy = envToBoolean(process.env.BEHIND_PROXY) if (!process.env.GROUP_TO_JOIN) throw new ConfigError( @@ -24,3 +24,5 @@ if (!process.env.GROUP_TO_JOIN) ) export const groupToJoin = process.env.GROUP_TO_JOIN + +export const groupsToLeave = envToArray(process.env.GROUPS_TO_LEAVE) diff --git a/src/controllers/inbox.ts b/src/controllers/inbox.ts index a683d3f..c9a81a0 100644 --- a/src/controllers/inbox.ts +++ b/src/controllers/inbox.ts @@ -1,18 +1,18 @@ import { getAuthenticatedFetch } from '@soid/koa' import { type Middleware } from 'koa' -import assert from 'node:assert' import { solid, vcard } from 'rdf-namespaces' import { AppConfig } from '../app.js' import { HttpError, ValidationError } from '../utils/errors.js' -export const verifyReqest: Middleware<{ +export const verifyRequest: Middleware<{ user: string config: AppConfig }> = async (ctx, next) => { const actor = ctx.request.body.actor.id const authenticatedUser = ctx.state.user const object = ctx.request.body.object.id - const group = ctx.state.config.groupToJoin + const { groupToJoin, groupsToLeave } = ctx.state.config + const task: 'Join' | 'Leave' = ctx.request.body.type if (actor !== authenticatedUser) ctx.throw( @@ -20,11 +20,27 @@ export const verifyReqest: Middleware<{ `Actor does not match authenticated agent.\nActor: ${actor}\nAuthenticated agent: ${authenticatedUser}`, ) - if (object !== group) - throw new ValidationError( - `Object does not match expected group.\nExpected: ${group}\nActual: ${object}`, - [], - ) + switch (task) { + case 'Join': { + if (object !== groupToJoin) + throw new ValidationError( + `Object does not match expected group.\nExpected: ${groupToJoin}\nActual: ${object}`, + [], + ) + break + } + case 'Leave': { + if (!groupsToLeave.includes(object)) + throw new ValidationError( + `Object does not match any of the expected groups.\nExpected: ${groupsToLeave.join(',')}\nActual: ${object}`, + [], + ) + break + } + default: { + throw new Error(`Unrecognized task: ${task}`) + } + } await next() } @@ -32,8 +48,27 @@ export const verifyReqest: Middleware<{ export const processRequest: Middleware<{ user: string config: AppConfig +}> = async (ctx, next) => { + const task: 'Join' | 'Leave' = ctx.request.body.type + + switch (task) { + case 'Join': { + return await joinGroup(ctx, next) + } + case 'Leave': { + return await leaveGroup(ctx, next) + } + default: + throw new Error('Unexpected task') + } +} + +const joinGroup: Middleware<{ + user: string + config: AppConfig }> = async ctx => { - const group = ctx.state.config.groupToJoin + const group = ctx.request.body.object.id + const authFetch = await getAuthenticatedFetch(ctx.state.config.webId) const response = await authFetch(group, { headers: { 'content-type': 'text/n3' }, @@ -47,10 +82,34 @@ export const processRequest: Middleware<{ throw new HttpError( `Adding person ${ctx.state.user} to group ${group} failed.`, response, + 500, ) - assert(response.ok) - ctx.status = 200 ctx.set('location', group) } + +const leaveGroup: Middleware<{ + user: string + config: AppConfig +}> = async ctx => { + const group = ctx.request.body.object.id + + const authFetch = await getAuthenticatedFetch(ctx.state.config.webId) + const response = await authFetch(group, { + headers: { 'content-type': 'text/n3' }, + method: 'PATCH', + body: `_:remove a <${solid.InsertDeletePatch}>; + <${solid.deletes}> { <${group}> <${vcard.hasMember}> <${ctx.state.user}> . }. + `, + }) + + if (!response.ok) + throw new HttpError( + `Removing person ${ctx.state.user} from group ${group} failed.`, + response, + response.status === 409 ? 409 : 500, + ) + + ctx.status = 200 +} diff --git a/src/index.ts b/src/index.ts index beb5d32..86bc57d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,14 @@ /* eslint-disable no-console */ import { createApp } from './app.js' -import { groupToJoin, isBehindProxy, port, webId } from './config/index.js' +import { + groupsToLeave, + groupToJoin, + isBehindProxy, + port, + webId, +} from './config/index.js' -createApp({ webId, isBehindProxy, groupToJoin }).then(app => +createApp({ webId, isBehindProxy, groupToJoin, groupsToLeave }).then(app => app.listen(port, async () => { console.log(`community inbox service is listening on port ${port}`) }), diff --git a/src/schema.ts b/src/schema.ts index 6838468..b676035 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,7 +1,7 @@ import { JSONSchemaType } from 'ajv/dist/2020.js' export const notification: JSONSchemaType<{ - type: 'Join' + type: 'Join' | 'Leave' id: string '@context': 'https://www.w3.org/ns/activitystreams' actor: { type: 'Person'; id: string } @@ -17,7 +17,7 @@ export const notification: JSONSchemaType<{ const: 'https://www.w3.org/ns/activitystreams', }, id: { type: 'string' }, - type: { type: 'string', enum: ['Join'] }, + type: { type: 'string', enum: ['Join', 'Leave'] }, actor: { type: 'object', properties: { diff --git a/src/test/leave.spec.ts b/src/test/leave.spec.ts index 9a6ae75..0158a3d 100644 --- a/src/test/leave.spec.ts +++ b/src/test/leave.spec.ts @@ -1,11 +1,178 @@ -import { describe, test } from 'vitest' +import { describe, expect, test } from 'vitest' +import { checkMembership } from './helpers/index.js' +import { TestContext } from './setup.js' describe('Leaving the community', () => { - test.todo('[request is not authenticated] 401') - test.todo('[actor is missing] 400') - test.todo('[object is missing] 400') - test.todo('[actor does not match authenticated user] 403') - test.todo('[object does not match the community] 400') - test.todo("[actor is not in any community's group] 404") - test.todo("[all ok] should remove the actor from community's groups") + test('[request is not authenticated] 401', async ctx => { + const person = ctx.people[2] + + // check that the person is in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person.webId }, + object: { type: 'Group', id: ctx.community.group }, + }), + }) + + // receive response 401 + expect(response.status).toBe(401) + + // check that the person is still in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + }) + + test('[actor is missing] 400', async ctx => { + const person = ctx.people[2] + + // check that the person is in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await person.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + object: { type: 'Group', id: ctx.community.group }, + }), + }) + + // receive response 400 + expect(response.status).toBe(400) + + // check that the person is still in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + }) + + test('[object is missing] 400', async ctx => { + const person = ctx.people[2] + + // check that the person is in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await person.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person.webId }, + }), + }) + + // receive response 400 + expect(response.status).toBe(400) + + // check that the person is still in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + }) + + test('[actor does not match authenticated user] 403', async ctx => { + const [, person2, person3] = ctx.people + + // check that the person is in the group + expect(await checkMembership(person3.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await person2.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person3.webId }, + object: { type: 'Group', id: ctx.community.group }, + }), + }) + + // receive response 403 + expect(response.status).toBe(403) + + // check that the person is still in the group + expect(await checkMembership(person3.webId, ctx.community.group)).toBe(true) + }) + + test('[object does not match any configured groups] 400', async ctx => { + const person = ctx.people[2] + + // check that the person is in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await person.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person.webId }, + object: { type: 'Group', id: 'https://example.com/community/group#us' }, + }), + }) + + // receive response 400 + expect(response.status).toBe(400) + + // check that the person is still in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + }) + + test('[actor is not in the group] 409', async ctx => { + const person = ctx.people[0] + + // check that the person is not in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(false) + + // send request to the app + const response = await person.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person.webId }, + object: { type: 'Group', id: ctx.community.group }, + }), + }) + + // receive response 409 + expect(response.status).toBe(409) + + // check that the person is not in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(false) + }) + + test("[all ok] should remove the actor from community's groups", async ctx => { + const person = ctx.people[2] + + // check that the person is in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(true) + + // send request to the app + const response = await person.fetch(`${ctx.app.origin}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Leave', + actor: { type: 'Person', id: person.webId }, + object: { type: 'Group', id: ctx.community.group }, + }), + }) + + // receive response 200 + expect(response.status).toBe(200) + + // check that the person is not in the group + expect(await checkMembership(person.webId, ctx.community.group)).toBe(false) + }) }) diff --git a/src/test/setup.ts b/src/test/setup.ts index 90642ef..c734db5 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -104,6 +104,7 @@ beforeEach(async ctx => { const app = await createApp({ webId: ctx.app.webId, groupToJoin: ctx.community.group, + groupsToLeave: [ctx.community.group], }) server = await new Promise(resolve => { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 6e5cf88..65074d3 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,10 +5,10 @@ export class HttpError extends Error { public status: number public response: Response - constructor(message: string, response: Response) { + constructor(message: string, response: Response, status?: number) { super(message) this.name = 'HttpError' - this.status = response.status + this.status = status ?? response.status this.response = response // Set the prototype explicitly to maintain correct instance type @@ -50,6 +50,16 @@ export const handleErrors: Middleware = async (ctx, next) => { message: e.message, detail: e.errors, } + } else if (e instanceof HttpError) { + ctx.response.status = e.status + ctx.response.type = 'json' + ctx.response.body = { + message: e.message, + detail: { + status: e.status, + body: await e.response.text(), + }, + } } else throw e } }