Merge remote-tracking branch 'origin/master' into 296-image_component

This commit is contained in:
Robert Schäfer 2019-05-10 13:30:06 +02:00
commit 1db35be50f
126 changed files with 4458 additions and 3250 deletions

169
.codecov.yml Normal file
View File

@ -0,0 +1,169 @@
codecov:
#token: uuid # Your private repository token
#url: "http" # for Codecov Enterprise customers
#slug: "owner/repo" # for Codecov Enterprise customers
#branch: master # override the default branch
#bot: username # set user whom will be the consumer of oauth requests
#ci: # Custom CI domains if Codecov does not identify them automatically
# - ci.domain.com
# - !provider # ignore these providers when checking if CI passed
# # ex. You may test on Travis, Circle, and AppVeyor, but only need
# # to check if Travis passes. Therefore add: !circle and !appveyor
notify:
#after_n_builds: null # number of expected builds to recieve before sending notifications
# # after: check ci status unless disabled via require_ci_to_pass
require_ci_to_pass: yes # yes: will delay sending notifications until all ci is finished
# no: will send notifications without checking ci status and wait till "after_n_builds" are uploaded
#countdown: null # number of seconds to wait before first ci build check
#delay: null # number of seconds to wait between ci build checks
coverage:
precision: 2 # 2 = xx.xx%, 0 = xx%
round: nearest # down|up|nearest - default down
# range: 50...60 # default 70...90. red...green
#notify:
# irc:
# default:
# server: "chat.freenode.net"|encrypted
# branches: null # all branches by default
# threshold: 1%
# message: "Coverage {{changed}} for {{owner}}/{{repo}}" # customize the message
# flags: null
# paths: null
#
# slack:
# default:
# url: "http"|encrypted
# threshold: 1%
# branches: null # all branches by default
# message: "Coverage {{changed}} for {{owner}}/{{repo}}" # customize the message
# attachments: "sunburst, diff"
# only_pulls: false
# flags: null
# paths: null
#
# email:
# default:
# to:
# - example@domain.com
# - &author
# threshold: 1%
# only_pulls: false
# layout: header, diff, trends
# flags: null
# paths: null
#
# hipchat:
# default:
# url: "http"|encrypted
# room: name|id
# threshold: 1%
# token: encrypted
# branches: null # all branches by default
# notify: false # if the hipchat message is silent or loud (default false)
# message: "Coverage {{changed}} for {{owner}}/{{repo}}" # customize the message
# flags: null
# paths: null
#
# gitter:
# url: "http"|encrypted
# threshold: 1%
# branches: null # all branches by default
# message: "Coverage {{changed}} for {{owner}}/{{repo}}" # customize the message
#
# webhooks:
# _name_:
# url: "http"|encrypted
# threshold: 1%
# branches: null # all branches by default
status:
project:
default: false # disable the default status that measures entire project
backend: # declare a new status context "backend"
against: parent
target: auto
threshold: null
#threshold: 1%
base: auto
if_no_uploads: error
if_not_found: success
if_ci_failed: error
only_pulls: false
#branches:
# - master
#flags:
# - integration
paths:
- backend/ # only include coverage in "backend/" folder
webapp: # declare a new status context "frontend"
against: parent
target: auto
threshold: null
#threshold: 1%
base: auto
if_no_uploads: error
if_not_found: success
if_ci_failed: error
only_pulls: false
#branches:
# - master
#flags:
# - integration
paths:
- webapp/ # only include coverage in "webapp/" folder
patch:
default: false
# against: parent
# target: 80%
# branches: null
# if_no_uploads: success
# if_not_found: success
# if_ci_failed: error
# only_pulls: false
# flags:
# - integration
# paths:
# - folder
#changes:
# default:
# against: parent
# branches: null
# if_no_uploads: error
# if_not_found: success
# if_ci_failed: error
# only_pulls: false
# flags:
# - integration
# paths:
# - folder
#flags:
# integration:
# branches:
# - master
# ignore:
# - app/ui
#ignore: # files and folders for processing
# - tests/*
#fixes:
# - "old_path::new_path"
comment:
# layout options are quite limited in v4.x - there have been way more options in v1.0
layout: reach, diff, flags, files # mostly old options: header, diff, uncovered, reach, files, tree, changes, sunburst, flags
behavior: new # default = posts once then update, posts new if delete
# once = post once then updates
# new = delete old, post new
# spammy = post new
require_changes: false # if true: only post the comment if coverage changes
require_base: no # [yes :: must have a base report to post]
require_head: no # [yes :: must have a head report to post]
branches: null # branch names that can post comment
flags: null
paths: null

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ cypress/screenshots/
cypress.env.json
!.gitkeep
**/coverage

View File

@ -10,6 +10,8 @@ addons:
before_install:
- yarn global add wait-on
# Install Codecov
- yarn global add codecov
- yarn install
- cp cypress.env.template.json cypress.env.json
@ -18,6 +20,7 @@ install:
- wait-on http://localhost:7474 && docker-compose exec neo4j migrate
script:
# Backend
- docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false
- docker-compose exec backend yarn run db:reset
@ -25,10 +28,14 @@ script:
- docker-compose exec backend yarn run test:cucumber
- 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
- docker-compose exec -d backend yarn run test:before:seeder
- yarn run cypress:run
# Fullstack
- CYPRESS_RETRIES=1 yarn run cypress:run
# Coverage
- codecov
after_success:
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh

View File

