From 5feb676183a3c52823c9464de3dd0ebde620e02e Mon Sep 17 00:00:00 2001 From: mrkvon Date: Tue, 16 Jan 2024 03:45:01 +0100 Subject: [PATCH 1/3] Check whether a person has a verified email with GET /status/:webId --- .env.sample | 4 + README.md | 14 +- package.json | 1 + src/app.ts | 13 +- src/config/index.ts | 6 + src/controllers/status.ts | 182 ++++++++++++++++++--- src/helpers.ts | 15 -- src/middlewares/authorizeGroup.ts | 55 +++++-- src/middlewares/solidAuth.ts | 5 +- src/setup.ts | 93 ----------- src/test/helpers/index.ts | 69 ++++++++ src/test/helpers/setupPod.ts | 241 ++++++++++++++++++++++++++++ src/test/helpers/types.ts | 8 + src/test/integration-finish.spec.ts | 24 +-- src/test/notification.spec.ts | 2 +- src/test/status.spec.ts | 98 +++++------ src/test/testSetup.spec.ts | 34 ++-- yarn.lock | 9 +- 18 files changed, 640 insertions(+), 233 deletions(-) delete mode 100644 src/helpers.ts delete mode 100644 src/setup.ts create mode 100644 src/test/helpers/index.ts create mode 100644 src/test/helpers/setupPod.ts create mode 100644 src/test/helpers/types.ts diff --git a/.env.sample b/.env.sample index 3d9de52..1cf958b 100644 --- a/.env.sample +++ b/.env.sample @@ -14,6 +14,7 @@ MAILER_IDENTITY_EMAIL=bot@example MAILER_IDENTITY_PASSWORD=password MAILER_IDENTITY_PROVIDER=http://localhost:3456 MAILER_IDENTITY_WEBID=http://localhost:3456/bot/profile/card#me +MAILER_IDENTITY_CSS_VERSION=7 # supported versions are 6 and 7 # link to group of users who are allowed to use the service ALLOWED_GROUPS= @@ -50,3 +51,6 @@ DB_PORT= JWT_KEY=./ecdsa-p256-private.pem # jwt algorithm JWT_ALG=ES256 + +# type index class by which we find settings with email +EMAIL_DISCOVERY_TYPE=http://w3id.org/hospex/ns#PersonalHospexDocument diff --git a/README.md b/README.md index 7ac1eea..022e00f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ # Simple Solid email notifications -This service sends email from one person within a Solid group to another. It doesn't use native Solid notifications, because there are pods that don't support it. +This service sends email from one person within a Solid group to another. It doesn't use native Solid notifications, because there are pods that don't support them. ## How it works -- It's a bot agent with its own identity, which must be provided by arbitrary CommunitySolidServer pod +- It's a bot agent with its own identity, which must be provided by arbitrary [CommunitySolidServer](https://github.com/CommunitySolidServer/CommunitySolidServer) pod - It runs on a server - You can check whether another person in the group has set up email notifications. - If the other person has set up the notifications, you can send them an email through this service. - At the beginning, you need to verify your email, save it into your hospitality exchange settings, and give the mailer a permission to read the email; so the email notifier can access the settings when it sends you an email. - When you want to notify other person, the service will check whether both of you belong to the specified group(s). If you both belong, and the other person has email notifications set up, it will send the other person an email. +### Verified email address discovery + +Email address and verification token should be stored in (webId) - space:preferencesFile -> (email settings file) to which the mailer identity has read (and maybe write) access. It can be in the main webId file, or it can be in some document discovered via publicTypeIndex. In case of hospitality exchange, it can be in hospex:PersonalHospexDocument. + +1. Go to person's webId +1. Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)`` +1. Find instances of hospex:PersonalHospexDocument +1. Find settings in the relevant instance (webId) - space:preferencesFile -> (settings) +1. In the settings, find (webId) - foaf:mbox -> (email) and (webId) - example:emailVerificationToken -> (JWT) + ## Usage ### Configure diff --git a/package.json b/package.json index 30f9fc9..9190078 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@koa/bodyparser": "^5.0.0", "@koa/cors": "^4.0.0", "@koa/router": "^12.0.0", + "@ldhop/core": "^0.0.0-alpha.0", "@solid/access-token-verifier": "^2.0.5", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", diff --git a/src/app.ts b/src/app.ts index 40ac088..f5545a4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,7 +12,10 @@ import { } from './controllers/integration' import { getStatus } from './controllers/status' import { webhookReceiver } from './controllers/webhookReceiver' -import { authorizeGroups } from './middlewares/authorizeGroup' +import { + authorizeGroups, + checkParamGroupMembership, +} from './middlewares/authorizeGroup' import { solidAuth } from './middlewares/solidAuth' import { validateBody } from './middlewares/validate' @@ -90,7 +93,13 @@ router ) .get('/verify-email', checkVerificationLink, finishIntegration) .post('/webhook-receiver', webhookReceiver) - .get('/status', solidAuth, getStatus) + .get( + '/status/:webId', + solidAuth, + authorizeGroups(allowedGroups), + checkParamGroupMembership(allowedGroups, 'webId' as const), + getStatus, + ) app .use(helmet()) diff --git a/src/config/index.ts b/src/config/index.ts index de9bf52..6a42c90 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -16,6 +16,8 @@ export const mailerCredentials = { webId: process.env.MAILER_IDENTITY_WEBID ?? 'http://localhost:3456/bot/profile/card#me', + // version of CommunitySolidServer that provides identity for mailer + cssVersion: <6 | 7>+(process.env.MAILER_IDENTITY_CSS_VERSION ?? 7), // 6 or 7 } const stringToBoolean = (value: string | undefined): boolean => { @@ -71,3 +73,7 @@ export const jwt = { key: process.env.JWT_KEY ?? './ecdsa-p256-private.pem', alg: process.env.JWT_ALG ?? 'ES256', } + +export const emailDiscoveryType = + process.env.EMAIL_DISCOVERY_TYPE ?? + 'http://w3id.org/hospex/ns#PersonalHospexDocument' diff --git a/src/controllers/status.ts b/src/controllers/status.ts index 364d3d9..b93d779 100644 --- a/src/controllers/status.ts +++ b/src/controllers/status.ts @@ -1,27 +1,159 @@ -import { Middleware } from 'koa' -import { EmailVerification, Integration } from '../config/sequelize' - -export const getStatus: Middleware = async ctx => { - const webId = ctx.state.user - - const verified = await Integration.findAll({ where: { webId } }) - - const unverified = await EmailVerification.findAll({ where: { webId } }) - - ctx.response.body = { - actor: webId, - integrations: [ - ...verified.map(s => ({ - object: s.inbox, - target: s.email, - verified: true, - })), - ...unverified.map(s => ({ - object: s.inbox, - target: s.email, - verified: false, - })), - ], - } +import { RdfQuery } from '@ldhop/core/dist/src' +import { QueryAndStore } from '@ldhop/core/dist/src/QueryAndStore' +import { fetchRdfDocument } from '@ldhop/core/dist/src/utils/helpers' +import { getAuthenticatedFetch as getAuthenticatedFetch6x } from 'css-authn/dist/6.x' +import { getAuthenticatedFetch as getAuthenticatedFetch7x } from 'css-authn/dist/7.x' +import { readFile } from 'fs-extra' +import { verify } from 'jsonwebtoken' +import type { DefaultContext, DefaultState, Middleware } from 'koa' +import { NamedNode, Quad } from 'n3' +import { dct, rdfs, solid, space } from 'rdf-namespaces' +import * as config from '../config' + +export const getVerifiedEmails = async (webId: string) => { + const tokens = await findEmailVerificationTokens(webId) + + const pem = await readFile(config.jwt.key, { encoding: 'utf-8' }) + + const verifiedEmails = tokens + .map(token => { + try { + return verify(token, pem) as { + webId: string + email: string + emailVerified: boolean + iss: string + iat: number + } + } catch { + return null + } + }) + .filter(a => a?.emailVerified && a.webId === webId) + .map(a => a!.email) + + return verifiedEmails +} + +export const getStatus: Middleware< + DefaultState, + DefaultContext & { params: { webId: string } } +> = async ctx => { + const webId = ctx.params.webId + + const verifiedEmails = await getVerifiedEmails(webId) + + ctx.response.body = { emailVerified: verifiedEmails.length > 0 } ctx.response.status = 200 } + +/** + * To find verified email of a person + * + * - Go to person's webId + * - Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)`` + * - Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE defaults to hospex:PersonalHospexDocument) + * - Find settings in the relevant instance (webId) - space:preferencesFile -> (settings) + * - In the settings, find (webId) - example:emailVerificationToken -> (JWT) + */ +const findEmailQuery: RdfQuery = [ + // Go to person's webId and fetch extended profile documents, too + { + type: 'match', + subject: '?person', + predicate: rdfs.seeAlso, + pick: 'object', + target: '?extendedDocument', + }, + { type: 'add resources', variable: '?extendedDocument' }, + // Find public type index + { + type: 'match', + subject: '?person', + predicate: solid.publicTypeIndex, + pick: 'object', + target: '?publicTypeIndex', + }, + // Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE) + { + type: 'match', + subject: '?publicTypeIndex', + predicate: dct.references, + pick: 'object', + target: '?typeRegistration', + }, + { + type: 'match', + subject: '?typeRegistration', + predicate: solid.forClass, + object: config.emailDiscoveryType, + pick: 'subject', + target: '?typeRegistrationForClass', + }, + { + type: 'match', + subject: '?typeRegistrationForClass', + predicate: solid.instance, + pick: 'object', + target: `?classDocument`, + }, + { type: 'add resources', variable: '?classDocument' }, + // Find settings + { + type: 'match', + subject: '?person', + predicate: space.preferencesFile, + pick: 'object', + target: '?settings', + }, + { type: 'add resources', variable: '?settings' }, +] + +const findEmailVerificationTokens = async (webId: string) => { + // initialize knowledge graph and follow your nose through it + // according to the query + const qas = new QueryAndStore(findEmailQuery, { person: new Set([webId]) }) + await run(qas) + + // Find email verification tokens + const objects = qas.store.getObjects( + new NamedNode(webId), + new NamedNode('https://example.com/emailVerificationToken'), + null, + ) + + return objects.map(o => o.value) +} + +const fetchRdf = async (uri: string) => { + const getAuthenticatedFetch = + config.mailerCredentials.cssVersion === 6 + ? getAuthenticatedFetch6x + : getAuthenticatedFetch7x + const authBotFetch = await getAuthenticatedFetch(config.mailerCredentials) + + const { data: quads } = await fetchRdfDocument(uri, authBotFetch) + + return quads +} + +/** + * Follow your nose through the linked data graph by query + */ +const run = async (qas: QueryAndStore) => { + let missingResources = qas.getMissingResources() + + while (missingResources.length > 0) { + let quads: Quad[] = [] + const res = missingResources[0] + try { + quads = await fetchRdf(missingResources[0]) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + } finally { + qas.addResource(res, quads) + missingResources = qas.getMissingResources() + } + } +} diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index 39c2d2d..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createAccount } from 'css-authn/dist/7.x' -import * as uuid from 'uuid' - -export const createRandomAccount = ({ - solidServer, -}: { - solidServer: string -}) => { - return createAccount({ - username: uuid.v4(), - password: uuid.v4(), - email: uuid.v4() + '@example.com', - provider: solidServer, - }) -} diff --git a/src/middlewares/authorizeGroup.ts b/src/middlewares/authorizeGroup.ts index 267740d..33b1888 100644 --- a/src/middlewares/authorizeGroup.ts +++ b/src/middlewares/authorizeGroup.ts @@ -1,23 +1,16 @@ -import { Middleware } from 'koa' +import { DefaultContext, DefaultState, Middleware } from 'koa' import { Parser } from 'n3' import { vcard } from 'rdf-namespaces' export const authorizeGroups = - (groups: string[]): Middleware => + (groups: string[]): Middleware<{ user: string }> => async (ctx, next) => { // if array of groups are empty, we allow everybody (default) if (groups.length === 0) return await next() - const user = ctx.state.user + const user = ctx.state.user - const memberships = await Promise.allSettled( - groups.map(group => isGroupMember(user, group)), - ) - - const isAllowed = memberships.some( - membership => - membership.status === 'fulfilled' && membership.value === true, - ) + const isAllowed = await isSomeGroupMember(user, groups) if (!isAllowed) { return ctx.throw( @@ -29,6 +22,46 @@ export const authorizeGroups = await next() } +const isSomeGroupMember = async (user: string, groups: string[]) => { + const memberships = await Promise.allSettled( + groups.map(group => isGroupMember(user, group)), + ) + + const isMember = memberships.some( + membership => + membership.status === 'fulfilled' && membership.value === true, + ) + return isMember +} + +/** + * Check whether a user specified in param is member of any of the given groups + */ +export const checkParamGroupMembership = + ( + groups: string[], + param: T, + ): Middleware< + DefaultState, + DefaultContext & { params: { [K in T]: string } } + > => + async (ctx, next) => { + // if array of groups are empty, we allow everybody (default) + if (groups.length === 0) return await next() + const webId = ctx.params[param] + const isAllowed = await isSomeGroupMember(webId, groups) + + if (!isAllowed) { + return ctx.throw(400, { + error: 'Person is not a member of any allowed group', + person: webId, + groups, + }) + } + + await next() + } + const isGroupMember = async (user: string, group: string) => { const groupDocumentResponse = await fetch(group) if (!groupDocumentResponse.ok) return false diff --git a/src/middlewares/solidAuth.ts b/src/middlewares/solidAuth.ts index 223408a..90e72f5 100644 --- a/src/middlewares/solidAuth.ts +++ b/src/middlewares/solidAuth.ts @@ -5,7 +5,10 @@ import type { import * as verifier from '@solid/access-token-verifier' import type { Middleware } from 'koa' -export const solidAuth: Middleware = async (ctx, next) => { +export const solidAuth: Middleware<{ + user: string + client: string | undefined +}> = async (ctx, next) => { const authorizationHeader = ctx.request.headers.authorization const dpopHeader = ctx.request.headers.dpop const solidOidcAccessTokenVerifier: SolidTokenVerifierFunction = diff --git a/src/setup.ts b/src/setup.ts deleted file mode 100644 index 6a39c01..0000000 --- a/src/setup.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect } from 'chai' -import { ldp, solid } from 'rdf-namespaces' - -export const setupInbox = async ({ - webId, - inbox, - authenticatedFetch, -}: { - webId: string - inbox: string - authenticatedFetch: typeof fetch -}) => { - // create inbox - const createInboxResponse = await authenticatedFetch(inbox, { - method: 'PUT', - headers: { - link: '; rel="type"', - 'content-type': 'text/turtle', - }, - }) - - expect(createInboxResponse.ok).to.be.true - - const createInboxAclResponse = await authenticatedFetch(inbox + '.acl', { - // this .acl is a shortcut, should find .acl properly TODO - method: 'PUT', - headers: { 'content-type': 'text/turtle' }, - body: ` - @prefix acl: . - - <#Append> - a acl:Authorization; - acl:agentClass acl:AuthenticatedAgent; - acl:accessTo <./>; - acl:default <./>; - acl:mode acl:Append. - <#ControlReadWrite> - a acl:Authorization; - acl:agent <${webId}>; - acl:accessTo <./>; - acl:default <./>; - acl:mode acl:Control, acl:Read, acl:Write. - `, - }) - - expect(createInboxAclResponse.ok).to.be.true - - const linkInboxResponse = await authenticatedFetch(webId, { - method: 'PATCH', - headers: { 'content-type': 'text/n3' }, - body: ` - _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { - <${webId}> <${ldp.inbox}> <${inbox}>. - }.`, - }) - - expect(linkInboxResponse.ok).to.be.true -} - -/** - * Give agent a read access (e.g. to inbox) - * - * Currently assumes we're changing rights to container! - */ -export const addRead = async ({ - resource, - agent, - authenticatedFetch, -}: { - resource: string - agent: string - authenticatedFetch: typeof fetch -}) => { - const response = await authenticatedFetch(resource + '.acl', { - method: 'PATCH', - headers: { 'content-type': 'text/n3' }, - body: ` - @prefix acl: . - - _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { - <#Read> - a acl:Authorization; - acl:agent <${agent}>; - acl:accessTo <${resource}>; - acl:default <${resource}>; - acl:mode acl:Read. - }.`, - }) - - expect(response.ok).to.be.true - - return response -} diff --git a/src/test/helpers/index.ts b/src/test/helpers/index.ts new file mode 100644 index 0000000..a6c2f67 --- /dev/null +++ b/src/test/helpers/index.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai' +import * as cheerio from 'cheerio' +import { createAccount } from 'css-authn/dist/7.x' +import { createSandbox } from 'sinon' +import * as uuid from 'uuid' +import * as config from '../../config' +import * as mailerService from '../../services/mailerService' + +export const createRandomAccount = ({ + solidServer, +}: { + solidServer: string +}) => { + return createAccount({ + username: uuid.v4(), + password: uuid.v4(), + email: uuid.v4() + '@example.com', + provider: solidServer, + }) +} + +export const initIntegration = async ({ + email, + authenticatedFetch, +}: { + email: string + authenticatedFetch: typeof fetch +}) => { + const sandbox = createSandbox() + const sendMailSpy = sandbox.spy(mailerService, 'sendMail') + const initResponse = await authenticatedFetch(`${config.baseUrl}/init`, { + method: 'post', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) + + expect(initResponse.status).to.equal(200) + // email was sent + const emailMessage = sendMailSpy.firstCall.firstArg.html + const $ = cheerio.load(emailMessage) + const verificationLink = $('a').first().attr('href') as string + expect(verificationLink).to.not.be.null + sandbox.restore() + + return { verificationLink } +} + +export const finishIntegration = async (verificationLink: string) => { + const response = await fetch(verificationLink) + expect(response.ok).to.be.true + const jwt = await response.text() + return { token: jwt } +} + +export const verifyEmail = async ({ + email, + authenticatedFetch, +}: { + email: string + authenticatedFetch: typeof fetch +}) => { + const { verificationLink } = await initIntegration({ + email, + authenticatedFetch, + }) + const { token } = await finishIntegration(verificationLink) + + return token +} diff --git a/src/test/helpers/setupPod.ts b/src/test/helpers/setupPod.ts new file mode 100644 index 0000000..19d4fa8 --- /dev/null +++ b/src/test/helpers/setupPod.ts @@ -0,0 +1,241 @@ +import { expect } from 'chai' +import { foaf, ldp, solid, space } from 'rdf-namespaces' +import * as config from '../../config' +import { authenticatedFetch } from '../testSetup.spec' +import { Person } from './types' + +const createFile = async ({ + url, + body, + acl, + authenticatedFetch, +}: { + url: string + body: string + acl?: { read?: 'public' | string } + authenticatedFetch: typeof fetch +}) => { + const response = await authenticatedFetch(url, { + method: 'PUT', + body: body, + headers: { 'content-type': 'text/turtle' }, + }) + expect(response.ok).to.be.true + + if (acl && acl.read) { + if (acl.read === 'public') + await addPublicRead({ resource: url, authenticatedFetch }) + else await addRead({ resource: url, agent: acl.read, authenticatedFetch }) + } +} + +const patchFile = async ({ + url, + inserts = '', + deletes = '', + authenticatedFetch, +}: { + url: string + inserts?: string + deletes?: string + authenticatedFetch: typeof fetch +}) => { + if (!inserts && !deletes) return + const patch = `@prefix solid: . + + _:patch a solid:InsertDeletePatch; + ${inserts ? `solid:inserts { ${inserts} }` : ''} + ${inserts && deletes ? ';' : ''} + ${deletes ? `solid:deletes { ${deletes} }` : ''} + .` + const response = await authenticatedFetch(url, { + method: 'PATCH', + body: patch, + headers: { 'content-type': 'text/n3' }, + }) + expect(response.ok).to.be.true +} + +/** + * Setup email settings in person's pod + */ +export const setupEmailSettings = async ({ + person, + email, + emailVerificationToken, +}: { + person: Person + email: string + emailVerificationToken: string +}) => { + // add email settings, readable by mailer + const settings = ` + <${person.webId}> + <${foaf.mbox}> "${email}"; + "${emailVerificationToken}".` + const settingsPath = person.podUrl + 'hospex/email' + + await createFile({ + url: settingsPath, + body: settings, + acl: { read: config.mailerCredentials.webId }, + authenticatedFetch, + }) + + // add hospex document with reference to email settings, readable by mailer + const hospexDocument = ` + <${person.webId}> <${space.preferencesFile}> <${settingsPath}>.` + const hospexDocumentPath = person.podUrl + 'hospex/test/card' + + await createFile({ + url: hospexDocumentPath, + body: hospexDocument, + acl: { read: config.mailerCredentials.webId }, + authenticatedFetch, + }) + + // add public type index with reference to hospex document, public + const publicTypeIndex = ` + @prefix solid: . + + <> a solid:TypeIndex, solid:ListedDocument; + <#hospex>. + <#hospex> a solid:TypeRegistration; + solid:forClass ; + solid:instance <${hospexDocumentPath}> .` + const publicTypeIndexPath = person.podUrl + 'settings/publicTypeIndex.ttl' + + await createFile({ + url: publicTypeIndexPath, + body: publicTypeIndex, + acl: { read: 'public' }, + authenticatedFetch, + }) + + // add publicTypeIndex reference to webId document of person + const profileDocumentPatch = `<${person.webId}> solid:publicTypeIndex <${publicTypeIndexPath}>.` + + await patchFile({ + url: person.webId, + inserts: profileDocumentPatch, + authenticatedFetch, + }) +} + +export const setupInbox = async ({ + webId, + inbox, + authenticatedFetch, +}: { + webId: string + inbox: string + authenticatedFetch: typeof fetch +}) => { + // create inbox + const createInboxResponse = await authenticatedFetch(inbox, { + method: 'PUT', + headers: { + link: '; rel="type"', + 'content-type': 'text/turtle', + }, + }) + + expect(createInboxResponse.ok).to.be.true + + const createInboxAclResponse = await authenticatedFetch(inbox + '.acl', { + // this .acl is a shortcut, should find .acl properly TODO + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: ` + @prefix acl: . + + <#Append> + a acl:Authorization; + acl:agentClass acl:AuthenticatedAgent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Append. + <#ControlReadWrite> + a acl:Authorization; + acl:agent <${webId}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Control, acl:Read, acl:Write. + `, + }) + + expect(createInboxAclResponse.ok).to.be.true + + const linkInboxResponse = await authenticatedFetch(webId, { + method: 'PATCH', + headers: { 'content-type': 'text/n3' }, + body: ` + _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { + <${webId}> <${ldp.inbox}> <${inbox}>. + }.`, + }) + + expect(linkInboxResponse.ok).to.be.true +} + +/** + * Give agent a read access (e.g. to inbox) + * + * Currently assumes we're changing rights to container! + */ +export const addRead = async ({ + resource, + agent, + authenticatedFetch, +}: { + resource: string + agent: string + authenticatedFetch: typeof fetch +}) => { + const response = await authenticatedFetch(resource + '.acl', { + method: 'PATCH', + headers: { 'content-type': 'text/n3' }, + body: ` + @prefix acl: . + + _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { + <#Read> + a acl:Authorization; + acl:agent <${agent}>; + acl:accessTo <${resource}>; + acl:default <${resource}>; + acl:mode acl:Read. + }.`, + }) + + expect(response.ok).to.be.true + + return response +} + +export const addPublicRead = async ({ + resource, + authenticatedFetch, +}: { + resource: string + authenticatedFetch: typeof fetch +}) => { + const response = await authenticatedFetch(resource + '.acl', { + method: 'PATCH', + headers: { 'content-type': 'text/n3' }, + body: ` + @prefix acl: . + + _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { + <#Read> + a acl:Authorization; + acl:agentClass ; + acl:accessTo <${resource}>; + acl:mode acl:Read. + }.`, + }) + + expect(response.ok).to.be.true + + return response +} diff --git a/src/test/helpers/types.ts b/src/test/helpers/types.ts new file mode 100644 index 0000000..8e762a8 --- /dev/null +++ b/src/test/helpers/types.ts @@ -0,0 +1,8 @@ +export type Person = { + idp: string + podUrl: string + webId: string + username: string + password: string + email: string +} diff --git a/src/test/integration-finish.spec.ts b/src/test/integration-finish.spec.ts index 77fd865..4f3c89d 100644 --- a/src/test/integration-finish.spec.ts +++ b/src/test/integration-finish.spec.ts @@ -1,22 +1,18 @@ import { expect } from 'chai' -import * as cheerio from 'cheerio' import fetch from 'cross-fetch' import * as jsonwebtoken from 'jsonwebtoken' import { describe } from 'mocha' -import Mail from 'nodemailer/lib/mailer' -import { SinonSandbox, SinonSpy, createSandbox } from 'sinon' +import { SinonSandbox, createSandbox } from 'sinon' import * as config from '../config' -import * as mailerService from '../services/mailerService' +import { initIntegration } from './helpers' import { authenticatedFetch, person } from './testSetup.spec' describe('email verification via /verify-email?token=jwt', () => { - let sendMailSpy: SinonSpy<[options: Mail.Options], Promise> let verificationLink: string let sandbox: SinonSandbox beforeEach(() => { sandbox = createSandbox() - sendMailSpy = sandbox.spy(mailerService, 'sendMail') sandbox.useFakeTimers({ now: Date.now(), toFake: ['Date'] }) }) @@ -26,18 +22,10 @@ describe('email verification via /verify-email?token=jwt', () => { beforeEach(async () => { // initialize the integration - const initResponse = await authenticatedFetch(`${config.baseUrl}/init`, { - method: 'post', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ email: 'email@example.com' }), - }) - - expect(initResponse.status).to.equal(200) - // email was sent - const emailMessage = sendMailSpy.firstCall.firstArg.html - const $ = cheerio.load(emailMessage) - verificationLink = $('a').first().attr('href') as string - expect(verificationLink).to.not.be.null + ;({ verificationLink } = await initIntegration({ + email: 'email@example.com', + authenticatedFetch, + })) }) it('[correct token] should respond with 200', async () => { diff --git a/src/test/notification.spec.ts b/src/test/notification.spec.ts index 8aec890..df8c979 100644 --- a/src/test/notification.spec.ts +++ b/src/test/notification.spec.ts @@ -8,7 +8,7 @@ import { SinonSandbox, SinonSpy, createSandbox } from 'sinon' import { promisify } from 'util' import { baseUrl } from '../config' import * as mailerService from '../services/mailerService' -import { addRead, setupInbox } from '../setup' +import { addRead, setupInbox } from './helpers/setupPod' import { authenticatedFetch, person } from './testSetup.spec' describe.skip('received notification via /inbox', () => { diff --git a/src/test/status.spec.ts b/src/test/status.spec.ts index b7553b2..a021e20 100644 --- a/src/test/status.spec.ts +++ b/src/test/status.spec.ts @@ -1,26 +1,24 @@ import { expect } from 'chai' -import * as cheerio from 'cheerio' import fetch from 'cross-fetch' import { describe } from 'mocha' -import Mail from 'nodemailer/lib/mailer' -import { SinonSandbox, SinonSpy, createSandbox } from 'sinon' import { baseUrl } from '../config' -import * as mailerService from '../services/mailerService' -import { addRead, setupInbox } from '../setup' -import { authenticatedFetch, person } from './testSetup.spec' +import { verifyEmail } from './helpers' +import { addRead, setupEmailSettings, setupInbox } from './helpers/setupPod' +import { + authenticatedFetch, + authenticatedFetch3, + otherAuthenticatedFetch, + otherPerson, + person, + person3, +} from './testSetup.spec' -describe.skip('get info about integrations of a person with GET /status', () => { - let sendMailSpy: SinonSpy<[options: Mail.Options], Promise> - let verificationLink: string - let sandbox: SinonSandbox +const email = 'email@example.com' - beforeEach(() => { - sandbox = createSandbox() - sendMailSpy = sandbox.spy(mailerService, 'sendMail') - }) - - afterEach(() => { - sandbox.restore() +describe('get info about integrations of a person with GET /status/:webId', () => { + beforeEach(async () => { + const token = await verifyEmail({ email, authenticatedFetch }) + await setupEmailSettings({ person, email, emailVerificationToken: token }) }) beforeEach(async () => { @@ -37,38 +35,46 @@ describe.skip('get info about integrations of a person with GET /status', () => }) }) - beforeEach(async function () { - this.timeout(10000) - // initialize the integration - const initResponse = await authenticatedFetch(`${baseUrl}/inbox`, { - method: 'post', - headers: { - 'content-type': - 'application/ld+json;profile="https://www.w3.org/ns/activitystreams"', - }, - body: JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - '@id': '', - '@type': 'Add', - actor: person.webId, - object: `${person.podUrl}inbox/`, - target: 'email@example.com', - }), - }) + it('[not authenticated] should fail with 401', async () => { + const response = await fetch( + `${baseUrl}/status/${encodeURIComponent(person.webId)}`, + ) + expect(response.status).to.equal(401) + }) - expect(initResponse.status).to.equal(200) - // email was sent - const emailMessage = sendMailSpy.firstCall.firstArg.html - const $ = cheerio.load(emailMessage) - verificationLink = $('a').first().attr('href') as string - expect(verificationLink).to.not.be.null + it('[authenticated person not from group] should fail with 403', async () => { + const response = await otherAuthenticatedFetch( + `${baseUrl}/status/${encodeURIComponent(person.webId)}`, + ) + expect(response.status).to.equal(403) }) - it('[not authenticated] should fail with 401', async () => { - const response = await fetch(`${baseUrl}/status`) - expect(response.status).to.equal(401) + it('[requested person not from group] should fail with 400', async () => { + const response = await authenticatedFetch( + `${baseUrl}/status/${encodeURIComponent(otherPerson.webId)}`, + ) + expect(response.status).to.equal(400) + }) + + it('should say that email of user is verified', async () => { + const response = await authenticatedFetch3( + `${baseUrl}/status/${encodeURIComponent(person.webId)}`, + ) + expect(response.status).to.equal(200) + + const body = await response.json() + + expect(body).to.deep.equal({ emailVerified: true }) }) - it('[authenticated person not from group] should fail with 403') - it('[requested person not from group] should fail with 400') + it('should say that email of user is not verified', async () => { + const response = await authenticatedFetch( + `${baseUrl}/status/${encodeURIComponent(person3.webId)}`, + ) + expect(response.status).to.equal(200) + + const body = await response.json() + + expect(body).to.deep.equal({ emailVerified: false }) + }) }) diff --git a/src/test/testSetup.spec.ts b/src/test/testSetup.spec.ts index b99cea2..04f890c 100644 --- a/src/test/testSetup.spec.ts +++ b/src/test/testSetup.spec.ts @@ -8,27 +8,16 @@ import { SetupServer, setupServer } from 'msw/node' import app from '../app' import { port } from '../config' import { EmailVerification, Integration } from '../config/sequelize' -import { createRandomAccount } from '../helpers' +import { createRandomAccount } from './helpers' +import type { Person } from './helpers/types' let server: Server let authenticatedFetch: typeof fetch let otherAuthenticatedFetch: typeof fetch -let person: { - idp: string - podUrl: string - webId: string - username: string - password: string - email: string -} -let otherPerson: { - idp: string - podUrl: string - webId: string - username: string - password: string - email: string -} +let authenticatedFetch3: typeof fetch +let person: Person +let otherPerson: Person +let person3: Person let cssServer: css.App let mockServer: SetupServer @@ -110,6 +99,13 @@ beforeEach(async () => { password: otherPerson.password, provider: 'http://localhost:3456', }) + + person3 = await createRandomAccount({ solidServer: 'http://localhost:3456' }) + authenticatedFetch3 = await getAuthenticatedFetch({ + email: person3.email, + password: person3.password, + provider: 'http://localhost:3456', + }) }) // Enable request interception. @@ -121,7 +117,7 @@ beforeEach(async () => { http.get('https://example.com/', (/*{ request, params, cookies }*/) => { return HttpResponse.text(` @prefix vcard: . - <#us> vcard:hasMember <${person.webId}> . + <#us> vcard:hasMember <${person.webId}>, <${person3.webId}> . `) }), ) @@ -137,9 +133,11 @@ afterEach(() => { export { authenticatedFetch, + authenticatedFetch3, cssServer, otherAuthenticatedFetch, otherPerson, person, + person3, server, } diff --git a/yarn.lock b/yarn.lock index a86218e..e018ad0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2541,6 +2541,13 @@ methods "^1.1.2" path-to-regexp "^6.2.1" +"@ldhop/core@^0.0.0-alpha.0": + version "0.0.0-alpha.0" + resolved "https://registry.yarnpkg.com/@ldhop/core/-/core-0.0.0-alpha.0.tgz#4504a33b9a2cbf6199434391d606ecf14b31972b" + integrity sha512-XryHFesKAIO6AZPA3Z6jx71FwwtyiEt6ICd3/Xdar/sXPm/aT0qKqWAJthZZhbgW0r49X6NWs8NhT/GGYzEj3g== + dependencies: + n3 "^1.17.2" + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" @@ -6455,7 +6462,7 @@ n3@^1.16.2, n3@^1.16.3, n3@^1.17.0, n3@^1.6.3: queue-microtask "^1.1.2" readable-stream "^4.0.0" -n3@^1.17.1: +n3@^1.17.1, n3@^1.17.2: version "1.17.2" resolved "https://registry.yarnpkg.com/n3/-/n3-1.17.2.tgz#3370b2d07da98a5b2865fa43c2d4e5c563cc65df" integrity sha512-BxSM52wYFqXrbQQT5WUEzKUn6qpYV+2L4XZLfn3Gblz2kwZ09S+QxC33WNdVEQy2djenFL8SNkrjejEKlvI6+Q== From 275629f09a1835f0c2d9d8ad319b70a5bfa2b1bf Mon Sep 17 00:00:00 2001 From: mrkvon Date: Tue, 16 Jan 2024 04:50:04 +0100 Subject: [PATCH 2/3] Generate api docs --- apidocs/openapi.json | 39 +++++++++--------------- package.json | 2 +- src/app.ts | 71 ++++++++++---------------------------------- yarn.lock | 14 ++++++--- 4 files changed, 41 insertions(+), 85 deletions(-) diff --git a/apidocs/openapi.json b/apidocs/openapi.json index 3b45e06..5017ede 100644 --- a/apidocs/openapi.json +++ b/apidocs/openapi.json @@ -11,7 +11,7 @@ } ], "paths": { - "/inbox": { + "/init": { "post": { "description": "", "responses": { @@ -26,34 +26,13 @@ "schema": { "type": "object", "properties": { - "@context": { - "const": "https://www.w3.org/ns/activitystreams" - }, - "@id": { - "type": "string" - }, - "@type": { - "const": "Add" - }, - "actor": { - "type": "string", - "format": "uri" - }, - "object": { - "type": "string", - "format": "uri" - }, - "target": { + "email": { "type": "string", "format": "email" } }, "required": [ - "@context", - "@type", - "actor", - "object", - "target" + "email" ], "additionalProperties": false } @@ -82,9 +61,19 @@ } } }, - "/status": { + "/status/{webId}": { "get": { "description": "", + "parameters": [ + { + "name": "webId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "default": { "description": "" diff --git a/package.json b/package.json index 9190078..d06861b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@koa/bodyparser": "^5.0.0", "@koa/cors": "^4.0.0", "@koa/router": "^12.0.0", - "@ldhop/core": "^0.0.0-alpha.0", + "@ldhop/core": "^0.0.0-alpha.1", "@solid/access-token-verifier": "^2.0.5", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", diff --git a/src/app.ts b/src/app.ts index f5545a4..fdc69d0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,65 +24,26 @@ app.proxy = isBehindProxy const router = new Router() router - // .post( - // '/inbox', - // solidAuth, - // /* - // #swagger.requestBody = { - // required: true, - // content: { - // 'application/json': { - // schema: { - // type: 'object', - // properties: { - // '@context': { const: 'https://www.w3.org/ns/activitystreams' }, - // '@id': { type: 'string' }, - // '@type': { const: 'Add' }, - // actor: { type: 'string', format: 'uri' }, - // object: { type: 'string', format: 'uri' }, - // target: { type: 'string', format: 'email' }, - // }, - // required: ['@context', '@type', 'actor', 'object', 'target'], - // additionalProperties: false, - // }, - // }, - // }, - // } - // */ - // validateBody({ - // type: 'object', - // properties: { - // '@context': { const: 'https://www.w3.org/ns/activitystreams' }, - // '@id': { type: 'string' }, - // '@type': { const: 'Add' }, - // actor: { type: 'string', format: 'uri' }, - // object: { type: 'string', format: 'uri' }, - // target: { type: 'string', format: 'email' }, - // }, - // required: ['@context', '@type', 'actor', 'object', 'target'], - // additionalProperties: false, - // }), - // initializeIntegration, - // ) .post( '/init', solidAuth, authorizeGroups(allowedGroups), - // #swagger.requestBody = { - // required: true, - // content: { - // 'application/json': { - // schema: { - // type: 'object', - // properties: { - // email: { type: 'string', format: 'email' }, - // }, - // required: ['email'], - // additionalProperties: false, - // }, - // }, - // }, - // } + /* #swagger.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + }, + required: ['email'], + additionalProperties: false, + }, + }, + }, + } + */ validateBody({ type: 'object', properties: { email: { type: 'string', format: 'email' } }, diff --git a/yarn.lock b/yarn.lock index e018ad0..9564f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2541,12 +2541,13 @@ methods "^1.1.2" path-to-regexp "^6.2.1" -"@ldhop/core@^0.0.0-alpha.0": - version "0.0.0-alpha.0" - resolved "https://registry.yarnpkg.com/@ldhop/core/-/core-0.0.0-alpha.0.tgz#4504a33b9a2cbf6199434391d606ecf14b31972b" - integrity sha512-XryHFesKAIO6AZPA3Z6jx71FwwtyiEt6ICd3/Xdar/sXPm/aT0qKqWAJthZZhbgW0r49X6NWs8NhT/GGYzEj3g== +"@ldhop/core@^0.0.0-alpha.1": + version "0.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@ldhop/core/-/core-0.0.0-alpha.1.tgz#c18ed9427cfd69dab55ab314d18305759d2d2dcb" + integrity sha512-64g79eUg2ev1KlZju0WgNeF5xZITO3WNdcaVb3sU/2jAI8mki89cQyY24AuoTWWcEl46FBZFEmmTDRORkWRd1Q== dependencies: n3 "^1.17.2" + utility-types "^3.10.0" "@mapbox/node-pre-gyp@^1.0.0": version "1.0.11" @@ -8174,6 +8175,11 @@ util-deprecate@^1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +utility-types@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" From ac0213381a00a749119b12d15f85323f10e55fac Mon Sep 17 00:00:00 2001 From: mrkvon Date: Tue, 16 Jan 2024 05:20:19 +0100 Subject: [PATCH 3/3] Remove unnecessary setup from test --- src/test/status.spec.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/test/status.spec.ts b/src/test/status.spec.ts index a021e20..05968a0 100644 --- a/src/test/status.spec.ts +++ b/src/test/status.spec.ts @@ -3,7 +3,7 @@ import fetch from 'cross-fetch' import { describe } from 'mocha' import { baseUrl } from '../config' import { verifyEmail } from './helpers' -import { addRead, setupEmailSettings, setupInbox } from './helpers/setupPod' +import { setupEmailSettings } from './helpers/setupPod' import { authenticatedFetch, authenticatedFetch3, @@ -21,20 +21,6 @@ describe('get info about integrations of a person with GET /status/:webId', () = await setupEmailSettings({ person, email, emailVerificationToken: token }) }) - beforeEach(async () => { - await setupInbox({ - webId: person.webId, - inbox: `${person.podUrl}inbox/`, - authenticatedFetch, - }) - - await addRead({ - resource: `${person.podUrl}inbox/`, - agent: 'http://localhost:3456/bot/profile/card#me', - authenticatedFetch, - }) - }) - it('[not authenticated] should fail with 401', async () => { const response = await fetch( `${baseUrl}/status/${encodeURIComponent(person.webId)}`,