Skip to content

Commit

Permalink
feat: support users and logins (#42)
Browse files Browse the repository at this point in the history
There's honestly a lot of features in this PR. A `ZodValidationPipe`, a `ServerHashModule` and service, a `base32` library that holds the valid values for a base32 string and a regex for a ulid (should be renamed), e2e tests has been updated so that migrations run for the test database before the tests are ran, the e2e tests are now ran through vitest (uvu should be fully removed soon), the binding of interceptors and filters has been moved to the `RootModule` to better manage when they get used, **and finally**, user signup and login is now functional as well. 

I should still verify that the cookies are sent back properly with the necessary information, if any, but the session that should be created is indeed updated in the Redis database after signup, so it should all be functional as is
  • Loading branch information
jmcdo29 committed Jul 4, 2023
2 parents 723f5d7 + 8170c68 commit 80ca1c7
Show file tree
Hide file tree
Showing 74 changed files with 5,060 additions and 2,463 deletions.
66 changes: 64 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,70 @@ jobs:
parallel-commands: |
pnpm exec nx-cloud record -- pnpm exec nx format:check
parallel-commands-on-agents: |
pnpm exec nx affected --target=lint --parallel=3
pnpm exec nx affected --target=build --parallel=3
pnpm exec nx affected -t lint build --parallel=3
e2e:
name: E2E Tests
runs-on: ubuntu-latest
env:
DATABASE_USER: postgres
DATABASE_PORT: 25432
DATABASE_NAME: test
DATABASE_PASSWORD: postgres
DATABASE_HOST: localhost
REDIS_URL: redis://localhost:26379
NODE_ENV: development
steps:
- name: Checkout Repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Nx SHAs
uses: nrwl/nx-set-shas@v3

- name: Install PNPM
shell: bash
run: npm i -g pnpm

- name: Print node/npm/yarn versions
id: versions
run: |
node_ver=$( node --version )
pnpm_ver=$( pnpm --version || true )
echo "Node: ${node_ver:1}"
if [[ $pnpm_ver != '' ]]; then echo "PNPM: $pnpm_ver"; fi
echo "node_version=${node_ver:1}" >> $GITHUB_OUTPUT
- name: Get pnpm cache directory path
id: pnpm-cache-dir-path
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT

- name: Use the node_modules cache if available
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-node-${{ steps.versions.outputs.node_version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ steps.versions.outputs.node_version }}-
- name: Use the Cypress Cache if available
uses: actions/cache@v3
with:
path: ./.cache/Cypress
key: ${{ runner.os }}-node-${{ steps.versions.outputs.node_version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ steps.versions.outputs.node_version }}-
- name: Install Deps
shell: bash
run: pnpm i

- name: Run E2E Tests
shell: bash
run: pnpm exec nx affected -t e2e --parallel --configuration=ci
continue-on-error: true

agents:
name: Nx Cloud - Agents
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

node_modules/.bin/nx affected:lint
node_modules/.bin/nx affected:lint --parallel
node_modules/.bin/nx format:write
if git diff --cached --name-only | grep -Eq \(diagram\.mmd\|migrations\/README\); then
node_modules/.bin/embedme --stdout --strip-embed-comment libs/db/migrations/README.template.md > libs/db/migrations/README.md
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ you can just watch the project and see what becomes of it.

Any major questions I guess you can raise an issue or [email me about it][email]

[email]: mailto://me+unteris@jaymcdoniel.dev
[email]: mailto://dev@unteris.com
7 changes: 7 additions & 0 deletions apps/kysely-cli/.env.migrate-e2e
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DATABASE_NAME=test
DATABASE_PORT=25432
DATABASE_USER=postgres
DATABASE_HOST=localhost
DATABASE_PASSWORD=postgres
REDIS_URL=redis://localhost:26379
NODE_ENV=development
21 changes: 21 additions & 0 deletions apps/kysely-cli/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@
}
]
},
"migrate-e2e": {
"executor": "@nx/js:node",
"options": {
"buildTarget": "kysely-cli:build",
"watch": false
},
"configurations": {
"development": {
"buildTarget": "kysely-cli:build:development"
},
"production": {
"buildTarget": "kysely-cli:build:production"
}
},
"dependsOn": [
{
"target": "build",
"dependencies": true
}
]
},
"seed": {
"executor": "@nx/js:node",
"options": {
Expand Down
5 changes: 3 additions & 2 deletions apps/server-e2e/.env.e2e
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
DATABASE_NAME=test
DATABASE_PORT=5432
DATABASE_PORT=25432
DATABASE_USER=postgres
DATABASE_HOST=localhost
DATABASE_PASSWORD=postgres
REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:26379
NODE_ENV=development
32 changes: 28 additions & 4 deletions apps/server-e2e/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,38 @@
"projectType": "application",
"targets": {
"e2e": {
"executor": "nx:run-commands",
"options": {
"commands": ["docker compose -f docker-compose.test.yml down"]
},
"dependsOn": ["run-tests"]
},
"run-tests": {
"executor": "@nx/vite:test",
"options": {
// "config": "./apps/server-e2e/vite.config.ts"
},
"dependsOn": [
{
"projects": "self",
"target": "run-migrations"
}
]
},
"run-migrations": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm nx run kysely-cli:migrate-e2e"
},
"dependsOn": ["start-docker"]
},
"start-docker": {
"executor": "nx:run-commands",
"options": {
"commands": [
"docker compose -f docker-compose.test.yml up -d",
"while ! nc -q0 localhost 6379 < /dev/null > /dev/null 2>&1; do sleep 10; done",
"while ! nc -q0 localhost 5432 < /dev/null > /dev/null 2>&1; do sleep 10; done",
"NODE_ENV=development node -r @swc/register apps/server-e2e/src/main.ts",
"docker compose -f docker-compose.test.yml down"
"while ! nc -q0 localhost 26379 < /dev/null > /dev/null 2>&1; do sleep 10; done",
"while ! nc -q0 localhost 25432 < /dev/null > /dev/null 2>&1; do sleep 10; done"
]
}
},
Expand Down
6 changes: 6 additions & 0 deletions apps/server-e2e/src/interfaces/test-context.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Database } from '@unteris/server/kysely';
import { Kysely } from 'kysely';