@ -1,6 +1,7 @@
# Human-Connection
[![Build Status](https://travis-ci.com/Human-Connection/Human-Connection.svg?branch=master)](https://travis-ci.com/Human-Connection/Human-Connection)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/Human-Connection/Human-Connection/master.svg?style=flat-square)](https://codecov.io/gh/Human-Connection/Human-Connection/)
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Human-Connection/Nitro-Backend/blob/backend/LICENSE.md)
[![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discord.gg/6ub73U3)

View File

@ -5,7 +5,6 @@
* [Installation](installation.md)
* [Backend](backend/README.md)
* [GraphQL](backend/graphql.md)
* [Legacy Migration](backend/db-migration-worker/README.md)
* [Webapp](webapp/README.md)
* [COMPONENTS](webapp/components.md)
* [PLUGINS](webapp/plugins.md)
@ -21,7 +20,15 @@
* [Frontend tests](webapp/testing.md)
* [Backend tests](backend/testing.md)
* [Contributing](CONTRIBUTING.md)
* [Deployment](deployment/README.md)
* [Kubernetes Deployment](deployment/README.md)
* [Minikube](deployment/minikube/README.md)
* [Digital Ocean](deployment/digital-ocean/README.md)
* [Kubernetes Dashboard](deployment/digital-ocean/dashboard/README.md)
* [HTTPS](deployment/digital-ocean/https/README.md)
* [Human Connection](deployment/human-connection/README.md)
* [Volumes](deployment/volumes/README.md)
* [Neo4J DB Backup](deployment/backup.md)
* [Legacy Migration](deployment/legacy-migration/README.md)
* [Feature Specification](cypress/features.md)
* [Code of conduct](CODE_OF_CONDUCT.md)
* [License](LICENSE.md)

View File

@ -15,7 +15,7 @@ node_modules/
scripts/
dist/
db-migration-worker/
maintenance-worker/
neo4j/
public/uploads/*

View File

@ -1,13 +0,0 @@
FROM mongo:4
RUN apt-get update && apt-get -y install --no-install-recommends wget apt-transport-https \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN wget -O - https://debian.neo4j.org/neotechnology.gpg.key | apt-key add -
RUN echo 'deb https://debian.neo4j.org/repo stable/' | tee /etc/apt/sources.list.d/neo4j.list
RUN apt-get update && apt-get -y install --no-install-recommends openjdk-8-jre openssh-client neo4j rsync \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY migration ./migration
COPY migrate.sh /usr/local/bin/migrate
COPY sync_uploads.sh /usr/local/bin/sync_uploads

View File

@ -1,40 +0,0 @@
# Legacy Migration
This guide helps you to import data from our legacy servers, which are using FeathersJS and MongoDB.
**You can skip this if you don't plan to migrate any legacy applications!**
## Prerequisites
You need [docker](https://www.docker.com/) installed on your machine. Furthermore you need SSH access to the server and you need to know the following login credentials and server settings:
| Environment variable | Description |
| :--- | :--- |
| SSH\_USERNAME | Your ssh username on the server |
| SSH\_HOST | The IP address of the server |
| MONGODB\_USERNAME | Mongo username on the server |
| MONGODB\_PASSWORD | Mongo password on the server |
| MONGODB\_AUTH\_DB | Mongo authentication database |
| MONGODB\_DATABASE | The name of the mongo database |
| UPLOADS\_DIRECTORY | Path to remote uploads folder |
## Run the database migration
Run `docker-compose` with all environment variables specified:
```bash
SSH_USERNAME=username SSH_HOST=some.server.com MONGODB_USERNAME='hc-api' MONGODB_PASSWORD='secret' MONGODB_DATABASE=hc_api MONGODB_AUTH_DB=hc_api UPLOADS_DIRECTORY=/var/www/api/uploads docker-compose up
```
Download the remote mongo database:
```bash
docker-compose exec db-migration-worker ./import.sh
```
Import the local download into Neo4J:
```bash
docker-compose exec neo4j import/import.sh
```

View File

@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
echo "MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r;" | cypher-shell -a $NEO4J_URI
for collection in "badges" "categories" "users" "follows" "contributions" "shouts" "comments"
do
echo "Import ${collection}..." && cypher-shell -a $NEO4J_URI < $SCRIPT_DIRECTORY/$collection.cql
done

View File

@ -1,18 +0,0 @@
version: "3.7"
services:
neo4j:
environment:
- NEO4J_AUTH=none
ports:
- 7687:7687
- 7474:7474
backend:
ports:
- 4001:4001
- 4123:4123
image: humanconnection/nitro-backend:builder
build:
context: .
target: builder
command: yarn run test:cypress

View File

@ -1,36 +0,0 @@
version: "3.7"
services:
backend:
volumes:
- uploads:/nitro-backend/public/uploads
neo4j:
volumes:
- mongo-export:/mongo-export
environment:
- NEO4J_apoc_import_file_enabled=true
db-migration-worker:
build:
context: db-migration-worker
volumes:
- mongo-export:/mongo-export
- uploads:/uploads
- ./db-migration-worker/migration/:/migration
- ./db-migration-worker/.ssh/:/root/.ssh/
networks:
- hc-network
depends_on:
- backend
environment:
- NEO4J_URI=bolt://neo4j:7687
- "SSH_USERNAME=${SSH_USERNAME}"
- "SSH_HOST=${SSH_HOST}"
- "MONGODB_USERNAME=${MONGODB_USERNAME}"
- "MONGODB_PASSWORD=${MONGODB_PASSWORD}"
- "MONGODB_AUTH_DB=${MONGODB_AUTH_DB}"
- "MONGODB_DATABASE=${MONGODB_DATABASE}"
- "UPLOADS_DIRECTORY=${UPLOADS_DIRECTORY}"
volumes:
mongo-export:
uploads:

View File

@ -1,23 +0,0 @@
version: "3.7"
services:
backend:
image: humanconnection/nitro-backend:builder
build:
context: .
target: builder
volumes:
- .:/nitro-backend
- /nitro-backend/node_modules
command: yarn run dev
neo4j:
environment:
- NEO4J_AUTH=none
ports:
- 7687:7687
- 7474:7474
volumes:
- neo4j-data:/data
volumes:
neo4j-data:

View File

@ -1,9 +0,0 @@
version: "3.7"
services:
neo4j:
environment:
- NEO4J_PASSWORD=letmein
backend:
environment:
- NEO4J_PASSWORD=letmein

View File

@ -1,14 +0,0 @@
version: "3.7"
services:
neo4j:
environment:
- NEO4J_AUTH=none
ports:
- 7687:7687
- 7474:7474
backend:
image: humanconnection/nitro-backend:builder
build:
context: .
target: builder

View File

@ -1,34 +0,0 @@
version: "3.7"
services:
backend:
image: humanconnection/nitro-backend:latest
build:
context: .
target: production
networks:
- hc-network
depends_on:
- neo4j
ports:
- 4000:4000
environment:
- NEO4J_URI=bolt://neo4j:7687
- GRAPHQL_PORT=4000
- GRAPHQL_URI=http://localhost:4000
- CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd
- MOCK=false
- MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
neo4j:
image: humanconnection/neo4j:latest
build:
context: neo4j
networks:
- hc-network
networks:
hc-network:
name: hc-network

View File

@ -1,18 +0,0 @@
#!/usr/bin/env bash
# If the user has the password `neo4j` this is a strong indicator, that we are
# the initial default user. Before we can create constraints, we have to change
# the default password. This is a security feature of neo4j.
if echo ":exit" | cypher-shell --password neo4j 2> /dev/null ; then
echo "CALL dbms.security.changePassword('${NEO4J_PASSWORD}');" | cypher-shell --password neo4j
fi
set -e
echo '
CALL db.index.fulltext.createNodeIndex("full_text_search",["Post"],["title", "content"]);
CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
' | cypher-shell

View File

@ -15,7 +15,7 @@
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
"test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --",
"test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
"db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js",
@ -26,6 +26,18 @@
"license": "MIT",
"jest": {
"verbose": true,
"collectCoverage": true,
"collectCoverageFrom": [
"**/*.js",
"!**/node_modules/**",
"!**/test/**",
"!**/dist/**",
"!**/src/**/?(*.)+(spec|test).js?(x)"
],
"coverageReporters": [
"text",
"lcov"
],
"testMatch": [
"**/src/**/?(*.)+(spec|test).js?(x)"
]
@ -36,62 +48,62 @@
"apollo-client": "~2.5.1",
"apollo-link-context": "~1.0.14",
"apollo-link-http": "~1.5.14",
"apollo-server": "~2.4.8",
"apollo-server": "~2.5.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
"date-fns": "2.0.0-alpha.27",
"debug": "~4.1.1",
"dotenv": "~7.0.0",
"dotenv": "~8.0.0",
"express": "~4.16.4",
"faker": "~4.1.0",
"graphql": "~14.2.1",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2",
"graphql-shield": "~5.3.4",
"graphql-shield": "~5.3.5",
"graphql-tag": "~2.10.1",
"graphql-yoga": "~1.17.4",
"helmet": "~3.16.0",
"helmet": "~3.18.0",
"jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.11",
"ms": "~2.1.1",
"neo4j-driver": "~1.7.3",
"neo4j-driver": "~1.7.4",
"neo4j-graphql-js": "~2.4.2",
"node-fetch": "~2.3.0",
"node-fetch": "~2.5.0",
"npm-run-all": "~4.1.5",
"request": "~2.88.0",
"sanitize-html": "~1.20.0",
"sanitize-html": "~1.20.1",
"slug": "~1.1.0",
"trunc-html": "~1.1.2",
"uuid": "~3.3.2",
"wait-on": "~3.2.0"
},
"devDependencies": {
"@babel/cli": "~7.4.3",
"@babel/core": "~7.4.3",
"@babel/cli": "~7.4.4",
"@babel/core": "~7.4.4",
"@babel/node": "~7.2.2",
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.4.3",
"@babel/register": "~7.4.0",
"@babel/preset-env": "~7.4.4",
"@babel/register": "~7.4.4",
"apollo-server-testing": "~2.4.8",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.1",
"babel-jest": "~24.7.1",
"babel-jest": "~24.8.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
"eslint": "~5.16.0",
"eslint-config-standard": "~12.0.0",
"eslint-plugin-import": "~2.17.2",
"eslint-plugin-jest": "~22.4.1",
"eslint-plugin-node": "~8.0.1",
"eslint-plugin-jest": "~22.5.1",
"eslint-plugin-node": "~9.0.1",
"eslint-plugin-promise": "~4.1.1",
"eslint-plugin-standard": "~4.0.0",
"graphql-request": "~1.8.2",
"jest": "~24.7.1",
"nodemon": "~1.18.11",
"jest": "~24.8.0",
"nodemon": "~1.19.0",
"supertest": "~4.0.2"
}
}
}

View File

@ -11,6 +11,7 @@ import shout from './resolvers/shout.js'
import rewards from './resolvers/rewards.js'
import socialMedia from './resolvers/socialMedia.js'
import notifications from './resolvers/notifications'
import comments from './resolvers/comments'
export const typeDefs = fs
.readFileSync(
@ -22,7 +23,8 @@ export const resolvers = {
Query: {
...statistics.Query,
...userManagement.Query,
...notifications.Query
...notifications.Query,
...comments.Query
},
Mutation: {
...userManagement.Mutation,
@ -33,6 +35,7 @@ export const resolvers = {
...shout.Mutation,
...rewards.Mutation,
...socialMedia.Mutation,
...notifications.Mutation
...notifications.Mutation,
...comments.Mutation
}
}

View File

@ -75,6 +75,7 @@ const permissions = shield({
DeleteBadge: isAdmin,
AddUserBadges: isAdmin,
CreateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated,
// AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,
@ -86,7 +87,8 @@ const permissions = shield({
unshout: isAuthenticated,
changePassword: isAuthenticated,
enable: isModerator,
disable: isModerator
disable: isModerator,
CreateComment: isAuthenticated
// CreateUser: allow,
},
User: {

View File

@ -40,10 +40,13 @@ describe('authorization', () => {
})
it('does not expose the owner\'s email address', async () => {
let response = {}
try {
await action()
} catch (error) {
expect(error.response.data).toEqual({ User: [ { email: null } ] })
response = error.response.data
} finally {
expect(response).toEqual({ User: [ null ] })
}
})
})
@ -74,11 +77,13 @@ describe('authorization', () => {
})
it('does not expose the owner\'s email address', async () => {
let response
try {
await action()
} catch (error) {
expect(error.response.data).toEqual({ User: [ { email: null } ] })
response = error.response.data
}
expect(response).toEqual({ User: [ null ] })
})
})
})

View File

