fix: Re-enable webfinger feature

Ok, so here is the plan. Let's give both our cucumber features and your
cypress tests a prominent place to live. That would be the root level
folder of our application. Second, let's revive formerly dead code step
by step.

Ie. move code from the former location `backend/features/` to `features/`
when it is ready. All edge cases should be tested with unit tests in
`backend/`, see my `webfinger.spec.js` as an example.
This commit is contained in:
roschaefer 2019-11-22 23:14:00 +01:00
parent 35c3219460
commit 7c6d5b5129
18 changed files with 1418 additions and 122 deletions

View File

@ -8,13 +8,13 @@ addons:
- docker
- chromium
before_install:
install:
- yarn global add wait-on
# Install Codecov
- yarn install
- cp cypress.env.template.json cypress.env.json
install:
before_script:
- docker-compose -f docker-compose.yml build --parallel
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml build # just tagging, just be quite fast
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml up -d
@ -30,10 +30,6 @@ script:
- docker-compose exec backend yarn run test --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset
# ActivityPub cucumber testing temporarily disabled because it's too buggy
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
# - docker-compose exec backend yarn run db:reset
# - docker-compose exec backend yarn run db:seed
# Frontend
- docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage
@ -42,6 +38,7 @@ script:
- docker-compose -f docker-compose.yml up -d
- wait-on http://localhost:7474
- yarn run cypress:run --record
- yarn run cucumber
# Coverage
- yarn run codecov

12
babel.config.json Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
]
]
}

View File

@ -10,8 +10,6 @@
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js",
"test": "jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"db:reset": "babel-node src/seed/reset-db.js",
"db:seed": "babel-node src/seed/seed-db.js"
},

View File

@ -1,27 +1,29 @@
import user from './user'
import inbox from './inbox'
import webFinger from './webFinger'
import express from 'express'
import cors from 'cors'
import verify from './verify'
const router = express.Router()
router.use('/.well-known/webFinger', cors(), express.urlencoded({ extended: true }), webFinger)
router.use(
'/activitypub/users',
cors(),
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }),
express.urlencoded({ extended: true }),
user,
)
router.use(
'/activitypub/inbox',
cors(),
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }),
express.urlencoded({ extended: true }),
verify,
inbox,
)
export default router
export default function() {
const router = express.Router()
router.use(
'/activitypub/users',
cors(),
express.json({
type: ['application/activity+json', 'application/ld+json', 'application/json'],
}),
express.urlencoded({ extended: true }),
user,
)
router.use(
'/activitypub/inbox',
cors(),
express.json({
type: ['application/activity+json', 'application/ld+json', 'application/json'],
}),
express.urlencoded({ extended: true }),
verify,
inbox,
)
return router
}

View File

@ -1,43 +0,0 @@
import express from 'express'
import { createWebFinger } from '../utils/actor'
import gql from 'graphql-tag'
const router = express.Router()
router.get('/', async function(req, res) {
const resource = req.query.resource
if (!resource || !resource.includes('acct:')) {
return res
.status(400)
.send(
'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.',
)
} else {
const nameAndDomain = resource.replace('acct:', '')
const name = nameAndDomain.split('@')[0]
let result
try {
result = await req.app.get('ap').dataSource.client.query({
query: gql`
query {
User(slug: "${name}") {
slug
}
}
`,
})
} catch (error) {
return res.status(500).json({ error })
}
if (result.data && result.data.User.length > 0) {
const webFinger = createWebFinger(name)
return res.contentType('application/jrd+json').json(webFinger)
} else {
return res.status(404).json({ error: `No record found for ${nameAndDomain}.` })
}
}
})
export default router

View File