export interface DbContext {
db: Kysely<Database>;
}
45 changes: 23 additions & 22 deletions apps/server-e2e/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { OgmaService } from '@ogma/nestjs-module';
import { getKyselyInstanceToken } from '@unteris/server/kysely';
import { RootModule } from '@unteris/server/root';
import { request, spec } from 'pactum';
import { suite } from 'uvu';
import { csrfTests } from './tests/csrf';
import { request } from 'pactum';
import { describe, beforeAll, beforeEach } from 'vitest';
import { DbContext } from './interfaces/test-context.interface';
import { csrfTest } from './tests/csrf';
import { signUpAndLoginTests } from './tests/signup-and-login';

type UvuContext = { app: INestApplication };

const ApplicationE2E = suite<UvuContext>('Unteris E2E test suite');

ApplicationE2E.before(async (context) => {
const app = await NestFactory.create(RootModule, { bufferLogs: true });
app.useLogger(app.get(OgmaService));
await app.listen(0);
const reqURL = await app.getUrl();
request.setBaseUrl(reqURL.replace('[::1]', 'localhost'));
context.app = app;
});

ApplicationE2E.after(async ({ app }) => {
await app.close();
describe('Unteris E2E test suite', () => {
let app: INestApplication;
beforeAll(async () => {
app = await NestFactory.create(RootModule, { bufferLogs: true });
app.useLogger(app.get(OgmaService));
await app.listen(0);
const reqURL = await app.getUrl();
request.setBaseUrl(reqURL.replace('[::1]', 'localhost'));
return async () => {
await app.close();
};
});
beforeEach<DbContext>((context) => {
context.db = app.get(getKyselyInstanceToken(), { strict: false });
});
csrfTest();
signUpAndLoginTests();
});

ApplicationE2E('CSRF Testing', csrfTests);

ApplicationE2E.run();
99 changes: 50 additions & 49 deletions apps/server-e2e/src/tests/csrf.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,54 @@
import { parse } from 'lightcookie';
import { spec } from 'pactum';
import { Callback } from 'uvu';
import { ok, unreachable } from 'uvu/assert';
import { describe, expect, test } from 'vitest';

export const csrfTests: Callback<any> = async (_context: any) => {
await spec()
.get('/csrf')
.expectStatus(200)
.expectJsonLike({ csrfToken: /\w+/ })
.expect(({ res }) => {
const cookies = res.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
unreachable('Received no cookies from the server');
return;
}
cookies.forEach((cookie) => {
const parsed = parse(cookie);
ok(
['sessionId', 'refreshId'].some((cookieName) => {
return cookieName in parsed;
})
);
});
})
.stores((_req, res) => {
const { csrfToken } = res.body;
const cookies = res.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
unreachable('Received no cookies from the server');
return;
}
const sessionId = cookies
.map((c) => parse(c))
.find((cookie) => 'sessionId' in cookie)?.sessionId;
const refreshId = cookies
.map((c) => parse(c))
.find((cookie) => 'refreshId' in cookie)?.refreshId;
return {
csrfToken,
sessionId,
refreshId,
};
})
.toss();
await spec()
.post('/csrf/verify')
.withHeaders('X-UNTERIS-CSRF-PROTECTION', '$S{csrfToken}')
.withCookies('sessionId', '$S{sessionId}')
.expectStatus(201)
.expectJson({ success: true })
.toss();
export const csrfTest = () => {
return describe('CSRF Tests', () => {
test('CSRF Testing', async () => {
await spec()
.get('/csrf')
.expectStatus(200)
.expectJsonLike({ csrfToken: /\w+/ })
.expect(({ res }) => {
const cookies = res.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
throw new Error('Received no cookies from the server');
}
cookies.forEach((cookie) => {
const parsed = parse(cookie);
expect(
['sessionId', 'refreshId'].some((cookieName) => {
return cookieName in parsed;
})
);
});
})
.stores((_req, res) => {
const { csrfToken } = res.body;
const cookies = res.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
throw new Error('Received no cookies from the server');
}
const sessionId = cookies
.map((c) => parse(c))
.find((cookie) => 'sessionId' in cookie)?.sessionId;
const refreshId = cookies
.map((c) => parse(c))
.find((cookie) => 'refreshId' in cookie)?.refreshId;
return {
csrfToken,
sessionId,
refreshId,
};
})
.toss();
await spec()
.post('/csrf/verify')
.withHeaders('X-UNTERIS-CSRF-PROTECTION', '$S{csrfToken}')
.withCookies('sessionId', '$S{sessionId}')
.expectStatus(201)
.expectJson({ success: true })
.toss();
});
});
};
50 changes: 50 additions & 0 deletions apps/server-e2e/src/tests/signup-and-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { base32Regex } from '@unteris/shared/base32';
import { randomUUID } from 'crypto';
import { spec } from 'pactum';
import { regex } from 'pactum-matchers';
import { describe, expect, test } from 'vitest';
import { DbContext } from '../interfaces/test-context.interface';