@ -1,6 +1,6 @@
import slugify from 'slug'
export default async function uniqueSlug (string, isUnique) {
let slug = slugify(string, {
let slug = slugify(string || 'anonymous', {
lower: true
})
if (await isUnique(slug)) return slug

View File

@ -15,4 +15,11 @@ describe('uniqueSlug', () => {
.mockResolvedValueOnce(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1')
})
it('slugify null string', () => {
const string = null
const isUnique = jest.fn()
.mockResolvedValue(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
})
})

View File

@ -77,7 +77,7 @@ describe('slugify', () => {
describe('CreateUser', () => {
const action = async (mutation, params) => {
return authenticatedClient.request(`mutation {
${mutation}(password: "yo", ${params}) { slug }
${mutation}(password: "yo", email: "123@123.de", ${params}) { slug }
}`)
}
it('generates a slug based on name', async () => {

View File

@ -23,21 +23,19 @@ beforeAll(async () => {
])
await Promise.all([
factory.create('Comment', { id: 'c2', content: 'Enabled comment on public post' })
factory.create('Comment', { id: 'c2', postId: 'p3', content: 'Enabled comment on public post' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' }),
factory.relate('Comment', 'Post', { from: 'c2', to: 'p3' })
factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' })
])
const asTroll = Factory()
await asTroll.authenticateAs({ email: 'troll@example.org', password: '1234' })
await asTroll.create('Post', { id: 'p2', title: 'Disabled post', content: 'This is an offensive post content', image: '/some/offensive/image.jpg', deleted: false })
await asTroll.create('Comment', { id: 'c1', content: 'Disabled comment' })
await asTroll.create('Comment', { id: 'c1', postId: 'p3', content: 'Disabled comment' })
await Promise.all([
asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' }),
asTroll.relate('Comment', 'Post', { from: 'c1', to: 'p3' })
asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' })
])
const asModerator = Factory()

View File

@ -1,5 +1,7 @@
import createOrUpdateLocations from './nodes/locations'
import dotenv from 'dotenv'
import { UserInputError } from 'apollo-server'
import createOrUpdateLocations from './nodes/locations'
dotenv.config()
@ -11,6 +13,10 @@ export default {
return result
},
UpdateUser: async (resolve, root, args, context, info) => {
const USERNAME_MIN_LENGTH = 3 // TODO move to the correct place
if (!args.name || args.name.length < USERNAME_MIN_LENGTH) {
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} characters long!`)
}
const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver)
return result

View File

@ -0,0 +1,81 @@
import { GraphQLClient } from 'graphql-request'
import { host } from '../jest/helpers'
import Factory from '../seed/factories'
const factory = Factory()
let client
afterAll(async () => {
await factory.cleanDatabase()
})
describe('userMiddleware', () => {
describe('create User', () => {
const mutation = `
mutation($id: ID, $password: String!, $email: String!) {
CreateUser(id: $id, password: $password, email: $email) {
id
}
}
`
client = new GraphQLClient(host)
it('with password and email', async () => {
const variables = {
password: '123',
email: '123@123.de'
}
const expected = {
CreateUser: {
id: expect.any(String)
}
}
await expect(client.request(mutation, variables))
.resolves.toEqual(expected)
})
})
describe('update User', () => {
const mutation = `
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
}
}
`
client = new GraphQLClient(host)
// TODO why is this failing - it returns { UpdateUser: null } - that should not be
/* it('name within specifications', async () => {
const variables = {
id: 'u1',
name: 'Peter Lustig'
}
const expected = {
UpdateUser: {
name: 'Peter Lustig'
}
}
await expect(client.request(mutation, variables))
.resolves.toEqual(expected)
}) */
it('with no name', async () => {
const variables = {
id: 'u1'
}
const expected = 'Username must be at least 3 characters long!'
await expect(client.request(mutation, variables))
.rejects.toThrow(expected)
})
it('with too short name', async () => {
const variables = {
id: 'u1'
}
const expected = 'Username must be at least 3 characters long!'
await expect(client.request(mutation, variables))
.rejects.toThrow(expected)
})
})
})

View File

@ -0,0 +1,55 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import { UserInputError } from 'apollo-server'
const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
export default {
Mutation: {
CreateComment: async (object, params, context, resolveInfo) => {
const content = params.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = params
// Adding relationship from comment to post by passing in the postId,
// but we do not want to create the comment with postId as an attribute
// because we use relationships for this. So, we are deleting it from params
// before comment creation.
delete params.postId
if (!params.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
if (!postId.trim()) {
throw new UserInputError(NO_POST_ERR_MESSAGE)
}
const session = context.driver.session()
const postQueryRes = await session.run(`
MATCH (post:Post {id: $postId})
RETURN post`, {
postId
}
)
const [post] = postQueryRes.records.map(record => {
return record.get('post')
})
if (!post) {
throw new UserInputError(NO_POST_ERR_MESSAGE)
}
const comment = await neo4jgraphql(object, params, context, resolveInfo, false)
await session.run(`
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId})
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN post`, {
userId: context.user.id,
postId,
commentId: comment.id
}
)
session.close()
return comment
}
}
}

View File

@ -0,0 +1,178 @@
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
let client
let createCommentVariables
let createPostVariables
let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost
beforeEach(async () => {
await factory.create('User', {
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('CreateComment', () => {
const createCommentMutation = `
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
const createPostMutation = `
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
const commentQueryForPostId = `
query($content: String) {
Comment(content: $content) {
postId
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
createCommentVariables = {
postId: 'p1',
content: 'I\'m not authorised to comment'
}
client = new GraphQLClient(host)
await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow('Not Authorised')
})
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
createCommentVariables = {
postId: 'p1',
content: 'I\'m authorised to comment'
}
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me'
}
await client.request(createPostMutation, createPostVariables)
})
it('creates a comment', async () => {
const expected = {
CreateComment: {
content: 'I\'m authorised to comment'
}
}
await expect(client.request(createCommentMutation, createCommentVariables)).resolves.toMatchObject(expected)
})
it('assigns the authenticated user as author', async () => {
await client.request(createCommentMutation, createCommentVariables)
const { User } = await client.request(`{
User(email: "test@example.org") {
comments {
content
}
}
}`)
expect(User).toEqual([ { comments: [ { content: 'I\'m authorised to comment' } ] } ])
})
it('throw an error if an empty string is sent from the editor as content', async () => {
createCommentVariables = {
postId: 'p1',
content: '<p></p>'
}
await expect(client.request(createCommentMutation, createCommentVariables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
it('throws an error if a comment sent from the editor does not contain a single character', async () => {
createCommentVariables = {
postId: 'p1',
content: '<p> </p>'
}
await expect(client.request(createCommentMutation, createCommentVariables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
it('throws an error if postId is sent as an empty string', async () => {
createCommentVariables = {
postId: 'p1',
content: ''
}
await expect(client.request(createCommentMutation, createCommentVariables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
it('throws an error if content is sent as an string of empty characters', async () => {
createCommentVariables = {
postId: 'p1',
content: ' '
}
await expect(client.request(createCommentMutation, createCommentVariables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
it('throws an error if postId is sent as an empty string', async () => {
createCommentVariablesSansPostId = {
postId: '',
content: 'this comment should not be created'
}
await expect(client.request(createCommentMutation, createCommentVariablesSansPostId))
.rejects.toThrow('Comment cannot be created without a post!')
})
it('throws an error if postId is sent as an string of empty characters', async () => {
createCommentVariablesSansPostId = {
postId: ' ',
content: 'this comment should not be created'
}
await expect(client.request(createCommentMutation, createCommentVariablesSansPostId))
.rejects.toThrow('Comment cannot be created without a post!')
})
it('throws an error if the post does not exist in the database', async () => {
createCommentVariablesWithNonExistentPost = {
postId: 'p2',
content: 'comment should not be created cause the post doesn\'t exist'
}
await expect(client.request(createCommentMutation, createCommentVariablesWithNonExistentPost))
.rejects.toThrow('Comment cannot be created without a post!')
})
it('does not create the comment with the postId as an attribute', async () => {
const commentQueryVariablesByContent = {
content: 'I\'m authorised to comment'
}
await client.request(createCommentMutation, createCommentVariables)
const { Comment } = await client.request(commentQueryForPostId, commentQueryVariablesByContent)
expect(Comment).toEqual([{ postId: null }])
})
})
})

View File

@ -16,6 +16,9 @@ const setupAuthenticateClient = (params) => {
let createResource
let authenticateClient
let createPostVariables
let createCommentVariables
beforeEach(() => {
createResource = () => {}
authenticateClient = () => {
@ -103,18 +106,21 @@ describe('disable', () => {
variables = {
id: 'c47'
}
createPostVariables = {
id: 'p3',
title: 'post to comment on',
content: 'please comment on me'
}
createCommentVariables = {
id: 'c47',
postId: 'p3',
content: 'this comment was created for this post'
}
createResource = async () => {
await factory.create('User', { id: 'u45', email: 'commenter@example.org', password: '1234' })
await factory.authenticateAs({ email: 'commenter@example.org', password: '1234' })
await Promise.all([
factory.create('Post', { id: 'p3' }),
factory.create('Comment', { id: 'c47' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' }),
factory.relate('Comment', 'Post', { from: 'c47', to: 'p3' })
])
const asAuthenticatedUser = await factory.authenticateAs({ email: 'commenter@example.org', password: '1234' })
await asAuthenticatedUser.create('Post', createPostVariables)
await asAuthenticatedUser.create('Comment', createCommentVariables)
}
})
@ -277,18 +283,21 @@ describe('enable', () => {
variables = {
id: 'c456'
}
createPostVariables = {
id: 'p9',
title: 'post to comment on',
content: 'please comment on me'
}
createCommentVariables = {
id: 'c456',
postId: 'p9',
content: 'this comment was created for this post'
}
createResource = async () => {
await factory.create('User', { id: 'u123', email: 'author@example.org', password: '1234' })
await factory.authenticateAs({ email: 'author@example.org', password: '1234' })
await Promise.all([
factory.create('Post', { id: 'p9' }),
factory.create('Comment', { id: 'c456' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' }),
factory.relate('Comment', 'Post', { from: 'c456', to: 'p9' })
])
const asAuthenticatedUser = await factory.authenticateAs({ email: 'author@example.org', password: '1234' })
await asAuthenticatedUser.create('Post', createPostVariables)
await asAuthenticatedUser.create('Comment', createCommentVariables)
const disableMutation = `
mutation {

View File

@ -9,6 +9,7 @@ describe('report', () => {
let headers
let returnedObject
let variables
let createPostVariables
beforeEach(async () => {
returnedObject = '{ description }'
@ -128,8 +129,14 @@ describe('report', () => {
describe('reported resource is a comment', () => {
beforeEach(async () => {
await factory.authenticateAs({ email: 'test@example.org', password: '1234' })
await factory.create('Comment', { id: 'c34', content: 'Robert getting tired.' })
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me'
}
const asAuthenticatedUser = await factory.authenticateAs({ email: 'test@example.org', password: '1234' })
await asAuthenticatedUser.create('Post', createPostVariables)
await asAuthenticatedUser.create('Comment', { postId: 'p1', id: 'c34', content: 'Robert getting tired.' })
variables = { id: 'c34' }
})

View File

@ -3,7 +3,10 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, true)
/**
* TODO?: Creates double Nodes!
*/
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
@ -15,6 +18,11 @@ export default {
)
session.close()
return socialMedia
},
DeleteSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
return socialMedia
}
}

View File

@ -7,9 +7,18 @@ const factory = Factory()
describe('CreateSocialMedia', () => {
let client
let headers
const mutation = `
const mutationC = `
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const mutationD = `
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
@ -30,20 +39,63 @@ describe('CreateSocialMedia', () => {
await factory.cleanDatabase()
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
const variables = { url: 'http://nsosp.org' }
await expect(
client.request(mutationC, variables)
).rejects.toThrow('Not Authorised')
})
})
describe('authenticated', () => {
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
it('creates social media with correct URL', async () => {
const variables = { url: 'http://nsosp.org' }
await expect(
client.request(mutationC, variables)
).resolves.toEqual(expect.objectContaining({
CreateSocialMedia: {
id: expect.any(String),
url: 'http://nsosp.org'
}
}))
})
it('deletes social media', async () => {
const creationVariables = { url: 'http://nsosp.org' }
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
const deletionVariables = { id }
const expected = {
DeleteSocialMedia: {
id: id,
url: 'http://nsosp.org'
}
}
await expect(
client.request(mutationD, deletionVariables)
).resolves.toEqual(expected)
})
it('rejects empty string', async () => {
const variables = { url: '' }
await expect(client.request(mutation, variables)).rejects.toThrow('Input is not a URL')
await expect(
client.request(mutationC, variables)
).rejects.toThrow('Input is not a URL')
})
it('validates URLs', async () => {
const variables = { url: 'not-a-url' }
await expect(client.request(mutation, variables)).rejects.toThrow('Input is not a URL')
await expect(
client.request(mutationC, variables)
).rejects.toThrow('Input is not a URL')
})
})
})

View File

@ -339,7 +339,7 @@ describe('do not expose private RSA key', () => {
email: 'apfel-strudel@test.org'
}
await client.request(gql`
mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String) {
mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String!) {
CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) {
id
}

View File

@ -16,6 +16,7 @@ type Query {
LIMIT $limit
"""
)
CommentByPost(postId: ID!): [Comment]!
}
type Mutation {
# Get a JWT Token for the given Email and password
@ -94,7 +95,7 @@ type User {
id: ID!
actorId: String
name: String
email: String
email: String!
slug: String
password: String!
avatar: String
@ -210,6 +211,7 @@ type Post {
type Comment {
id: ID!
activityId: String
postId: ID
author: User @relation(name: "WROTE", direction: "IN")
content: String!
contentExcerpt: String

View File

@ -4,6 +4,7 @@ import uuid from 'uuid/v4'
export default function (params) {
const {
id = uuid(),
postId = 'p6',
content = [
faker.lorem.sentence(),
faker.lorem.sentence()
@ -12,12 +13,12 @@ export default function (params) {
return {
mutation: `
mutation($id: ID!, $content: String!) {
CreateComment(id: $id, content: $content) {
mutation($id: ID!, $postId: ID, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`,
variables: { id, content }
variables: { id, postId, content }
}
}

View File

@ -20,7 +20,7 @@ export default function create (params) {
$name: String
$slug: String
$password: String!
$email: String
$email: String!
$avatar: String
$about: String
$role: UserGroupEnum

View File

@ -189,45 +189,18 @@ import Factory from './factories'
])
await Promise.all([
f.create('Comment', { id: 'c1' }),
f.create('Comment', { id: 'c2' }),
f.create('Comment', { id: 'c3' }),
f.create('Comment', { id: 'c4' }),
f.create('Comment', { id: 'c5' }),
f.create('Comment', { id: 'c6' }),
f.create('Comment', { id: 'c7' }),
f.create('Comment', { id: 'c8' }),
f.create('Comment', { id: 'c9' }),
f.create('Comment', { id: 'c10' }),
f.create('Comment', { id: 'c11' }),
f.create('Comment', { id: 'c12' })
])
await Promise.all([
f.relate('Comment', 'Author', { from: 'u3', to: 'c1' }),
f.relate('Comment', 'Post', { from: 'c1', to: 'p1' }),
f.relate('Comment', 'Author', { from: 'u1', to: 'c2' }),
f.relate('Comment', 'Post', { from: 'c2', to: 'p1' }),
f.relate('Comment', 'Author', { from: 'u1', to: 'c3' }),
f.relate('Comment', 'Post', { from: 'c3', to: 'p3' }),
f.relate('Comment', 'Author', { from: 'u4', to: 'c4' }),
f.relate('Comment', 'Post', { from: 'c4', to: 'p2' }),
f.relate('Comment', 'Author', { from: 'u4', to: 'c5' }),
f.relate('Comment', 'Post', { from: 'c5', to: 'p3' }),
f.relate('Comment', 'Author', { from: 'u3', to: 'c6' }),
f.relate('Comment', 'Post', { from: 'c6', to: 'p4' }),
f.relate('Comment', 'Author', { from: 'u2', to: 'c7' }),
f.relate('Comment', 'Post', { from: 'c7', to: 'p2' }),
f.relate('Comment', 'Author', { from: 'u5', to: 'c8' }),
f.relate('Comment', 'Post', { from: 'c8', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u6', to: 'c9' }),
f.relate('Comment', 'Post', { from: 'c9', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u7', to: 'c10' }),
f.relate('Comment', 'Post', { from: 'c10', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u5', to: 'c11' }),
f.relate('Comment', 'Post', { from: 'c11', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u6', to: 'c12' }),
f.relate('Comment', 'Post', { from: 'c12', to: 'p15' })
asUser.create('Comment', { id: 'c1', postId: 'p1' }),
asTick.create('Comment', { id: 'c2', postId: 'p1' }),
asTrack.create('Comment', { id: 'c3', postId: 'p3' }),
asTrick.create('Comment', { id: 'c4', postId: 'p2' }),
asModerator.create('Comment', { id: 'c5', postId: 'p3' }),
asAdmin.create('Comment', { id: 'c6', postId: 'p4' }),
asUser.create('Comment', { id: 'c7', postId: 'p2' }),
asTick.create('Comment', { id: 'c8', postId: 'p15' }),
asTrick.create('Comment', { id: 'c9', postId: 'p15' }),
asTrack.create('Comment', { id: 'c10', postId: 'p15' }),
asUser.create('Comment', { id: 'c11', postId: 'p15' }),
asUser.create('Comment', { id: 'c12', postId: 'p15' })
])
const disableMutation = 'mutation($id: ID!) { disable(id: $id) }'

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -0,0 +1,25 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
const narratorAvatar = 'https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg'
Then('I click on the {string} button', text => {
cy.get('button').contains(text).click()
})
Then('my comment should be successfully created', () => {
cy.get('.iziToast-message')
.contains('Comment Submitted')
})
Then('I should see my comment', () => {
cy.get('div.comment p')
.should('contain', 'Human Connection rocks')
.get('.ds-avatar img')
.should('have.attr', 'src')
.and('contain', narratorAvatar)
})
Then('the editor should be cleared', () => {
cy.get('.ProseMirror p')
.should('have.class', 'is-empty')
})

View File

@ -49,25 +49,20 @@ When('I click on "Report Post" from the content menu of the post', () => {
.click()
})
When(
'I click on "Report User" from the content menu in the user info box',
() => {
cy.contains('.ds-card', davidIrvingName)
.find('.content-menu-trigger')
.first()
.click({force: true})
When('I click on "Report User" from the content menu in the user info box', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
.get('.user-content-menu .content-menu-trigger')
.click({ force: true })
cy.get('.popover .ds-menu-item-link')
.contains('Report User')
.click()
}
)
cy.get('.popover .ds-menu-item-link')
.contains('Report User')
.click()
})
When('I click on the author', () => {
cy.get('a.user')
.first()
cy.get('.username')
.click()
.wait(200)
.url().should('include', '/profile/')
})
When('I report the author', () => {

View File

@ -77,7 +77,7 @@ Then('I should be on the {string} page', page => {
.should('contain', 'Social media')
})
Then('I add a social media link', () => {
When('I add a social media link', () => {
cy.get("input[name='social-media']")
.type('https://freeradical.zone/peter-pan')
.get('button')
@ -87,7 +87,7 @@ Then('I add a social media link', () => {
Then('it gets saved successfully', () => {
cy.get('.iziToast-message')
.should('contain', 'Updated user')
.should('contain', 'Added social media')
})
Then('the new social media link shows up on the page', () => {
@ -110,3 +110,13 @@ Then('they should be able to see my social media links', () => {
.get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1)
})
When('I delete a social media link', () => {
cy.get("a[name='delete']")
.click()
})
Then('it gets deleted successfully', () => {
cy.get('.iziToast-message')
.should('contain', 'Deleted social media')
})

View File

@ -11,6 +11,7 @@ let loginCredentials = {
}
const narratorParams = {
name: 'Peter Pan',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg',
...loginCredentials
}

View File

@ -1,6 +1,6 @@
Feature: Report and Moderate
As a user
I would like to report content that viloates the community guidlines
I would like to report content that violates the community guidlines
So the moderators can take action on it
As a moderator
@ -12,18 +12,19 @@ Feature: Report and Moderate
| Author | id | title | content |
| David Irving | p1 | The Truth about the Holocaust | It never existed! |
Scenario Outline: Report a post from various pages
Given I am logged in with a "user" role
When I see David Irving's post on the <Page>
And I click on "Report Post" from the content menu of the post
And I confirm the reporting dialog because it is a criminal act under German law:
"""
Do you really want to report the contribution "The Truth about the Holocaust"?
"""
"""
Do you really want to report the contribution "The Truth about the Holocaust"?
"""
Then I see a success message:
"""
Thanks for reporting!
"""
"""
Thanks for reporting!
"""
Examples:
| Page |
| landing page |
@ -35,13 +36,13 @@ Feature: Report and Moderate
When I click on the author
And I click on "Report User" from the content menu in the user info box
And I confirm the reporting dialog because he is a holocaust denier:
"""
Do you really want to report the user "David Irving"?
"""
"""
Do you really want to report the user "David Irving"?
"""
Then I see a success message:
"""
Thanks for reporting!
"""
"""
Thanks for reporting!
"""
Scenario: Review reported content
Given somebody reported the following posts:

View File

@ -0,0 +1,22 @@
Feature: Post Comment
As a user
I want to comment on contributions of others
To be able to express my thoughts and emotions about these, discuss, and add give further information.
Background:
Given we have the following posts in our database:
| id | title | slug |
| bWBjpkTKZp | 101 Essays that will change the way you think | 101-essays |
And I have a user account
And I am logged in
Scenario: Comment creation
Given I visit "post/bWBjpkTKZp/101-essays"
And I type in the following text:
"""
Human Connection rocks
"""
And I click on the "Comment" button
Then my comment should be successfully created
And I should see my comment
And the editor should be cleared

View File

@ -19,3 +19,11 @@ Feature: List Social Media Accounts
Given I have added a social media link
When people visit my profile page
Then they should be able to see my social media links
Scenario: Deleting Social Media
Given I am on the "settings" page
And I click on the "Social media" link
Then I should be on the "/settings/my-social-media" page
Given I have added a social media link
When I delete a social media link
Then it gets deleted successfully

View File

@ -14,8 +14,13 @@
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import './factories'
// intermittent failing tests
import 'cypress-plugin-retries'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -1,3 +1,4 @@
secrets.yaml
*/secrets.yaml
kubeconfig.yaml
configmap.yaml
**/secrets.yaml
**/configmap.yaml

View File

@ -4,223 +4,8 @@ We deploy with [kubernetes](https://kubernetes.io/). In order to deploy your own
network you have to [install kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
and get a kubernetes cluster.
We have tested two different kubernetes providers: [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
and [Digital Ocean](https://www.digitalocean.com/).
## Minikube
There are many Kubernetes providers, but if you're just getting started, Minikube is a tool that you can use to get your feet wet.
[Install Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
Open minikube dashboard:
```text
$ 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.
Follow the [installation instruction](deployment.md#installation-with-kubernetes) below. If all the pods and services have settled and everything looks green in your minikube dashboard, expose the `nitro-web` service on your host system with:
```text
$ minikube service nitro-web --namespace=human-connection
```
## Digital Ocean
1. At first, create a cluster on Digital Ocean.
2. Download the config.yaml if the process has finished.
3. Put the config file where you can find it later \(preferable in your home directory under `~/.kube/`\)
4. In the open terminal you can set the current config for the active session: `export KUBECONFIG=~/.kube/THE-NAME-OF-YOUR-CLUSTER-kubeconfig.yaml`. You could make this change permanent by adding the line to your `.bashrc` or `~/.config/fish/config.fish` depending on your shell.
Otherwise you would have to always add `--kubeconfig ~/.kube/THE-NAME-OF-YOUR-CLUSTER-kubeconfig.yaml` on every `kubectl` command that you are running.
5. Now check if you can connect to the cluster and if its your newly created one by running: `kubectl get nodes`
If you got the steps right above and see your nodes you can continue.
First, install kubernetes dashboard:
```bash
$ kubectl apply -f dashboard/
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/master/aio/deploy/recommended/kubernetes-dashboard.yaml
```
Get your token on the command line:
```bash
$ kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep admin-user | awk '{print $1}')
```
It should print something like:
```text
Name: admin-user-token-6gl6l
Namespace: kube-system
Labels: <none>
Annotations: kubernetes.io/service-account.name=admin-user
kubernetes.io/service-account.uid=b16afba9-dfec-11e7-bbb9-901b0e532516
Type: kubernetes.io/service-account-token
Data
====
ca.crt: 1025 bytes
namespace: 11 bytes
token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJrdWJlLXN5c3RlbSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJhZG1pbi11c2VyLXRva2VuLTZnbDZsIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQubmFtZSI6ImFkbWluLXVzZXIiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiJiMTZhZmJhOS1kZmVjLTExZTctYmJiOS05MDFiMGU1MzI1MTYiLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6a3ViZS1zeXN0ZW06YWRtaW4tdXNlciJ9.M70CU3lbu3PP4OjhFms8PVL5pQKj-jj4RNSLA4YmQfTXpPUuxqXjiTf094_Rzr0fgN_IVX6gC4fiNUL5ynx9KU-lkPfk0HnX8scxfJNzypL039mpGt0bbe1IXKSIRaq_9VW59Xz-yBUhycYcKPO9RM2Qa1Ax29nqNVko4vLn1_1wPqJ6XSq3GYI8anTzV8Fku4jasUwjrws6Cn6_sPEGmL54sq5R4Z5afUtv-mItTmqZZdxnkRqcJLlg2Y8WbCPogErbsaCDJoABQ7ppaqHetwfM_0yMun6ABOQbIwwl8pspJhpplKwyo700OSpvTT9zlBsu-b35lzXGBRHzv5g_RA
```
Proxy localhost to the remote kubernetes dashboard:
```bash
$ kubectl proxy
```
Grab the token from above and paste it into the login screen at [http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/](http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/)
## Installation with kubernetes
You have to do some prerequisites e.g. change some secrets according to your own setup.
### Edit secrets
```bash
$ cp secrets.template.yaml human-connection/secrets.yaml
```
Change all secrets as needed.
If you want to edit secrets, you have to `base64` encode them. See [kubernetes documentation](https://kubernetes.io/docs/concepts/configuration/secret/#creating-a-secret-manually).
```text
# example how to base64 a string:
$ echo -n 'admin' | base64
YWRtaW4=
```
Those secrets get `base64` decoded in a kubernetes pod.
### Create a namespace
```text
$ kubectl apply -f namespace-human-connection.yaml
```
Switch to the namespace `human-connection` in your kubernetes dashboard.
### Run the configuration
```text
$ kubectl apply -f human-connection/
```
This can take a while because kubernetes will download the docker images. Sit back and relax and have a look into your kubernetes dashboard. Wait until all pods turn green and they don't show a warning `Waiting: ContainerCreating` anymore.
#### Setup Ingress and HTTPS
Follow [this quick start guide](https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html) and install certmanager via helm and tiller:
```text
$ kubectl create serviceaccount tiller --namespace=kube-system
$ kubectl create clusterrolebinding tiller-admin --serviceaccount=kube-system:tiller --clusterrole=cluster-admin
$ helm init --service-account=tiller
$ helm repo update
$ helm install stable/nginx-ingress
$ kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.6/deploy/manifests/00-crds.yaml
$ helm install --name cert-manager --namespace cert-manager stable/cert-manager
```
Create letsencrypt issuers. _Change the email address_ in these files before running this command.
```bash
$ kubectl apply -f human-connection/https/
```
Create an ingress service in namespace `human-connection`. _Change the domain name_ according to your needs:
```bash
$ kubectl apply -f human-connection/ingress/
```
Check the ingress server is working correctly:
```bash
$ curl -kivL -H 'Host: <DOMAIN_NAME>' 'https://<IP_ADDRESS>'
```
If the response looks good, configure your domain registrar for the new IP address and the domain.
Now let's get a valid HTTPS certificate. According to the tutorial above, check your tls certificate for staging:
```bash
$ kubectl describe --namespace=human-connection certificate tls
$ kubectl describe --namespace=human-connection secret tls
```
If everything looks good, update the issuer of your ingress. Change the annotation `certmanager.k8s.io/issuer` from `letsencrypt-staging` to `letsencrypt-prod` in your ingress configuration in `human-connection/ingress/ingress.yaml`.
```bash
$ kubectl apply -f human-connection/ingress/ingress.yaml
```
Delete the former secret to force a refresh:
```text
$ kubectl --namespace=human-connection delete secret tls
```
Now, HTTPS should be configured on your domain. Congrats.
#### Legacy data migration
This setup is completely optional and only required if you have data on a server which is running our legacy code and you want to import that data. It will import the uploads folder and migrate a dump of mongodb into neo4j.
**Prepare migration of Human Connection legacy server**
Create a configmap with the specific connection data of your legacy server:
```bash
$ kubectl create configmap db-migration-worker \
--namespace=human-connection \
--from-literal=SSH_USERNAME=someuser \
--from-literal=SSH_HOST=yourhost \
--from-literal=MONGODB_USERNAME=hc-api \
--from-literal=MONGODB_PASSWORD=secretpassword \
--from-literal=MONGODB_AUTH_DB=hc_api \
--from-literal=MONGODB_DATABASE=hc_api \
--from-literal=UPLOADS_DIRECTORY=/var/www/api/uploads \
--from-literal=NEO4J_URI=bolt://localhost:7687
```
Create a secret with your public and private ssh keys. As the [kubernetes documentation](https://kubernetes.io/docs/concepts/configuration/secret/#use-case-pod-with-ssh-keys) points out, you should be careful with your ssh keys. Anyone with access to your cluster will have access to your ssh keys. Better create a new pair with `ssh-keygen` and copy the public key to your legacy server with `ssh-copy-id`:
```bash
$ kubectl create secret generic ssh-keys \
--namespace=human-connection \
--from-file=id_rsa=/path/to/.ssh/id_rsa \
--from-file=id_rsa.pub=/path/to/.ssh/id_rsa.pub \
--from-file=known_hosts=/path/to/.ssh/known_hosts
```
**Migrate legacy database**
Patch the existing deployments to use a multi-container setup:
```bash
cd legacy-migration
kubectl apply -f volume-claim-mongo-export.yaml
kubectl patch --namespace=human-connection deployment nitro-backend --patch "$(cat deployment-backend.yaml)"
kubectl patch --namespace=human-connection deployment nitro-neo4j --patch "$(cat deployment-neo4j.yaml)"
cd ..
```
Run the migration:
```text
$ kubectl --namespace=human-connection get pods
# change <POD_IDs> below
$ kubectl --namespace=human-connection exec -it nitro-neo4j-65bbdb597c-nc2lv migrate
$ kubectl --namespace=human-connection exec -it nitro-backend-c6cc5ff69-8h96z sync_uploads
```
We have tested two different kubernetes providers: [Minikube](./minikube/README.md)
and [Digital Ocean](./digital-ocean/README.md).
Check out the specific documentation for your provider. After that, learn how
to apply the specific kubernetes configuration for [Human Connection](./human-connection/README.md).

83
deployment/backup.md Normal file
View File

@ -0,0 +1,83 @@
# Backup (offline)
This tutorial explains how to carry out an offline backup of your Neo4J
database in a kubernetes cluster.
An offline backup requires the Neo4J database to be stopped. Read
[the docs](https://neo4j.com/docs/operations-manual/current/tools/dump-load/).
Neo4J also offers online backups but this is available in enterprise edition
only.
The tricky part is to stop the Neo4J database *without* stopping the container.
Neo4J's docker container image starts `neo4j` by default, so we have to override
this command with sth. that keeps the container spinning but does not terminate
it.
## Stop and Restart Neo4J Database in Kubernetes
[This tutorial](http://bigdatums.net/2017/11/07/how-to-keep-docker-containers-running/)
explains how to keep a docker container running. For kubernetes, the way to
override the docker image `CMD` is explained [here](https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#define-a-command-and-arguments-when-you-create-a-pod).
So, all we have to do is edit the kubernetes deployment of our Neo4J database
and set a custom `command` every time we have to carry out tasks like backup,
restore, seed etc.
{% hint style="info" %} TODO: implement maintenance mode {% endhint %}
First bring the application into maintenance mode to ensure there are no
database connections left and nobody can access the application.
Run the following:
```sh
kubectl --namespace=human-connection edit deployment nitro-neo4j
```
Add the following to `spec.template.spec.containers`:
```
["tail", "-f", "/dev/null"]
```
and write the file which will update the deployment.
The command `tail -f /dev/null` is the equivalent of *sleep forever*. It is a
hack to keep the container busy and to prevent its shutdown. It will also
override the default `neo4j` command and the kubernetes pod will not start the
database.
Now perform your tasks!
When you're done, edit the deployment again and remove the `command`. Write the
file and trigger an update of the deployment.
## Create a Backup in Kubernetes
First stop your Neo4J database, see above. Then:
```sh
kubectl --namespace=human-connection get pods
# Copy the ID of the pod running Neo4J.
kubectl --namespace=human-connection exec -it <POD-ID> bash
# Once you're in the pod, dump the db to a file e.g. `/root/neo4j-backup`.
neo4j-admin dump --to=/root/neo4j-backup
exit
# Download the file from the pod to your computer.
kubectl cp human-connection/<POD-ID>:/root/neo4j-backup ./neo4j-backup
```
Revert your changes to deployment `nitro-neo4j` which will restart the database.
## Restore a Backup in Kubernetes
First stop your Neo4J database. Then:
```sh
kubectl --namespace=human-connection get pods
# Copy the ID of the pod running Neo4J.
# Then upload your local backup to the pod. Note that once the pod gets deleted
# e.g. if you change the deployment, the backup file is gone with it.
kubectl cp ./neo4j-backup human-connection/<POD-ID>:/root/
kubectl --namespace=human-connection exec -it <POD-ID> bash
# Once you're in the pod restore the backup and overwrite the default database
# called `graph.db` with `--force`.
# This will delete all existing data in database `graph.db`!
neo4j-admin load --from=/root/neo4j-backup --force
exit
```
Revert your changes to deployment `nitro-neo4j` which will restart the database.

View File

@ -9,8 +9,6 @@
NEO4J_USER: "neo4j"
NEO4J_AUTH: "none"
CLIENT_URI: "https://nitro-staging.human-connection.org"
MAPBOX_TOKEN: "pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"
PRIVATE_KEY_PASSPHRASE: "a7dsf78sadg87ad87sfagsadg78"
metadata:
name: configmap
namespace: human-connection

View File

@ -1,39 +0,0 @@
---
kind: Pod
apiVersion: v1
metadata:
name: nitro-db-migration-worker
namespace: human-connection
spec:
volumes:
- name: secret-volume
secret:
secretName: ssh-keys
defaultMode: 0400
- name: mongo-export
persistentVolumeClaim:
claimName: mongo-export-claim
containers:
- name: nitro-db-migration-worker
image: humanconnection/db-migration-worker:latest
envFrom:
- configMapRef:
name: db-migration-worker
volumeMounts:
- name: secret-volume
readOnly: false
mountPath: /root/.ssh
- name: mongo-export
mountPath: /mongo-export/
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: mongo-export-claim
namespace: human-connection
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@ -0,0 +1,26 @@
# Digital Ocean
As a start, read the [introduction into kubernetes](https://www.digitalocean.com/community/tutorials/an-introduction-to-kubernetes) by the folks at Digital Ocean. The following section should enable you to deploy Human Connection to your kubernetes cluster.
## Connect to your local cluster
1. Create a cluster at [Digital Ocean](https://www.digitalocean.com/).
2. Download the `***-kubeconfig.yaml` from the Web UI.
3. Move the file to the default location where kubectl expects it to be: `mv ***-kubeconfig.yaml ~/.kube/config`. Alternatively you can set the config on every command: `--kubeconfig ***-kubeconfig.yaml`
4. Now check if you can connect to the cluster and if its your newly created one by running: `kubectl get nodes`
The output should look about like this:
```
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
nifty-driscoll-uu1w Ready <none> 69d v1.13.2
nifty-driscoll-uuiw Ready <none> 69d v1.13.2
nifty-driscoll-uusn Ready <none> 69d v1.13.2
```
If you got the steps right above and see your nodes you can continue.
Digital Ocean kubernetes clusters don't have a graphical interface, so I suggest
to setup the [kubernetes dashboard](./dashboard/README.md) as a next step.
Configuring [HTTPS](./https/README.md) is bit tricky and therefore I suggest to
do this as a last step.

View File

@ -0,0 +1,55 @@
# Install Kubernetes Dashboard
The kubernetes dashboard is optional but very helpful for debugging. If you want to install it, you have to do so only **once** per cluster:
```bash
# in folder deployment/digital-ocean/
$ kubectl apply -f dashboard/
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/master/aio/deploy/recommended/kubernetes-dashboard.yaml
```
### Login to your dashboard
Proxy the remote kubernetes dashboard to localhost:
```bash
$ kubectl proxy
```
Visit:
[http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/](http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/)
You should see a login screen.
To get your token for the dashboard you can run this command:
```bash
$ kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep admin-user | awk '{print $1}')
```
It should print something like:
```text
Name: admin-user-token-6gl6l
Namespace: kube-system
Labels: <none>
Annotations: kubernetes.io/service-account.name=admin-user
kubernetes.io/service-account.uid=b16afba9-dfec-11e7-bbb9-901b0e532516
Type: kubernetes.io/service-account-token
Data
====
ca.crt: 1025 bytes
namespace: 11 bytes
token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJrdWJlLXN5c3RlbSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJhZG1pbi11c2VyLXRva2VuLTZnbDZsIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQubmFtZSI6ImFkbWluLXVzZXIiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiJiMTZhZmJhOS1kZmVjLTExZTctYmJiOS05MDFiMGU1MzI1MTYiLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6a3ViZS1zeXN0ZW06YWRtaW4tdXNlciJ9.M70CU3lbu3PP4OjhFms8PVL5pQKj-jj4RNSLA4YmQfTXpPUuxqXjiTf094_Rzr0fgN_IVX6gC4fiNUL5ynx9KU-lkPfk0HnX8scxfJNzypL039mpGt0bbe1IXKSIRaq_9VW59Xz-yBUhycYcKPO9RM2Qa1Ax29nqNVko4vLn1_1wPqJ6XSq3GYI8anTzV8Fku4jasUwjrws6Cn6_sPEGmL54sq5R4Z5afUtv-mItTmqZZdxnkRqcJLlg2Y8WbCPogErbsaCDJoABQ7ppaqHetwfM_0yMun6ABOQbIwwl8pspJhpplKwyo700OSpvTT9zlBsu-b35lzXGBRHzv5g_RA
```
Grab the token from above and paste it into the [login screen](http://localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/)
When you are logged in, you should see sth. like:
![Dashboard](./dashboard-screenshot.png)
Feel free to save the login token from above in your password manager. Unlike the `kubeconfig` file, this token does not expire.

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@ -0,0 +1,57 @@
# Setup Ingress and HTTPS
Follow [this quick start guide](https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html) and install certmanager via helm and tiller:
```text
$ kubectl create serviceaccount tiller --namespace=kube-system
$ kubectl create clusterrolebinding tiller-admin --serviceaccount=kube-system:tiller --clusterrole=cluster-admin
$ helm init --service-account=tiller
$ helm repo update
$ helm install stable/nginx-ingress
$ kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.6/deploy/manifests/00-crds.yaml
$ helm install --name cert-manager --namespace cert-manager stable/cert-manager
```
Create letsencrypt issuers. _Change the email address_ in these files before running this command.
```bash
# in folder deployment/digital-ocean/https/
$ kubectl apply -f issuer.yaml
```
Create an ingress service in namespace `human-connection`. _Change the domain name_ according to your needs:
```bash
# in folder deployment/digital-ocean/https/
$ kubectl apply -f ingress.yaml
```
Check the ingress server is working correctly:
```bash
$ curl -kivL -H 'Host: <DOMAIN_NAME>' 'https://<IP_ADDRESS>'
```
If the response looks good, configure your domain registrar for the new IP address and the domain.
Now let's get a valid HTTPS certificate. According to the tutorial above, check your tls certificate for staging:
```bash
$ kubectl describe --namespace=human-connection certificate tls
$ kubectl describe --namespace=human-connection secret tls
```
If everything looks good, update the issuer of your ingress. Change the annotation `certmanager.k8s.io/issuer` from `letsencrypt-staging` to `letsencrypt-prod` in your ingress configuration in `ingress.yaml`.
```bash
# in folder deployment/digital-ocean/https/
$ kubectl apply -f ingress.yaml
```
Delete the former secret to force a refresh:
```text
$ kubectl --namespace=human-connection delete secret tls
```
Now, HTTPS should be configured on your domain. Congrats.

View File

@ -0,0 +1,58 @@
# Kubernetes Configuration for Human Connection
Deploying Human Connection with kubernetes is straight forward. All you have to
do is to change certain parameters, like domain names and API keys, then you
just apply our provided configuration files to your cluster.
## Configuration
Copy our provided templates:
```bash
$ cp secrets.template.yaml human-connection/secrets.yaml
$ cp configmap.template.yaml human-connection/configmap.yaml
```
Change the `configmap.yaml` as needed, all variables will be available as
environment variables in your deployed kubernetes pods.
If you want to edit secrets, you have to `base64` encode them. See [kubernetes documentation](https://kubernetes.io/docs/concepts/configuration/secret/#creating-a-secret-manually).
```bash
# example how to base64 a string:
$ echo -n 'admin' | base64 --wrap 0
YWRtaW4=
```
Those secrets get `base64` decoded and are available as environment variables in
your deployed kubernetes pods.
## Create a namespace
```bash
$ kubectl apply -f namespace-human-connection.yaml
```
If you have a [kubernets dashboard](../digital-ocean/dashboard/README.md)
deployed you should switch to namespace `human-connection` in order to
monitor the state of your deployments.
## Create persistent volumes
While the deployments and services can easily be restored, simply by deleting
and applying the kubernetes configurations again, certain data is not that
easily recovered. Therefore we separated persistent volumes from deployments
and services. There is a [dedicated section](../volumes/README.md). Create those
persistent volumes once before you apply the configuration.
## Apply the configuration
```bash
# in folder deployment/
$ kubectl apply -f human-connection/
```
This can take a while because kubernetes will download the docker images. Sit
back and relax and have a look into your kubernetes dashboard. Wait until all
pods turn green and they don't show a warning `Waiting: ContainerCreating`
anymore.

View File

@ -43,15 +43,3 @@
restartPolicy: Always
terminationGracePeriodSeconds: 30
status: {}
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: uploads-claim
namespace: human-connection
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi

View File

@ -6,6 +6,10 @@
namespace: human-connection
spec:
replicas: 1
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: "100%"
selector:
matchLabels:
human-connection.org/selector: deployment-human-connection-neo4j
@ -53,15 +57,3 @@
claimName: neo4j-data-claim
restartPolicy: Always
terminationGracePeriodSeconds: 30
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: neo4j-data-claim
namespace: human-connection
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@ -0,0 +1,85 @@
# Legacy data migration
This setup is **completely optional** and only required if you have data on a
server which is running our legacy code and you want to import that data. It
will import the uploads folder and migrate a dump of the legacy Mongo database
into our new Neo4J graph database.
## Configure Maintenance-Worker Pod
Create a configmap with the specific connection data of your legacy server:
```bash
$ kubectl create configmap maintenance-worker \
--namespace=human-connection \
--from-literal=SSH_USERNAME=someuser \
--from-literal=SSH_HOST=yourhost \
--from-literal=MONGODB_USERNAME=hc-api \
--from-literal=MONGODB_PASSWORD=secretpassword \
--from-literal=MONGODB_AUTH_DB=hc_api \
--from-literal=MONGODB_DATABASE=hc_api \
--from-literal=UPLOADS_DIRECTORY=/var/www/api/uploads
```
Create a secret with your public and private ssh keys. As the [kubernetes documentation](https://kubernetes.io/docs/concepts/configuration/secret/#use-case-pod-with-ssh-keys) points out, you should be careful with your ssh keys. Anyone with access to your cluster will have access to your ssh keys. Better create a new pair with `ssh-keygen` and copy the public key to your legacy server with `ssh-copy-id`:
```bash
$ kubectl create secret generic ssh-keys \
--namespace=human-connection \
--from-file=id_rsa=/path/to/.ssh/id_rsa \
--from-file=id_rsa.pub=/path/to/.ssh/id_rsa.pub \
--from-file=known_hosts=/path/to/.ssh/known_hosts
```
## Deploy a Temporary Maintenance-Worker Pod
Bring the application into maintenance mode.
{% hint style="info" %} TODO: implement maintenance mode {% endhint %}
Then temporarily delete backend and database deployments
```bash
$ kubectl --namespace=human-connection get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
nitro-backend 1/1 1 1 3d11h
nitro-neo4j 1/1 1 1 3d11h
nitro-web 2/2 2 2 73d
$ kubectl --namespace=human-connection delete deployment nitro-neo4j
deployment.extensions "nitro-neo4j" deleted
$ kubectl --namespace=human-connection delete deployment nitro-backend
deployment.extensions "nitro-backend" deleted
```
Deploy one-time maintenance-worker pod:
```bash
# in deployment/legacy-migration/
$ kubectl apply -f db-migration-worker.yaml
pod/nitro-maintenance-worker created
```
Import legacy database and uploads:
```bash
$ kubectl --namespace=human-connection exec -it nitro-maintenance-worker bash
$ import_legacy_db
$ import_uploads
$ exit
```
Delete the pod when you're done:
```bash
$ kubectl --namespace=human-connection delete pod nitro-maintenance-worker
```
Oh, and of course you have to get those deleted deployments back. One way of
doing it would be:
```bash
# in folder deployment/
$ kubectl apply -f human-connection/deployment-backend.yaml -f human-connection/deployment-neo4j.yaml
```

View File

@ -1,27 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: nitro-backend
namespace: human-connection
spec:
template:
spec:
containers:
- name: nitro-db-migration-worker
image: humanconnection/db-migration-worker:latest
imagePullPolicy: Always
envFrom:
- configMapRef:
name: db-migration-worker
volumeMounts:
- name: secret-volume
readOnly: false
mountPath: /root/.ssh
- name: uploads
mountPath: /uploads/
volumes:
- name: secret-volume
secret:
secretName: ssh-keys
defaultMode: 0400

View File

@ -1,39 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: nitro-neo4j
namespace: human-connection
spec:
template:
spec:
containers:
- name: nitro-db-migration-worker
image: humanconnection/db-migration-worker:latest
imagePullPolicy: Always
envFrom:
- configMapRef:
name: db-migration-worker
env:
- name: COMMIT
value: <BACKEND_COMMIT>
- name: NEO4J_URI
value: bolt://localhost:7687
volumeMounts:
- name: secret-volume
readOnly: false
mountPath: /root/.ssh
- name: mongo-export
mountPath: /mongo-export/
- name: nitro-neo4j
volumeMounts:
- mountPath: /mongo-export/
name: mongo-export
volumes:
- name: secret-volume
secret:
secretName: ssh-keys
defaultMode: 0400
- name: mongo-export
persistentVolumeClaim:
claimName: mongo-export-claim

View File

@ -0,0 +1,34 @@
---
kind: Pod
apiVersion: v1
metadata:
name: nitro-maintenance-worker
namespace: human-connection
spec:
containers:
- name: nitro-maintenance-worker
image: humanconnection/maintenance-worker:latest
envFrom:
- configMapRef:
name: maintenance-worker
- configMapRef:
name: configmap
volumeMounts:
- name: secret-volume
readOnly: false
mountPath: /root/.ssh
- name: uploads
mountPath: /nitro-backend/public/uploads
- name: neo4j-data
mountPath: /data/
volumes:
- name: secret-volume
secret:
secretName: ssh-keys
defaultMode: 0400
- name: uploads
persistentVolumeClaim:
claimName: uploads-claim
- name: neo4j-data
persistentVolumeClaim:
claimName: neo4j-data-claim

View File

@ -0,0 +1,11 @@
FROM humanconnection/neo4j:latest
ENV NODE_ENV=maintenance
EXPOSE 7687 7474
RUN apk upgrade --update
RUN apk add --no-cache mongodb-tools openssh nodejs yarn rsync
COPY known_hosts /root/.ssh/known_hosts
COPY migration ./migration
COPY ./binaries/* /usr/local/bin/

View File

@ -0,0 +1,2 @@
#!/usr/bin/env bash
tail -f /dev/null

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
set -e
for var in "SSH_USERNAME" "SSH_HOST" "MONGODB_USERNAME" "MONGODB_PASSWORD" "MONGODB_DATABASE" "MONGODB_AUTH_DB" "NEO4J_URI"
for var in "SSH_USERNAME" "SSH_HOST" "MONGODB_USERNAME" "MONGODB_PASSWORD" "MONGODB_DATABASE" "MONGODB_AUTH_DB"
do
if [[ -z "${!var}" ]]; then
echo "${var} is undefined"

View File

@ -9,4 +9,4 @@ do
fi
done
rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/* /uploads/
rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/ /uploads/

View File

@ -9,16 +9,18 @@ echo "MONGODB_DATABASE ${MONGODB_DATABASE}"
echo "MONGODB_AUTH_DB ${MONGODB_AUTH_DB}"
echo "-------------------------------------------------"
mongo ${MONGODB_DATABASE} --eval "db.dropDatabase();"
rm -rf /mongo-export/*
rm -rf /tmp/mongo-export/*
mkdir -p /tmp/mongo-export/
ssh -4 -M -S my-ctrl-socket -fnNT -L 27018:localhost:27017 -l ${SSH_USERNAME} ${SSH_HOST}
mongodump --host localhost -d ${MONGODB_DATABASE} --port 27018 --username ${MONGODB_USERNAME} --password ${MONGODB_PASSWORD} --authenticationDatabase ${MONGODB_AUTH_DB} --gzip --archive=/tmp/mongodump.archive
mongorestore --gzip --archive=/tmp/mongodump.archive
ssh -S my-ctrl-socket -O check -l ${SSH_USERNAME} ${SSH_HOST}
ssh -S my-ctrl-socket -O exit -l ${SSH_USERNAME} ${SSH_HOST}
for collection in "categories" "badges" "users" "contributions" "comments" "follows" "shouts"
do
mongoexport --db ${MONGODB_DATABASE} --collection $collection --out "/mongo-export/$collection.json"
mongoexport --db ${MONGODB_DATABASE} --host localhost -d ${MONGODB_DATABASE} --port 27018 --username ${MONGODB_USERNAME} --password ${MONGODB_PASSWORD} --authenticationDatabase ${MONGODB_AUTH_DB} --collection $collection --collection $collection --out "/tmp/mongo-export/$collection.json"
mkdir -p /tmp/mongo-export/splits/$collection/
split -l 1000 -a 3 /tmp/mongo-export/$collection.json /tmp/mongo-export/splits/$collection/
done
ssh -S my-ctrl-socket -O check -l ${SSH_USERNAME} ${SSH_HOST}
ssh -S my-ctrl-socket -O exit -l ${SSH_USERNAME} ${SSH_HOST}

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/badges.json') YIELD value as badge
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as badge
MERGE(b:Badge {id: badge._id["$oid"]})
ON CREATE SET
b.key = badge.key,

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/categories.json') YIELD value as category
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as category
MERGE(c:Category {id: category._id["$oid"]})
ON CREATE SET
c.name = category.title,

View File

@ -1,4 +1,5 @@
CALL apoc.load.json('file:/mongo-export/comments.json') YIELD value as json
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as json
MERGE (comment:Comment {id: json._id["$oid"]})
ON CREATE SET
comment.content = json.content,

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/contributions.json') YIELD value as post
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as post
MERGE (p:Post {id: post._id["$oid"]})
ON CREATE SET
p.title = post.title,

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/follows.json') YIELD value as follow
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as follow
MATCH (u1:User {id: follow.userId}), (u2:User {id: follow.foreignId})
MERGE (u1)-[:FOLLOWS]->(u2)
;

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
SECONDS=0
SCRIPT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
echo "MATCH (n) DETACH DELETE n;" | cypher-shell
for collection in "badges" "categories" "users" "follows" "contributions" "shouts" "comments"
do
for chunk in /tmp/mongo-export/splits/$collection/*
do
mv $chunk /tmp/mongo-export/splits/current-chunk.json
echo "Import ${chunk}" && cypher-shell < $SCRIPT_DIRECTORY/$collection.cql
done
done
echo "Time elapsed: $SECONDS seconds"

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/shouts.json') YIELD value as shout
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as shout
MATCH (u:User {id: shout.userId}), (p:Post {id: shout.foreignId})
MERGE (u)-[:SHOUTED]->(p)
;

View File

@ -1,4 +1,4 @@
CALL apoc.load.json('file:/mongo-export/users.json') YIELD value as user
CALL apoc.load.json('file:/tmp/mongo-export/splits/current-chunk.json') YIELD value as user
MERGE(u:User {id: user._id["$oid"]})
ON CREATE SET
u.name = user.name,

View File

@ -0,0 +1,25 @@
# Minikube
There are many Kubernetes providers, but if you're just getting started, Minikube is a tool that you can use to get your feet wet.
After you [installed Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
open your minikube dashboard:
```text
$ 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.
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
minikube dashboard, expose the services you want on your host system.
For example:
```text
$ minikube service nitro-web --namespace=human-connection
# optionally
$ minikube service nitro-backend --namespace=human-connection
```

View File

@ -4,6 +4,7 @@ data:
JWT_SECRET: "Yi8mJjdiNzhCRiZmdi9WZA=="
MONGODB_PASSWORD: "TU9OR09EQl9QQVNTV09SRA=="
PRIVATE_KEY_PASSPHRASE: "YTdkc2Y3OHNhZGc4N2FkODdzZmFnc2FkZzc4"
MAPBOX_TOKEN: "cGsuZXlKMUlqb2lhSFZ0WVc0dFkyOXVibVZqZEdsdmJpSXNJbUVpT2lKamFqbDBjbkJ1Ykdvd2VUVmxNM1Z3WjJsek5UTnVkM1p0SW4wLktaOEtLOWw3MG9talhiRWtrYkhHc1EK"
metadata:
name: human-connection
namespace: human-connection

View File

@ -0,0 +1,42 @@
# Persistent Volumes
At the moment, the application needs two persistent volumes:
* The `/data/` folder where `neo4j` stores its database and
* the folder `/nitro-backend/public/uploads` where the backend stores uploads.
As a matter of precaution, the persistent volume claims that setup these volumes
live in a separate folder. You don't want to accidently loose all your data in
your database by running `kubectl delete -f human-connection/`, do you?
## Create Persistent Volume Claims
Run the following:
```sh
# in folder deployments/
$ kubectl apply -f volumes
persistentvolumeclaim/neo4j-data-claim created
persistentvolumeclaim/uploads-claim created
```
## Change Reclaim Policy
We recommend to change the `ReclaimPolicy`, so if you delete the persistent
volume claims, the associated volumes will be released, not deleted:
```sh
$ kubectl --namespace=human-connection get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
pvc-bd02a715-66d0-11e9-be52-ba9c337f4551 1Gi RWO Delete Bound human-connection/neo4j-data-claim do-block-storage 4m24s
pvc-bd208086-66d0-11e9-be52-ba9c337f4551 2Gi RWO Delete Bound human-connection/uploads-claim do-block-storage 4m12s
```
Get the volume id from above, then change `ReclaimPolicy` with:
```sh
kubectl patch pv <VOLUME-ID> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
# in the above example
kubectl patch pv pvc-bd02a715-66d0-11e9-be52-ba9c337f4551 -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
kubectl patch pv pvc-bd208086-66d0-11e9-be52-ba9c337f4551 -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
```

View File

@ -2,7 +2,7 @@
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: mongo-export-claim
name: neo4j-data-claim
namespace: human-connection
spec:
accessModes:

View File

@ -0,0 +1,12 @@
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: uploads-claim
namespace: human-connection
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi

View File

@ -0,0 +1,44 @@
version: "3.4"
services:
maintenance:
image: humanconnection/maintenance-worker:latest
build:
context: deployment/legacy-migration/maintenance-worker
volumes:
- uploads:/uploads
- neo4j-data:/data
- ./deployment/legacy-migration/maintenance-worker/migration/:/migration
- ./deployment/legacy-migration/maintenance-worker/ssh/:/root/.ssh
networks:
- hc-network
environment:
- NEO4J_dbms_security_auth__enabled=false
- NEO4J_dbms_memory_heap_max__size=2G
- GRAPHQL_PORT=4000
- GRAPHQL_URI=http://localhost:4000
- CLIENT_URI=http://localhost:3000
- JWT_SECRET=b/&&7b78BF&fv/Vd
- MOCK=false
- MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ
- PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
- NEO4J_apoc_import_file_enabled=true
- "SSH_USERNAME=${SSH_USERNAME}"
- "SSH_HOST=${SSH_HOST}"
- "MONGODB_USERNAME=${MONGODB_USERNAME}"
- "MONGODB_PASSWORD=${MONGODB_PASSWORD}"
- "MONGODB_AUTH_DB=${MONGODB_AUTH_DB}"
- "MONGODB_DATABASE=${MONGODB_DATABASE}"
- "UPLOADS_DIRECTORY=${UPLOADS_DIRECTORY}"
ports:
- 7687:7687
- 7474:7474
networks:
hc-network:
volumes:
webapp_node_modules:
backend_node_modules:
neo4j-data:
uploads:

View File

@ -18,6 +18,7 @@ services:
volumes:
- ./backend:/nitro-backend
- backend_node_modules:/nitro-backend/node_modules
- uploads:/nitro-backend/public/uploads
command: yarn run dev
neo4j:
environment:
@ -32,3 +33,4 @@ volumes:
webapp_node_modules:
backend_node_modules:
neo4j-data:
uploads:

View File

@ -11,6 +11,9 @@ services:
build:
context: webapp
target: build-and-test
volumes:
#/nitro-web
- ./webapp/coverage:/nitro-web/coverage
environment:
- GRAPHQL_URI=http://backend:4000
backend:
@ -18,6 +21,8 @@ services:
build:
context: backend
target: builder
volumes:
- ./backend/coverage:/nitro-backend/coverage
ports:
- 4001:4001
- 4123:4123

View File

@ -38,7 +38,7 @@ services:
neo4j:
image: humanconnection/neo4j:latest
build:
context: backend/neo4j
context: neo4j
networks:
- hc-network

View File

@ -1,3 +1,3 @@
FROM neo4j:3.5.4
FROM neo4j:3.5.5
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.1/apoc-3.5.0.1-all.jar -P plugins/
COPY migrate.sh /usr/local/bin/migrate

33
neo4j/migrate.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# If the user has the password `neo4j` this is a strong indicator, that we are
# the initial default user. Before we can create constraints, we have to change
# the default password. This is a security feature of neo4j.
if echo ":exit" | cypher-shell --password neo4j 2> /dev/null ; then
if [[ -z "${NEO4J_PASSWORD}" ]]; then
echo "NEO4J_PASSWORD environment variable is undefined. I cannot set the initial password."
else
echo "CALL dbms.security.changePassword('${NEO4J_PASSWORD}');" | cypher-shell --password neo4j
fi
fi
set -e
echo '
CALL db.index.fulltext.createNodeIndex("full_text_search",["Post"],["title", "content"]);
CREATE CONSTRAINT ON (p:Post) ASSERT p.id IS UNIQUE;
CREATE CONSTRAINT ON (c:Comment) ASSERT c.id IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.id IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.id IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.id IS UNIQUE;
CREATE CONSTRAINT ON (t:Tag) ASSERT t.id IS UNIQUE;
CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
' | cypher-shell
echo "Successfully created all indices and unique constraints:"
echo 'CALL db.indexes();' | cypher-shell

View File

@ -15,16 +15,19 @@
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev",
"cypress:setup": "run-p cypress:backend:* cypress:webapp",
"cypress:run": "cypress run --browser chromium",
"cypress:open": "cypress open --browser chromium"
"cypress:open": "cypress open --browser chromium",
"test:jest": "cd webapp && yarn test && cd ../backend && yarn test:jest && codecov"
},
"devDependencies": {
"codecov": "^3.4.0",
"cross-env": "^5.2.0",
"cypress": "^3.2.0",
"cypress-cucumber-preprocessor": "^1.11.0",
"dotenv": "^7.0.0",
"cypress-plugin-retries": "^1.2.0",
"dotenv": "^8.0.0",
"faker": "^4.1.0",
"graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.3",
"neo4j-driver": "^1.7.4",
"npm-run-all": "^4.1.5"
}
}

View File

@ -1,4 +1,7 @@
{
"plugins": [
"@babel/plugin-syntax-dynamic-import"
],
"presets": [
[
"@babel/preset-env",
@ -21,4 +24,4 @@
]
}
}
}
}

View File

@ -154,7 +154,10 @@
</ds-button>
</div>
</editor-floating-menu>
<editor-content :editor="editor" />
<editor-content
ref="editor"
:editor="editor"
/>
</div>
</template>
@ -224,7 +227,7 @@ export default {
new ListItem(),
new Placeholder({
emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…'
emptyNodeText: this.$t('editor.placeholder')
}),
new History(),
new Mention({
@ -445,6 +448,9 @@ export default {
// remove link
command({ href: null })
}
},
clear() {
this.editor.clearContent(true)
}
}
}

View File

@ -9,14 +9,19 @@ localVue.use(Styleguide)
describe('Editor.vue', () => {
let wrapper
let propsData
let mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: () => {}
}
})
describe('mount', () => {
let Wrapper = () => {
return (wrapper = mount(Editor, {
mocks,
propsData,
localVue,
sync: false,

Some files were not shown because too many files have changed in this diff Show More