@ -0,0 +1,59 @@
import express from 'express'
import CONFIG from '../../config/'
import cors from 'cors'
const debug = require('debug')('ea:webfinger')
const regex = /acct:([a-z0-9_-]*)@([a-z0-9_-]*)/
const createWebFinger = name => {
const { host } = new URL(CONFIG.CLIENT_URI)
return {
subject: `acct:${name}@${host}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `${CONFIG.CLIENT_URI}/activitypub/users/${name}`,
},
],
}
}
export async function handler(req, res) {
const { resource = '' } = req.query
// eslint-disable-next-line no-unused-vars
const [_, name, domain] = resource.match(regex) || []
if (!(name && domain))
return res.status(400).json({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
const session = req.app.get('driver').session()
try {
const [slug] = await session.readTransaction(async t => {
const result = await t.run('MATCH (u:User {slug: $slug}) RETURN u.slug AS slug', {
slug: name,
})
return result.records.map(record => record.get('slug'))
})
if (!slug)
return res.status(404).json({
error: `No record found for "${name}@${domain}".`,
})
const webFinger = createWebFinger(name)
return res.contentType('application/jrd+json').json(webFinger)
} catch (error) {
debug(error)
return res.status(500).json({
error: 'Something went terribly wrong. Please contact support@human-connection.org',
})
} finally {
session.close()
}
}
export default function() {
const router = express.Router()
router.use('/webfinger', cors(), express.urlencoded({ extended: true }), handler)
return router
}

View File

@ -0,0 +1,117 @@
import { handler } from './webfinger'
import Factory from '../../seed/factories'
import { getDriver } from '../../bootstrap/neo4j'
let resource
let res
let json
let status
let contentType
const factory = Factory()
const driver = getDriver()
const request = () => {
json = jest.fn()
status = jest.fn(() => ({ json }))
contentType = jest.fn(() => ({ json }))
res = { status, contentType }
const req = {
app: {
get: key => {
return {
driver,
}[key]
},
},
query: {
resource,
},
}
return handler(req, res)
}
afterEach(async () => {
await factory.cleanDatabase()
})
describe('webfinger', () => {
describe('no ressource', () => {
beforeEach(() => {
resource = undefined
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('?ressource query param', () => {
describe('is missing acct:', () => {
beforeEach(() => {
resource = 'some-user@domain'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('has no domain', () => {
beforeEach(() => {
resource = 'acct:some-user@'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('with acct:', () => {
beforeEach(() => {
resource = 'acct:some-user@domain'
})
it('returns empty json', async () => {
await request()
expect(status).toHaveBeenCalledWith(404)
expect(json).toHaveBeenCalledWith({
error: 'No record found for "some-user@domain".',
})
})
describe('given a user for acct', () => {
beforeEach(async () => {
await factory.create('User', { slug: 'some-user' })
})
it('returns user object', async () => {
await request()
expect(contentType).toHaveBeenCalledWith('application/jrd+json')
expect(json).toHaveBeenCalledWith({
links: [
{
href: 'http://localhost:3000/activitypub/users/some-user',
rel: 'self',
type: 'application/activity+json',
},
],
subject: 'acct:some-user@localhost:3000',
})
})
})
})
})
})

View File

@ -22,17 +22,3 @@ export function createActor(name, pubkey) {
},
}
}
export function createWebFinger(name) {
const { host } = new URL(activityPub.endpoint)
return {
subject: `acct:${name}@${host}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `${activityPub.endpoint}/activitypub/users/${name}`,
},
],
}
}

View File