export const signUpAndLoginTests = () => {
return describe('SignUp and Login', () => {
test<DbContext>('A new user should be able to sign up', async (context) => {
const email = `${randomUUID()}@testing.com`;
const res = await spec()
.post('/auth/signup')
.withBody({
email,
password: 'ALongEnoughP4ssw0rdToBeFin3',
confirmationPassword: 'ALongEnoughP4ssw0rdToBeFin3',
name: 'Test User' + randomUUID(),
})
.expectStatus(201)
.expectJsonMatch({
id: regex(base32Regex),
success: true,
})
.returns('.id');
// assert we properly made the user, login method, and related local login
const userAccount = await context.db
.selectFrom('userAccount as ua')
.innerJoin('loginMethod as lm', 'lm.userId', 'ua.id')
.innerJoin('localLogin as ll', 'll.loginMethodId', 'lm.id')
.select(({ fn }) => [fn.count('ua.id').as('count')])
.where('ua.id', '=', res)
.execute();
expect(userAccount[0].count).toBe('1');
await spec()
.post('/auth/login')
.withBody({
email,
password: 'ALongEnoughP4ssw0rdToBeFin3',
})
.expectJson({
success: true,
})
.expectStatus(201)
.expect(({ res: _res }) => {
// assert session token here
});
});
});
};
Loading

0 comments on commit 80ca1c7

Please sign in to comment.