Refactor & unit tests

This commit is contained in:
jd
2026-02-13 22:06:49 +00:00
parent eca7405974
commit 387a9a36f3
20 changed files with 423 additions and 148 deletions

2
.env.test Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgAppTest
JWT_SECRET_KEY=MySecret

View File

@@ -2,11 +2,11 @@ POST http://localhost:3000/api/auth/login
Content-Type: application/json
{
"username": "jd2",
"username": "jd",
"password": "Foobar"
}
###
GET http://localhost:3000/api/auth/test
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzgwNzcsImV4cCI6MTc3MTAyNDQ3N30.T_LInbvYJkv1beS39TSuC3asGtbx6gO2bKnFDk52qXM
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDEyNDM5LCJleHAiOjE3NzEwOTg4Mzl9.__EHi3dO_uG1mtCVhmRqVKTkbTkOzM5Hu-4gMrIfu7I

View File

@@ -9,4 +9,4 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY
###
GET http://localhost:3000/api/user/2
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzE1NzYsImV4cCI6MTc3MTAxNzk3Nn0.6ITZfX3_vtAFaf6qTuX7Fk_RLomgKbeto9IWhGMdN-k
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDE5NzMzLCJleHAiOjE3NzExMDYxMzN9.V4La9Sv13M15lubtxWGESUksWldhlyG8AhiIE4zAtWo

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
telemetry = false
[test]
root = "tests"
preload = ["./src/tests/test-setup.ts", "./src/tests/global-mocks.ts"]

View File

@@ -1,29 +0,0 @@
import {BunRequest as Request} from "bun";
import {Claims, orm} from "../utilities/orm.ts";
import jwt from "jsonwebtoken";
async function login(request: Request): Promise<Response> {
try {
const requestBody = await request.json();
console.log(`/api/auth/login: username=${requestBody.username}`);
const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password);
console.log(claims);
if (claims) {
const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"});
return Response.json({token: token}, {status: 200});
}
return Response.json({token: null}, {status: 401});
} catch (e) {
console.log(e);
return Response.json({message: e}, {status: 401});
}
}
async function test(request: Request, claims: Claims) {
return Response.json(claims, {status: 200});
}
export default {
login,
test
};

View File

@@ -1,28 +0,0 @@
import {Claims, orm} from "../utilities/orm";
import {BunRequest as Request} from 'bun';
async function create (request: Request, claims: Claims): Promise<Response> {
try {
const requestBody = await request.json();
return Response.json({
...(await orm.users.create(requestBody.username, requestBody.password, claims))
}, {status: 200});
} catch (e: any) {
return Response.json({message: e.message}, {status: 500});
}
}
async function get(request: Request, claims:Claims): Promise<Response> {
try {
return Response.json({
...(await orm.users.get(request.params.id, claims))
}, {status: 200});
} catch (e: any) {
return Response.json({message: e.message}, {status: 500});
}
}
export default {
create,
get,
}

View File

