Skip to content

Commit c5afdfb

Browse files
authored
Merge pull request #9 from DouglasNeuroInformatics/dev
Release v2
2 parents 60713da + a745c9c commit c5afdfb

File tree

135 files changed

+3943
-1450
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

135 files changed

+3943
-1450
lines changed

.bruno/Auth/Login.bru

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
meta {
2+
name: Login
3+
type: http
4+
seq: 1
5+
}
6+
7+
post {
8+
url: {{BASE_URL}}/v1/auth/login
9+
body: json
10+
auth: none
11+
}
12+
13+
body:json {
14+
{
15+
"username": "admin",
16+
"password": "password"
17+
}
18+
}

.bruno/Cats/Get Cats.bru

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
meta {
2+
name: Get Cats
3+
type: http
4+
seq: 1
5+
}
6+
7+
get {
8+
url: {{BASE_URL}}/v1/cats
9+
body: none
10+
auth: none
11+
}

.bruno/bruno.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"version": "1",
3+
"name": "libnest",
4+
"type": "collection",
5+
"ignore": ["node_modules", ".git"]
6+
}

.bruno/collection.bru

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
vars:pre-request {
2+
BASE_URL: http://localhost:5500
3+
}
4+
5+
script:pre-request {
6+
const axios = require("axios");
7+
8+
const getVar = (key) => {
9+
const value = bru.getVar(key);
10+
if (value === undefined) {
11+
throw new Error(`Could not get variable: ${key}`)
12+
}
13+
return value;
14+
}
15+
16+
if (!req.url.endsWith('/auth/login')) {
17+
const baseUrl = 'http://localhost:5500';
18+
const username = 'admin';
19+
const password = 'password';
20+
const response = await axios.post(`${baseUrl}/v1/auth/login`, {
21+
username,
22+
password
23+
});
24+
req.setHeader('Authorization', `Bearer ${response.data.accessToken}`)
25+
}
26+
27+
28+
29+
30+
31+
32+
}

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
API_PORT=5500
2+
MONGO_URI=mongodb://localhost:27017
3+
SECRET_KEY=a2c2ee0ef53a858201ee8a5fc4b5955ccd4bb7c8fee8d00a

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,4 @@ dist
133133
/docs/
134134

135135
# local scripts for linking
136-
bin/_*.*
136+
local/

example/app.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { randomBytes } from 'node:crypto';
2+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
3+
4+
import type { NestExpressApplication } from '@nestjs/platform-express';
5+
import request from 'supertest';
6+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
7+
8+
import appContainer from './app.js';
9+
10+
import type { BaseEnv } from '../src/schemas/env.schema.js';
11+
import type { CreateCatDto } from './cats/dto/create-cat.dto.js';
12+
13+
const env = {
14+
API_PORT: '5500',
15+
DEBUG: 'false',
16+
MONGO_URI: 'mongodb://localhost:27017',
17+
NODE_ENV: 'test',
18+
SECRET_KEY: '2622d72669dd194b98cffd9098b0d04b',
19+
VERBOSE: 'false'
20+
} satisfies { [K in keyof BaseEnv]?: string };
21+
22+
describe('e2e (example)', () => {
23+
let app: NestExpressApplication;
24+
let server!: Server<typeof IncomingMessage, typeof ServerResponse>;
25+
26+
beforeAll(async () => {
27+
Object.entries(env).forEach(([key, value]) => {
28+
vi.stubEnv(key, value);
29+
});
30+
app = appContainer.getApplicationInstance();
31+
await app.init();
32+
server = app.getHttpServer();
33+
});
34+
35+
afterAll(async () => {
36+
if (app) {
37+
await app.close();
38+
app.flushLogs();
39+
}
40+
});
41+
42+
describe('/spec.json', () => {
43+
it('should configure the documentation', async () => {
44+
const response = await request(server).get('/spec.json');
45+
expect(response.status).toBe(200);
46+
});
47+
});
48+
49+
describe('/auth/login', () => {
50+
it('should return status code 400 if the request body does not include login credentials', async () => {
51+
const response = await request(server).post('/v1/auth/login');
52+
expect(response.status).toBe(400);
53+
});
54+
it('should return status code 400 if the request body does not include a username', async () => {
55+
const response = await request(server).post('/v1/auth/login').send({ username: 'admin' });
56+
expect(response.status).toBe(400);
57+
});
58+
it('should return status code 400 if the request body does not include a password', async () => {
59+
const response = await request(server).post('/v1/auth/login').send({ password: 'password' });
60+
expect(response.status).toBe(400);
61+
});
62+
it('should return status code 400 if the request body includes a username and password, but are empty strings', async () => {
63+
const response = await request(server).post('/v1/auth/login').send({ password: '', username: '' });
64+
expect(response.status).toBe(400);
65+
});
66+
it('should return status code 400 if the request body includes a username and password, but password is a number', async () => {
67+
const response = await request(server).post('/v1/auth/login').send({ password: 123, username: 'admin' });
68+
expect(response.status).toBe(400);
69+
});
70+
it('should return status code 401 if the user does not exist', async () => {
71+
const response = await request(server).post('/v1/auth/login').send({ password: 'password', username: 'user' });
72+
expect(response.status).toBe(401);
73+
});
74+
it('should return status code 200 and an access token if the credentials are correct', async () => {
75+
const response = await request(server).post('/v1/auth/login').send({ password: 'password', username: 'admin' });
76+
expect(response.status).toBe(200);
77+
expect(response.body).toStrictEqual({
78+
accessToken: expect.stringMatching(/^[A-Za-z0-9-_]+\.([A-Za-z0-9-_]+)\.[A-Za-z0-9-_]+$/)
79+
});
80+
});
81+
});
82+
83+
describe('/cats', () => {
84+
let accessToken: string;
85+
86+
beforeAll(async () => {
87+
const response = await request(server).post('/v1/auth/login').send({ password: 'password', username: 'admin' });
88+
accessToken = response.body.accessToken;
89+
});
90+
91+
it('should return status code 401 if there is no access token provided', async () => {
92+
const response = await request(server).get('/v1/cats');
93+
expect(response.status).toBe(401);
94+
});
95+
96+
it('should return status code 401 if there is an invalid access token provided', async () => {
97+
const response = await request(server)
98+
.get('/v1/cats')
99+
.set('Authorization', `Bearer ${randomBytes(12).toString('base64')}`);
100+
expect(response.status).toBe(401);
101+
});
102+
103+
it('should allow a GET request', async () => {
104+
const response = await request(server).get('/v1/cats').set('Authorization', `Bearer ${accessToken}`);
105+
expect(response.status).toBe(200);
106+
});
107+
108+
it('should allow a POST request', async () => {
109+
const response = await request(server)
110+
.post('/v1/cats')
111+
.set('Authorization', `Bearer ${accessToken}`)
112+
.send({
113+
age: 1,
114+
name: 'Winston'
115+
} satisfies CreateCatDto);
116+
expect(response.status).toBe(201);
117+
});
118+
});
119+
});