@ -1,6 +1,7 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config()
dotenv.config({ path: path.resolve(__dirname, '../../.env') })
const {
MAPBOX_TOKEN,

View File

@ -6,6 +6,7 @@ import middleware from './middleware'
import { neode as getNeode, getDriver } from './bootstrap/neo4j'
import decode from './jwt/decode'
import schema from './schema'
import webfinger from './activitypub/routes/webfinger'
// check required configs and throw error
// TODO check this directly in config file - currently not possible due to testsetup
@ -41,7 +42,10 @@ const createServer = options => {
const server = new ApolloServer(Object.assign({}, defaults, options))
const app = express()
app.set('driver', driver)
app.use(helmet())
app.use('/.well-known/', webfinger())
app.use(express.static('public'))
server.applyMiddleware({ app, path: '/' })

View File

@ -9,32 +9,6 @@ Feature: Webfinger discovery
| Slug |
| peter-lustiger |
Scenario: Search
When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost"
Then I receive the following json:
"""
{
"subject": "acct:peter-lustiger@localhost:4123",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "http://localhost:4123/activitypub/users/peter-lustiger"
}
]
}
"""
And I expect the Content-Type to be "application/jrd+json; charset=utf-8"
Scenario: User does not exist
When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost"
Then I receive the following json:
"""
{
"error": "No record found for nonexisting@localhost."
}
"""
Scenario: Receiving an actor object
When I send a GET request to "/activitypub/users/peter-lustiger"
Then I receive the following json:

View File

@ -9,7 +9,7 @@ open your minikube dashboard:
$ minikube dashboard
```
This will give you an overview. Some of the steps below need some timing to make ressources available to other dependent deployments. Keeping an eye on the dashboard is a great way to check that.
This will give you an overview. Some of the steps below need some timing to make resources available to other dependent deployments. Keeping an eye on the dashboard is a great way to check that.
Follow the installation instruction for [Human Connection](../human-connection/README.md).
If all the pods and services have settled and everything looks green in your

45
features/support/steps.js Normal file
View File

@ -0,0 +1,45 @@
// features/support/steps.js
import { Given, When, Then, After, AfterAll } from 'cucumber'
import Factory from '../../backend/src/seed/factories'
import dotenv from 'dotenv'
import expect from 'expect'
const debug = require('debug')('ea:test:steps')
const factory = Factory()
After(async () => {
await factory.cleanDatabase()
})
Given('our CLIENT_URI is {string}', function (string) {
expect(process.env.CLIENT_URI).toEqual(string)
});
Given('we have the following users in our database:', function (dataTable) {
return Promise.all(dataTable.hashes().map(({ slug, name }) => {
return factory.create('User', {
name,
slug,
})
}))
})
When('I send a GET request to {string}', async function (pathname) {
const response = await this.get(pathname)
this.lastContentType = response.lastContentType
this.lastResponses.push(response.lastResponse)
this.statusCode = response.statusCode
})
Then('the server responds with a HTTP Status {int} and the following json:', function (statusCode, docString) {
expect(this.statusCode).toEqual(statusCode)
const [ lastResponse ] = this.lastResponses
expect(JSON.parse(lastResponse)).toMatchObject(JSON.parse(docString))
})
Then('the Content-Type is {string}', function (contentType) {
expect(this.lastContentType).toEqual(contentType)
})

View File

@ -0,0 +1,36 @@
Feature: Webfinger discovery
From an external server, e.g. Mastodon
I want to search for an actor alias
In order to follow the actor
Background:
Given our CLIENT_URI is "http://localhost:3000"
And we have the following users in our database:
| name | slug |
| Peter Lustiger | peter-lustiger |
Scenario: Search a user
When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost"
Then the server responds with a HTTP Status 200 and the following json:
"""
{
"subject": "acct:peter-lustiger@localhost:3000",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "http://localhost:3000/activitypub/users/peter-lustiger"
}
]
}
"""
And the Content-Type is "application/jrd+json; charset=utf-8"
Scenario: Search without result
When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost"
Then the server responds with a HTTP Status 404 and the following json:
"""
{
"error": "No record found for \"nonexisting@localhost\"."
}
"""

38
features/world.js Normal file
View File

@ -0,0 +1,38 @@
import { setWorldConstructor } from 'cucumber'
import request from 'request'
class CustomWorld {
constructor () {
// webFinger.feature
this.lastResponses = []
this.lastContentType = null
this.lastInboxUrl = null
this.lastActivity = null
// object-article.feature
this.statusCode = null
}
get (pathname) {
return new Promise((resolve, reject) => {
request(`http://localhost:4000/${this.replaceSlashes(pathname)}`, {
headers: {
'Accept': 'application/activity+json'
}}, (error, response, body) => {
if (!error) {
resolve({
lastResponse: body,
lastContentType: response.headers['content-type'],
statusCode: response.statusCode
})
} else {
reject(error)
}
})
})
}
replaceSlashes (pathname) {
return pathname.replace(/^\/+/, '')
}
}
setWorldConstructor(CustomWorld)

View File

@ -1,7 +1,7 @@
{
"name": "nitro-cypress",
"name": "human-connection",
"version": "0.1.11",
"description": "Fullstack tests with cypress for Human Connection",
"description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh",
"license": "MIT",
"cypress-cucumber-preprocessor": {
@ -16,19 +16,26 @@
"cypress:setup": "run-p cypress:backend cypress:webapp",
"cypress:run": "cross-env cypress run --browser chromium",
"cypress:open": "cross-env cypress open --browser chromium",
"cucumber:setup": "cd backend && yarn run dev",
"cucumber": "wait-on tcp:4000 && cucumber-js --require-module @babel/register --exit",
"version": "auto-changelog -p"
},
"devDependencies": {
"@babel/core": "^7.7.2",
"@babel/preset-env": "^7.7.4",
"@babel/register": "^7.7.4",
"auto-changelog": "^1.16.2",
"bcryptjs": "^2.4.3",
"codecov": "^3.6.1",
"cross-env": "^6.0.3",
"cucumber": "^6.0.5",
"cypress": "^3.7.0",
"cypress-cucumber-preprocessor": "^1.17.0",
"cypress-file-upload": "^3.5.0",
"cypress-plugin-retries": "^1.5.0",
"date-fns": "^2.8.1",
"dotenv": "^8.2.0",
"expect": "^24.9.0",
"faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.6",

1075
yarn.lock

File diff suppressed because it is too large Load Diff