@@ -2,11 +2,11 @@
"name": "bgApp",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "bun build index.ts --target bun",
"dev": "bun --env-file=.env.dev run index.ts"
"test": "bun --env-file=.env.test test ./src/tests/",
"build": "bun build src/index.ts --target bun",
"dev": "bun --env-file=.env.dev run src/index.ts"
},
"private": true,
"dependencies": {

32
src/endpoints/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import {BunRequest as Request} from "bun";
import {orm} from "../orm/orm.ts";
import jwt from "jsonwebtoken";
import {UnwrappedRequest} from "../utilities/guard";
import {ErrorResponse} from "../utilities/responseHelper";
import {Claims} from "../orm/claims";
import {UnauthorizedError} from "../utilities/errors";
async function login(request: UnwrappedRequest): Promise<Response> {
try {
const requestBody = request.json;
console.log(`/api/auth/login: username=${requestBody.username}`);
const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password);
console.log(claims);
if (claims) {
const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"});
return Response.json({token: token, claims: claims}, {status: 200});
}
throw new UnauthorizedError('Invalid credentials');
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function test(request: UnwrappedRequest) {
return Response.json(request.claims, {status: 200});
}
export default {
login,
test
};

38
src/endpoints/user.ts Normal file
View File

@@ -0,0 +1,38 @@
import {orm} from "../orm/orm.ts";
import {UnwrappedRequest} from "../utilities/guard.ts";
import {ErrorResponse} from "../utilities/responseHelper.ts";
async function create(request: UnwrappedRequest): Promise<Response> {
try {
const requestBody = request.json;
const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims);
if(!newUser) {
return new Response(null,{status: 201})
}
return Response.json(
{
...newUser
},
{status: 201}
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return Response.json({
...(await orm.users.get(request.params.id, request.claims))
}, {status: 200});
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
}

View File

@@ -1,12 +1,11 @@
import argon2 from "argon2";
import {guard} from './utilities/guard';
import auth from "./endpoints/auth";
import user from "./endpoints/user";
import {unwrapMethod, guard} from './utilities/guard.ts';
import auth from "./endpoints/auth.ts";
import user from "./endpoints/user.ts";
const server = Bun.serve({
routes: {
"/api/auth/login": {
POST: auth.login,
POST: unwrapMethod(auth.login),
},
"/api/auth/test": {
GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE'])

30
src/orm/claims.ts Normal file
View File

@@ -0,0 +1,30 @@
import {sql} from 'bun';
export class Claims {
userId: string | undefined;
claims: string[] = [];
public static test(userClaims: Claims, guardClaim: string): Boolean {
return userClaims.claims.some(x => x === guardClaim);
}
}
export class ClaimsOrm {
async getByUserId(userId: string): Promise<Claims> {
const dbResults: any[] = await sql`SELECT c.name
from user_claims as uc
JOIN claims as c on uc.claimid = c.id
where uc.userid = ${userId};`;
const claims = new Claims();
claims.userId = userId;
claims.claims = dbResults.map(x => x.name);
return claims;
}
async getDefaultClaims(): Promise<number[]> {
const dbResults: any[] = await sql`SELECT id
FROM claims
WHERE is_default = true;`;
return dbResults.map(x => x.id);
}
}

16
src/orm/orm.ts Normal file
View File

@@ -0,0 +1,16 @@
import {ClaimsOrm} from "./claims";
import {UsersOrm} from "./user";
class Orm {
claims: ClaimsOrm;
users: UsersOrm;
constructor() {
this.claims = new ClaimsOrm();
this.users = new UsersOrm(this.claims);
}
}
export const orm = new Orm();

View File

@@ -1,37 +1,10 @@
import argon2 from "argon2";
import {first} from "lodash";
import {Claims, ClaimsOrm} from "./claims";
import {sql} from "bun";
import {first} from "lodash";
import argon2 from "argon2";
import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors";
export class Claims {
userId: string | undefined;
claims: string[] = [];
public static test(userClaims: Claims, guardClaim: string): Boolean {
return userClaims.claims.some(x => x === guardClaim);
}
}
class ClaimsOrm {
async getByUserId(userId: string): Promise<Claims> {
const dbResults: any[] = await sql`SELECT c.name
from user_claims as uc
JOIN claims as c on uc.claimid = c.id
where uc.userid = ${userId};`;
const claims = new Claims();
claims.userId = userId;
claims.claims = dbResults.map(x => x.name);
return claims;
}
async getDefaultClaims(): Promise<number[]> {
const dbResults: any[] = await sql`SELECT id
FROM claims
WHERE is_default = true;`;
return dbResults.map(x => x.id);
}
}
class User {
export class User {
id: string;
name: string;
isAdmin: boolean;
@@ -45,7 +18,7 @@ class User {
}
}
class UsersOrm {
export class UsersOrm {
#claims: ClaimsOrm;
constructor(claims: ClaimsOrm) {
@@ -58,13 +31,20 @@ class UsersOrm {
Claims.test(claims, 'USERS_OTHER_READ') ||
(Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId)
)) {
throw new Error('Unauthorized');
throw new
UnauthorizedError();
}
const dbResult: any = first(await sql`select *
from users
where id = ${id}
and is_active = true limit 1`);
and is_active = true
limit 1`);
if(!dbResult) {
throw new NotFoundError('No matching user exists');
}
return new User(dbResult.id, dbResult.username, dbResult.is_admin);
}
@@ -72,24 +52,25 @@ class UsersOrm {
try {
const dbResult: any = first(await sql`select *
from users
where username = ${username} limit 1`);
where username = ${username}
limit 1`);
if (!await argon2.verify(dbResult.pass_hash, password)) {
return null;
}
return this.#claims.getByUserId(dbResult.id);
} catch (error) {
console.log(error);
throw error;
}
}
async create(username: string, password: string, claims: Claims): Promise<User> {
async create(username: string, password: string, claims: Claims): Promise<User | null> {
const existingUser: any = first(await sql`SELECT id
FROM users
WHERE username = ${username} LIMIT 1`);
WHERE username = ${username}
LIMIT 1`);
if (existingUser) {
throw new Error(`User with id ${existingUser.id} already exists`);
throw new BadRequestError(`User ${username} already exists`);
}
const defaultClaims: number[] = await this.#claims.getDefaultClaims();
@@ -103,19 +84,14 @@ class UsersOrm {
VALUES (${newUserId}, ${defaultClaims[i]})`;
}
})
if (!(
Claims.test(claims, 'ADMIN') ||
Claims.test(claims, 'USERS_OTHER_READ')
)) {
return null;
}
return await this.get(newUserId, claims);
}
}
class Orm {
claims: ClaimsOrm;
users: UsersOrm;
constructor() {
this.claims = new ClaimsOrm();
this.users = new UsersOrm(this.claims);
}
}
export const orm = new Orm();
}

View File

@@ -0,0 +1,2 @@
import {expect, test, beforeAll} from 'bun:test';
import {sql} from "bun";

35
src/tests/test-setup.ts Normal file
View File

@@ -0,0 +1,35 @@
import {beforeAll, afterAll} from 'bun:test';
import Bun from 'bun';
import {sql} from "bun";
beforeAll(async () => {
console.log(process.env.DATABASE_URL);
const scriptFile = await Bun.file('./scripts/dbCreate.sql').text();
// Drop the database in preparation for rebuild
await sql`DROP SCHEMA public CASCADE`;
await sql`CREATE SCHEMA public`;
// Run DB build script
await sql.unsafe(scriptFile);
// Populate initial data
await sql`SET search_path TO showfinder,public`;
await sql`INSERT INTO claims(name, is_default) VALUES ('ADMIN', false)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_CREATE', false)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_UPDATE', false)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_DELETE', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_READ', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_UPDATE', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_READ', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`;
});

130
src/tests/user.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import {expect, test} from 'bun:test';
import user from '../endpoints/user';
import {UnwrappedRequest} from "../utilities/guard";
import {Claims} from "../orm/claims";
test('Create user as admin', async () => {
const claims = new Claims();
claims.claims.push('ADMIN');
const request = new UnwrappedRequest({
claims,
request: null,
json: {
username: 'test1',
password: 'test123',
},
params: {},
});
const response = await user.create(request);
expect(response.status).toBe(201);
expect(response.body).toBeDefined();
});
test('Create user without read access', async () => {
const claims = new Claims();
claims.claims.push('USERS_CREATE');
const request = new UnwrappedRequest({
claims,
request: null,
json: {
username: 'test2',
password: 'test123',
},
params: {},
});
const response = await user.create(request);
expect(response.status).toBe(201);
expect(response.body).toBeNull();
});
test('Create user that already exists', async () => {
const claims = new Claims();
claims.claims.push('USERS_CREATE');
const request = new UnwrappedRequest({
claims,
request: null,
json: {
username: 'test2',
password: 'test123',
},
params: {},
});
const response = await user.create(request);
expect(response.status).toBe(400);
});
test('Get user', async () => {
const claims = new Claims();
claims.claims.push('USERS_OTHER_READ');
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
},
});
const response = await user.get(request);
const retrievedUser = await response.json();
expect(response.status).toBe(200);
expect(retrievedUser.id).toBe('1');
});
test('Get user self with only self read permission', async () => {
const claims = new Claims();
claims.userId = "1";
claims.claims.push('USERS_OTHER_READ');
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
},
});
const response = await user.get(request);
const retrievedUser = await response.json();
expect(response.status).toBe(200);
expect(retrievedUser.id).toBe('1');
});
test('Get other user without read permissions', async () => {
const claims = new Claims();
claims.userId = "2";
claims.claims.push('USERS_SELF_READ');
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
},
});
const response = await user.get(request);
expect(response.status).toBe(401);
});
test('Get user that doesn\'t exist', async () => {
const claims = new Claims();
claims.claims.push('ADMIN');
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 101
},
});
const response = await user.get(request);
expect(response.status).toBe(404);
});