example/app.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from 'zod';
2+
3+
import { $BaseEnv, AppContainer, AuthModule, CryptoService } from '../src/index.js';
4+
import { CatsModule } from './cats/cats.module.js';
5+
6+
export default await AppContainer.create({
7+
docs: {
8+
config: {
9+
title: 'Example API'
10+
},
11+
path: '/spec.json'
12+
},
13+
envSchema: $BaseEnv,
14+
imports: [
15+
AuthModule.forRootAsync({
16+
inject: [CryptoService],
17+
useFactory: (cryptoService: CryptoService) => {
18+
return {
19+
defineAbility: (ability, payload: { isAdmin: boolean }) => {
20+
if (payload.isAdmin) {
21+
ability.can('manage', 'all');
22+
}
23+
},
24+
loginCredentialsSchema: z.object({
25+
password: z.string().min(1),
26+
username: z.string().min(1)
27+
}),
28+
userQuery: async ({ username }) => {
29+
if (username !== 'admin') {
30+
return null;
31+
}
32+
return {
33+
hashedPassword: await cryptoService.hashPassword('password'),
34+
tokenPayload: {
35+
isAdmin: true
36+
}
37+
};
38+
}
39+
};
40+
}
41+
}),
42+
CatsModule
43+
],
44+
prisma: {
45+
dbPrefix: null
46+
},
47+
version: '1'
48+
});

src/core/factories/__tests__/stubs/cats.controller.ts renamed to example/cats/cats.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Body, Controller, Get, Post } from '@nestjs/common';
22

3+
import { RouteAccess } from '../../src/index.js';
34
import { CatsService } from './cats.service.js';
45
import { CreateCatDto } from './dto/create-cat.dto.js';
56

@@ -10,11 +11,13 @@ export class CatsController {
1011
constructor(private readonly catsService: CatsService) {}
1112

1213
@Post()
13-
async create(@Body() createCatDto: CreateCatDto) {
14+
@RouteAccess({ action: 'create', subject: 'Cat' })
15+
async create(@Body() createCatDto: CreateCatDto): Promise<Cat> {
1416
return this.catsService.create(createCatDto);
1517
}
1618

1719
@Get()
20+
@RouteAccess({ action: 'read', subject: 'Cat' })
1821
async findAll(): Promise<Cat[]> {
1922
return this.catsService.findAll();
2023
}

src/core/factories/__tests__/stubs/cats.module.ts renamed to example/cats/cats.module.ts

File renamed without changes.

0 commit comments

Comments
 (0)