17
src/utilities/errors.ts Normal file
View File

@@ -0,0 +1,17 @@
export class BadRequestError extends Error {
constructor(message: string | undefined = undefined) {
super(message);
}
}
export class UnauthorizedError extends Error {
constructor(message: string | undefined = undefined) {
super(message);
}
}
export class NotFoundError extends Error {
constructor(message: string | undefined = undefined) {
super(message);
}
}

57
src/utilities/guard.ts Normal file
View File

@@ -0,0 +1,57 @@
import {BunRequest as Request} from 'bun';
import jwt from 'jsonwebtoken';
import {Claims} from "../orm/orm.ts";
export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) {
try {
return guard(method, guardedClaims);
} catch (e) {
return redirectMethod();
}
}
export function guard(method: Function, guardedClaims: string[] | undefined = undefined):(r:Request)=>Promise<Response> {
return async (request: Request): Promise<Response> => {
const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null;
try {
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims;
console.log('Claims?', guardedClaims !== undefined, !userClaims.claims.some(x => guardedClaims?.includes(x)))
if (guardedClaims !== undefined && !userClaims.claims.some(x => guardedClaims.includes(x))) {
throw new Error('Unauthorized');
}
return method(await unwrap(request, userClaims));
} catch (e) {
return Response.json({message: 'Authentication failed.'}, {status: 401})
}
}
}
export class UnwrappedRequest {
readonly json: any;
readonly request: Request;
readonly params: { [x: string]: string };
readonly claims: Claims;
constructor(input: any) {
this.json = input.json;
this.request = input.request;
this.claims = input.claims;
this.params = input.params;
}
}
export async function unwrap(request: Request, claims: Claims | null = null) {
return new UnwrappedRequest({
request,
claims,
json: request.body ? await request.json() : null,
params: request.params,
})
}
export function unwrapMethod(methodToUnwrap:((r:UnwrappedRequest)=>Response)|((r:UnwrappedRequest)=>Promise<Response>)):(r:Request)=>Promise<Response> {
return async (request: Request) => {
const unwrappedRequest = await unwrap(request);
return await methodToUnwrap(unwrappedRequest);
};
}

View File

@@ -0,0 +1,18 @@
import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors";
export class ErrorResponse extends Response {
//@ts-ignore
constructor(error: Error) {
if(error instanceof BadRequestError) {
return Response.json({message: error.message}, {status: 400});
}
else if(error instanceof UnauthorizedError){
return Response.json({message: error.message}, {status: 401});
}
else if(error instanceof NotFoundError){
return Response.json({message: error.message}, {status: 404});
}
return Response.json({message: error.message}, {status: 500});
}
}

View File

@@ -1,25 +0,0 @@
import jwt from 'jsonwebtoken';
import {Claims} from "./orm";
export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) {
try {
return guard(method, guardedClaims);
} catch (e) {
return redirectMethod();
}
}
export function guard(method: Function, guardedClaims: string[] | undefined = undefined) {
return (request: Request): any => {
const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null;
try {
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims;
if (guardedClaims !== undefined && !userClaims.claims.some(x => guardedClaims.includes(x))) {
throw new Error('Unauthorized');
}
return method(request, userClaims);
} catch (e) {
return Response.json({message: 'Authentication failed.'}, {status: 401})
}
}
}