Reupload
This commit is contained in:
commit
61cbd57af1
168 changed files with 31208 additions and 0 deletions
12
server/.eslintrc.json
Normal file
12
server/.eslintrc.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"root": true,
|
||||
"rules": {
|
||||
"indent": ["error", "tab"],
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"object-curly-spacing": "error"
|
||||
}
|
||||
}
|
1
server/README.md
Normal file
1
server/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# awary-server
|
25
server/conf-example.env
Normal file
25
server/conf-example.env
Normal file
|
@ -0,0 +1,25 @@
|
|||
ENV=production
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8080
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=27017
|
||||
DB_USER=replace-me
|
||||
DB_PASSWORD=replace-me
|
||||
DB_NAME=awary
|
||||
|
||||
# For the following variables, you might want some long random string (if you have openssl installed you can use `openssl rand -base64 64`)
|
||||
JWT_SECRET=replace-me
|
||||
API_ADMIN_AUTHORIZATION=replace-me
|
||||
|
||||
ENABLE_USER_REGISTRATION=true
|
||||
RATE_LIMIT_ENABLED=false
|
||||
|
||||
# Uncomment to enable
|
||||
#
|
||||
# MAX_ACCOUNT=100
|
||||
# MAX_PROJECT_PER_ACCOUNT=4
|
||||
# MAX_METRIC_PER_PROJECT=5
|
||||
# METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||
# METRICS_HISTORY_LENGTH=1000
|
24
server/conf-test.env
Normal file
24
server/conf-test.env
Normal file
|
@ -0,0 +1,24 @@
|
|||
ENV=TEST
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8081
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=27018
|
||||
DB_USER=DevUser
|
||||
DB_PASSWORD=DevPassword
|
||||
DB_NAME=awary-test
|
||||
|
||||
JWT_SECRET=ujebxzQ1qZC/jAFBXnVk/KfKwqJd8U+zDo3ZX3ejWAC26XaV1J9kjxa4CGvkMpTxvhFaFBPZoL5DdKnWWEDfVQ==
|
||||
API_ADMIN_AUTHORIZATION=cM6FB3X27THShaNniHwF4bewRc8QFGe0/CIOOPiwcJLa3jtRhIjcTEVW/zPOKDrR6e5kMv+j0kFgXmDjrM/hWA==
|
||||
|
||||
ENABLE_USER_REGISTRATION=true
|
||||
RATE_LIMIT_ENABLED=false
|
||||
|
||||
MAX_ACCOUNT=100
|
||||
|
||||
MAX_PROJECT_PER_ACCOUNT=4
|
||||
|
||||
MAX_METRIC_PER_PROJECT=5
|
||||
METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||
METRICS_HISTORY_LENGTH=1000
|
15
server/create-user.sh
Normal file
15
server/create-user.sh
Normal file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
|
||||
. ./conf.env
|
||||
|
||||
BASE_URL=$1
|
||||
EMAIL=$2
|
||||
PASSWORD=`openssl rand -base64 16 | sed -e "s/=//g"`
|
||||
|
||||
echo $API_ADMIN_AUTHORIZATION
|
||||
echo $1
|
||||
echo $PASSWORD
|
||||
|
||||
curl -X POST $BASE_URL/signup \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\", \"adminToken\": \"$API_ADMIN_AUTHORIZATION\"}"
|
5240
server/package-lock.json
generated
Normal file
5240
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
server/package.json
Normal file
43
server/package.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@sinclair/typebox": "^0.23.5",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/sinon": "^10.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"@typescript-eslint/parser": "^5.54.0",
|
||||
"chai": "^4.3.6",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8.35.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha-better-spec-reporter": "^3.1.0",
|
||||
"nodemon": "^2.0.16",
|
||||
"sinon": "^14.0.0",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"ts-node": "^10.8.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsc-alias": "^1.8.2",
|
||||
"tsc-watch": "^6.0.0",
|
||||
"tsconfig-paths": "^4.0.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.0.0",
|
||||
"@fastify/jwt": "^6.1.0",
|
||||
"@fastify/type-provider-typebox": "^1.0.0",
|
||||
"argon2": "^0.28.5",
|
||||
"axios": "^0.27.2",
|
||||
"fastify": "^4.0.3",
|
||||
"mongodb": "^4.7.0",
|
||||
"nodemailer": "^6.7.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "LOGGER_ENABLED=1 env-cmd -f conf.env nodemon --watch './**/*.ts' --exec node --inspect=0.0.0.0 -r ts-node/register src/index.ts",
|
||||
"start:prod": "LOGGER_ENABLED=1 env-cmd -f conf.env node -r ts-node/register src/index.ts",
|
||||
"test": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register --exit 'src/**/*.test.ts'",
|
||||
"test-file": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register",
|
||||
"lint": "eslint src"
|
||||
}
|
||||
}
|
12
server/podman-mongodb-test.sh
Normal file
12
server/podman-mongodb-test.sh
Normal file
|
@ -0,0 +1,12 @@
|
|||
source ./conf-test.env
|
||||
|
||||
mkdir -p ./db-test
|
||||
podman stop awary_mongodb_test
|
||||
podman rm awary_mongodb_test
|
||||
podman run -dt -p 27018:27017 \
|
||||
--name awary_mongodb_test \
|
||||
--userns keep-id \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||
-v ./db-test:/data/db \
|
||||
docker.io/library/mongo:5.0
|
13
server/podman-mongodb.sh
Normal file
13
server/podman-mongodb.sh
Normal file
|
@ -0,0 +1,13 @@
|
|||
source ./conf.env
|
||||
|
||||
mkdir -p ./db
|
||||
mkdir -p ./backup
|
||||
podman stop awary_mongodb
|
||||
podman rm awary_mongodb
|
||||
podman run -dt -p 27017:27017 \
|
||||
--name awary_mongodb \
|
||||
--userns keep-id \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||
-v ./db:/data/db \
|
||||
docker.io/library/mongo:5.0
|
3
server/requests/.env-example
Normal file
3
server/requests/.env-example
Normal file
|
@ -0,0 +1,3 @@
|
|||
API_ADMIN_AUTHORIZATION=replace-me ## Must match the conf.env
|
||||
BEARER_TOKEN=random_string
|
||||
HOST=127.0.0.1:8080
|
36
server/requests/login.http
Normal file
36
server/requests/login.http
Normal file
|
@ -0,0 +1,36 @@
|
|||
### LOGIN
|
||||
|
||||
POST http://{{HOST}}/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
|
||||
{%
|
||||
local body = context.json_decode(context.result.body)
|
||||
context.set_env("BEARER_TOKEN", body.token)
|
||||
%}
|
||||
|
||||
### SIGNUP
|
||||
|
||||
POST http://{{HOST}}/signup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
### FORCE CHANGE PASSWORD
|
||||
|
||||
POST http://{{HOST}}/admin/change-user-password
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{API_ADMIN_AUTHORIZATION}}
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"newPassword": "1234567"
|
||||
}
|
56
server/src/core/App.ts
Normal file
56
server/src/core/App.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {Db} from "mongodb";
|
||||
import {UserFeature} from "./features/users";
|
||||
import {ProjectFeature} from "./features/projects";
|
||||
import {MetricFeature} from "./features/metrics";
|
||||
import {LogFeature} from "./features/logs";
|
||||
import {ActivityLoggerFeature} from "./features/activityLogger";
|
||||
import {ViewsFeature} from "./features/views";
|
||||
import {Logger} from "@app/utils/logger";
|
||||
import {ServerAdminFeature} from "./features/serverAdmin";
|
||||
|
||||
export class App {
|
||||
|
||||
db?: Db
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
viewsFeature: ViewsFeature
|
||||
activityLogger: ActivityLoggerFeature
|
||||
serverAdminFeature: ServerAdminFeature
|
||||
|
||||
constructor(db: Db) {
|
||||
const services = {db}
|
||||
this.userFeature = new UserFeature(services);
|
||||
this.projectFeature = new ProjectFeature(services, {
|
||||
userFeature: this.userFeature
|
||||
});
|
||||
this.logFeature = new LogFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature
|
||||
})
|
||||
this.metricFeature = new MetricFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature
|
||||
})
|
||||
this.viewsFeature = new ViewsFeature(services, {
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
this.activityLogger = new ActivityLoggerFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature,
|
||||
logFeature: this.logFeature,
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
this.serverAdminFeature = new ServerAdminFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature,
|
||||
logFeature: this.logFeature,
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
Logger.info("Starting application")
|
||||
}
|
||||
}
|
20
server/src/core/Feature.ts
Normal file
20
server/src/core/Feature.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {Db} from "mongodb";
|
||||
import {UserFeature} from "./features/users";
|
||||
|
||||
export type AppServices = {
|
||||
db: Db
|
||||
}
|
||||
|
||||
export type AppFeatures = {
|
||||
userFeature: UserFeature
|
||||
}
|
||||
|
||||
export abstract class Feature {
|
||||
|
||||
public abstract name: string
|
||||
protected services: AppServices
|
||||
|
||||
constructor(services: AppServices) {
|
||||
this.services = services
|
||||
}
|
||||
}
|
11
server/src/core/FeatureEvent.ts
Normal file
11
server/src/core/FeatureEvent.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export class FeatureEvent<T> {
|
||||
callbacks: ((data: T) => Promise<void>)[] = []
|
||||
|
||||
register(callback: (data: T) => Promise<void>) {
|
||||
this.callbacks.push(callback)
|
||||
}
|
||||
|
||||
async emit(data: T): Promise<void> {
|
||||
await Promise.all(this.callbacks.map(callback => callback(data)))
|
||||
}
|
||||
}
|
3
server/src/core/Identifiable.ts
Normal file
3
server/src/core/Identifiable.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export interface Identifiable {
|
||||
id: string
|
||||
}
|
3
server/src/core/exceptions/LimitReached.ts
Normal file
3
server/src/core/exceptions/LimitReached.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export class LimitReached extends Error {
|
||||
|
||||
}
|
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import {getAdminProjectId, getAdminSucessTagId} from "@app/utils";
|
||||
import {LogsUseCases} from "../logs/LogsUseCases";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {onMetricCreatedData} from "../metrics/MetricsEvents";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
import {ProjectContext} from "../projects/ProjectContext";
|
||||
import {onProjectCreatedData} from "../projects/ProjectsEvents";
|
||||
import {UserFeature} from "../users";
|
||||
import {onUserCreatedData} from "../users/UsersEvents";
|
||||
|
||||
interface ActivityLoggerDependencies {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logService: LogsUseCases
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ActivityLoggerService {
|
||||
private _userFeature: UserFeature
|
||||
private _projectFeature: ProjectFeature
|
||||
private _logService: LogsUseCases
|
||||
private _metricFeature: MetricFeature
|
||||
private _systemCaller = new Caller(new SystemCaller())
|
||||
|
||||
constructor(dependencies: ActivityLoggerDependencies) {
|
||||
this._userFeature = dependencies.userFeature
|
||||
this._projectFeature = dependencies.projectFeature
|
||||
this._logService = dependencies.logService
|
||||
this._metricFeature = dependencies.metricFeature
|
||||
|
||||
this._metricFeature.events.onMetricCreated.register(data => this.logMetricCreation(data))
|
||||
this._projectFeature.events.onProjectCreated.register(data => this.logProjectCreation(data))
|
||||
this._userFeature.events.onMetricCreated.register(data => this.adminLogUserCreation(data))
|
||||
}
|
||||
|
||||
private async adminLogUserCreation(data: onUserCreatedData): Promise<void> {
|
||||
const adminProjectId = getAdminProjectId()
|
||||
if (!adminProjectId)
|
||||
return ;
|
||||
const project = await this._projectFeature.projectRepository.findProjectById(adminProjectId)
|
||||
if (!project)
|
||||
return ;
|
||||
const context = new ProjectContext(project, this._systemCaller)
|
||||
const adminSucessTagId = getAdminSucessTagId()
|
||||
await this._logService.addLog(context, {
|
||||
title: `New user: ${data.user.email}`,
|
||||
tags: adminSucessTagId ? [adminSucessTagId] : []
|
||||
});
|
||||
}
|
||||
|
||||
private async logProjectCreation(data: onProjectCreatedData): Promise<void> {
|
||||
const context = new ProjectContext(data.project, this._systemCaller)
|
||||
await this._logService.addLog(context, {
|
||||
title: `Welcome to your new project: "${data.project.name}"`,
|
||||
});
|
||||
}
|
||||
|
||||
private async logMetricCreation(data: onMetricCreatedData): Promise<void> {
|
||||
const context = new ProjectContext(data.project, this._systemCaller)
|
||||
await this._logService.addLog(context, {
|
||||
title: `Created metric "${data.metric.name}"`,
|
||||
});
|
||||
}
|
||||
}
|
31
server/src/core/features/activityLogger/index.ts
Normal file
31
server/src/core/features/activityLogger/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {ActivityLoggerService} from "./ActivityLogger";
|
||||
|
||||
export type ActivityLoggerFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ActivityLoggerFeature extends Feature {
|
||||
|
||||
public name = "ActivityLogger"
|
||||
public service: ActivityLoggerService
|
||||
public dependencies: ActivityLoggerFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: ActivityLoggerFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.service = new ActivityLoggerService({
|
||||
userFeature: this.dependencies.userFeature,
|
||||
projectFeature: this.dependencies.projectFeature,
|
||||
logService: this.dependencies.logFeature.useCases,
|
||||
metricFeature: this.dependencies.metricFeature
|
||||
})
|
||||
}
|
||||
}
|
71
server/src/core/features/logs/LogsRepository.ts
Normal file
71
server/src/core/features/logs/LogsRepository.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {LogDataOnCreation, Log} from "./entities";
|
||||
|
||||
export interface LogDocument {
|
||||
projectId: ObjectId
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
function ConvertLogDocumentToEntity(document: WithId<LogDocument>): Log {
|
||||
return new Log({
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
title: document.title,
|
||||
tags: document.tags || [],
|
||||
content: document.content,
|
||||
createdAt: document.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
export class LogsRepository {
|
||||
|
||||
private _projectsLogs: Collection<LogDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._projectsLogs = db.collection("projectsLogs")
|
||||
}
|
||||
|
||||
async create(project: Project, data: LogDataOnCreation): Promise<void> {
|
||||
await this._projectsLogs.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
tags: data.tags,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
async findLogs(project: Project): Promise<Log[]> {
|
||||
const results = await this._projectsLogs.find({
|
||||
projectId: new ObjectId(project.id)
|
||||
})
|
||||
.sort({createdAt: -1})
|
||||
.toArray();
|
||||
const resultsWithId = results.map(value => ConvertLogDocumentToEntity(value));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findLogById(logId: string): Promise<Log | null> {
|
||||
const result = await this._projectsLogs.findOne({
|
||||
_id: new ObjectId(logId),
|
||||
});
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
return ConvertLogDocumentToEntity(result)
|
||||
}
|
||||
|
||||
async deleteLog(log: Log): Promise<void> {
|
||||
await this._projectsLogs.deleteOne({
|
||||
_id: new ObjectId(log.id),
|
||||
});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._projectsLogs.countDocuments();
|
||||
}
|
||||
}
|
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||
import {Log, LogDataOnCreation, Tag, TagOnCreation} from "./entities";
|
||||
import {LogsRepository} from "./LogsRepository";
|
||||
import {TagsRepository} from "./TagsRepository";
|
||||
|
||||
interface LogServiceDependencies {
|
||||
projectService: ProjectsUseCases,
|
||||
logsRepository: LogsRepository,
|
||||
tagsRepository: TagsRepository
|
||||
}
|
||||
|
||||
export class LogsUseCases {
|
||||
private _projectService: ProjectsUseCases
|
||||
private _logsRepository: LogsRepository
|
||||
private _tagsRepository: TagsRepository
|
||||
|
||||
constructor(dependencies: LogServiceDependencies) {
|
||||
this._projectService = dependencies.projectService
|
||||
this._logsRepository = dependencies.logsRepository
|
||||
this._tagsRepository = dependencies.tagsRepository
|
||||
}
|
||||
|
||||
async addLog(context: ProjectContext, log: LogDataOnCreation): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._logsRepository.create(context.project, {
|
||||
title: log.title,
|
||||
content: log.content,
|
||||
tags: log.tags,
|
||||
})
|
||||
}
|
||||
|
||||
async getLogById(context: ProjectContext, logId: string): Promise<Log> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const log = await this._logsRepository.findLogById(logId)
|
||||
if (!log) {
|
||||
throw new MissingResource("Log doesn't exist")
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async getLogs(context: ProjectContext) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._logsRepository.findLogs(context.project);
|
||||
}
|
||||
|
||||
async deleteLog(context: ProjectContext, log: Log) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._logsRepository.deleteLog(log);
|
||||
}
|
||||
|
||||
async createTag(context: ProjectContext, data: Omit<TagOnCreation, "projectId">): Promise<Tag> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
return this._tagsRepository.create(context.project, {
|
||||
projectId: context.project.id,
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
})
|
||||
}
|
||||
|
||||
async updateTag(context: ProjectContext, tag: Tag, data: Omit<TagOnCreation, "projectId">): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._tagsRepository.updateTag(tag, {
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
})
|
||||
}
|
||||
|
||||
async getTags(context: ProjectContext): Promise<Tag[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
return await this._tagsRepository.findByProject(context.project);
|
||||
}
|
||||
|
||||
async getTag(context: ProjectContext, id: string): Promise<Tag> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const tag = await this._tagsRepository.findById(context.project, id);
|
||||
if (!tag) {
|
||||
throw new MissingResource("Tag doesn't exist")
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
async deleteTag(context: ProjectContext, tag: Tag): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._tagsRepository.deleteTag(tag);
|
||||
}
|
||||
}
|
80
server/src/core/features/logs/TagsRepository.ts
Normal file
80
server/src/core/features/logs/TagsRepository.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {Tag, TagOnCreation} from "./entities";
|
||||
|
||||
export interface TagDocument {
|
||||
projectId: ObjectId
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
function ConvertTagDocumentToEntity(document: WithId<TagDocument>): Tag {
|
||||
return {
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
name: document.name,
|
||||
color: document.color
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsRepository {
|
||||
|
||||
private _tags: Collection<TagDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._tags = db.collection("tags")
|
||||
}
|
||||
|
||||
async create(project: Project, data: TagOnCreation): Promise<Tag> {
|
||||
const tag = await this._tags.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
name: data.name,
|
||||
color: data.color
|
||||
});
|
||||
return ConvertTagDocumentToEntity({
|
||||
_id: tag.insertedId,
|
||||
projectId: new ObjectId(project.id),
|
||||
name: data.name,
|
||||
color: data.color
|
||||
})
|
||||
}
|
||||
|
||||
async updateTag(tag: Tag, data: Partial<TagDocument>): Promise<void> {
|
||||
await this._tags.updateOne({_id: new ObjectId(tag.id)}, {
|
||||
$set: {
|
||||
name: data.name,
|
||||
color: data.color
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findByProject(project: Project): Promise<Tag[]> {
|
||||
const results = await this._tags.find({
|
||||
projectId: new ObjectId(project.id)
|
||||
})
|
||||
.sort({createdAt: -1})
|
||||
.toArray();
|
||||
const resultsWithId = results.map(value => ConvertTagDocumentToEntity(value));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findById(project: Project, id: string): Promise<Tag | null> {
|
||||
const result = await this._tags.findOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
_id: new ObjectId(id)
|
||||
})
|
||||
if (!result)
|
||||
return null
|
||||
return ConvertTagDocumentToEntity(result)
|
||||
}
|
||||
|
||||
async deleteTag(tag: Tag): Promise<void> {
|
||||
await this._tags.deleteOne({
|
||||
_id: new ObjectId(tag.id),
|
||||
});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._tags.countDocuments();
|
||||
}
|
||||
}
|
35
server/src/core/features/logs/entities/Log.ts
Normal file
35
server/src/core/features/logs/entities/Log.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
export interface LogDataOnCreation {
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface LogConstructor extends Identifiable {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export class Log {
|
||||
|
||||
public readonly id: string
|
||||
public readonly projectId: string
|
||||
public readonly title: string
|
||||
public readonly content?: string
|
||||
public readonly tags: string[]
|
||||
public readonly createdAt: number
|
||||
|
||||
constructor(data: LogConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.title = data.title
|
||||
this.content = data.content
|
||||
this.tags = data.tags || []
|
||||
this.createdAt = data.createdAt
|
||||
}
|
||||
}
|
12
server/src/core/features/logs/entities/Tag.ts
Normal file
12
server/src/core/features/logs/entities/Tag.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export interface TagOnCreation {
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
2
server/src/core/features/logs/entities/index.ts
Normal file
2
server/src/core/features/logs/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./Log"
|
||||
export * from "./Tag"
|
32
server/src/core/features/logs/index.ts
Normal file
32
server/src/core/features/logs/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {LogsRepository} from "./LogsRepository";
|
||||
import {LogsUseCases} from "./LogsUseCases";
|
||||
import {TagsRepository} from "./TagsRepository";
|
||||
|
||||
export type LogFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
}
|
||||
|
||||
export class LogFeature extends Feature {
|
||||
|
||||
name = "Log"
|
||||
logsRepository: LogsRepository
|
||||
tagsRepository: TagsRepository
|
||||
useCases: LogsUseCases
|
||||
dependencies: LogFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: LogFeatureDependencies) {
|
||||
super(services);
|
||||
this.logsRepository = new LogsRepository(services.db)
|
||||
this.tagsRepository = new TagsRepository(services.db)
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new LogsUseCases({
|
||||
projectService: this.dependencies.projectFeature.service,
|
||||
logsRepository: this.logsRepository,
|
||||
tagsRepository: this.tagsRepository
|
||||
})
|
||||
}
|
||||
}
|
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Caller} from "../projects/entities/Caller";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {Metric} from ".";
|
||||
|
||||
export type onMetricCreatedData = {
|
||||
project: Project
|
||||
metric: Metric
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class MetricEvents {
|
||||
onMetricCreated: FeatureEvent<onMetricCreatedData> = new FeatureEvent()
|
||||
}
|
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities";
|
||||
import {Metric, MetricValue} from "./entities";
|
||||
|
||||
interface MetricDocument {
|
||||
projectId: ObjectId
|
||||
name: string
|
||||
currentValue: undefined | number
|
||||
}
|
||||
|
||||
interface MetricHistoryDocument {
|
||||
metricId: ObjectId
|
||||
date: number // timestamp
|
||||
value: number
|
||||
}
|
||||
|
||||
function MetricDocumentToEntity(document: WithId<MetricDocument>, values: WithId<MetricHistoryDocument>[] | null): Metric {
|
||||
return new Metric({
|
||||
id: document._id.toString(),
|
||||
name: document.name,
|
||||
projectId: document.projectId.toString(),
|
||||
currentValue: document.currentValue,
|
||||
history: values ? values.map(MetricHistoryDocumentToEntity) : null
|
||||
})
|
||||
}
|
||||
|
||||
function MetricHistoryDocumentToEntity(document: WithId<MetricHistoryDocument>): MetricValue {
|
||||
return {
|
||||
id: document._id.toString(),
|
||||
metricId: document.metricId.toString(),
|
||||
date: document.date,
|
||||
value: document.value,
|
||||
};
|
||||
}
|
||||
|
||||
export class MetricsRepository {
|
||||
|
||||
private _metrics: Collection<MetricDocument>
|
||||
private _metricsHistory: Collection<MetricHistoryDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._metrics = db.collection("projectsMetrics")
|
||||
this._metricsHistory = db.collection("projectsMetricsHistory")
|
||||
}
|
||||
|
||||
async createMetric(project: Project, name: string): Promise<Metric> {
|
||||
const metric = await this._metrics.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
name: name,
|
||||
currentValue: undefined
|
||||
});
|
||||
return MetricDocumentToEntity({
|
||||
_id: metric.insertedId,
|
||||
projectId: new ObjectId(project.id),
|
||||
name,
|
||||
currentValue: undefined
|
||||
}, null)
|
||||
}
|
||||
|
||||
async updateMetric(metric: Metric, name: string): Promise<void> {
|
||||
await this._metrics.updateOne({_id: new ObjectId(metric.id)}, {
|
||||
$set: {
|
||||
name: name,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async AddValueToHistory(metric: Metric, value: Omit<MetricValue, "id" | "metricId">): Promise<void> {
|
||||
const metricQuery = {projectId: new ObjectId(metric.projectId), name: metric.name}
|
||||
const newValueDocument = {...value, metricId: new ObjectId(metric.id)}
|
||||
await this._metricsHistory.insertOne(newValueDocument);
|
||||
await this._metrics.updateOne(metricQuery, {$set: {currentValue: value.value}})
|
||||
}
|
||||
|
||||
async deleteHistoryRecord(metric: Metric, recordId: string): Promise<void> {
|
||||
const deleteFilter = {metricId: new ObjectId(metric.id), _id: new ObjectId(recordId)}
|
||||
await this._metricsHistory.deleteOne(deleteFilter);
|
||||
}
|
||||
|
||||
async findAll(project: Project, fetchValues = false): Promise<Metric[]> {
|
||||
const metric = await this._metrics.find({projectId: new ObjectId(project.id)}).toArray()
|
||||
const metricIds = metric.map(metric => metric._id);
|
||||
const allMetricValues = await this._metricsHistory.find({metricId: {$in: metricIds}}).sort({date: 1}).toArray()
|
||||
return metric.map(thisMetric => {
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(thisMetric, null);
|
||||
}
|
||||
const values = allMetricValues.filter(value => value.metricId.equals(thisMetric._id))
|
||||
return MetricDocumentToEntity(thisMetric, values);
|
||||
})
|
||||
}
|
||||
|
||||
async findOne(project: Project, name: string, fetchValues = false): Promise<Metric | null> {
|
||||
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name})
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(metric, null);
|
||||
}
|
||||
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||
return MetricDocumentToEntity(metric, values);
|
||||
}
|
||||
|
||||
async findMetricById(project: Project, id: string, fetchValues = false): Promise<Metric | null> {
|
||||
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id),_id: new ObjectId(id)})
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(metric, null);
|
||||
}
|
||||
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||
return MetricDocumentToEntity(metric, values);
|
||||
}
|
||||
|
||||
async MetricExists(project: Project, name: string): Promise<boolean> {
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name});
|
||||
return metric !== null
|
||||
}
|
||||
|
||||
async deleteMetric(metric: Metric): Promise<void> {
|
||||
const metricId = new ObjectId(metric.id);
|
||||
await this._metricsHistory.deleteMany({metricId: metricId})
|
||||
await this._metrics.deleteOne({_id: metricId})
|
||||
}
|
||||
|
||||
async getHistoryLengthOfMetric(metric: Metric): Promise<number> {
|
||||
return this._metricsHistory.countDocuments({metricId: new ObjectId(metric.id)});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._metrics.countDocuments();
|
||||
}
|
||||
|
||||
async countHistory(): Promise<number> {
|
||||
return this._metricsHistory.countDocuments();
|
||||
}
|
||||
}
|
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||
import {getMetricsHistoryLimit} from "../users/utils";
|
||||
import {Metric, MetricCreationProperties} from "./entities";
|
||||
import {MetricEvents} from "./MetricsEvents";
|
||||
import {MetricsRepository} from "./MetricsRepository";
|
||||
|
||||
interface MetricServiceDependencies {
|
||||
projectService: ProjectsUseCases,
|
||||
metricRepository: MetricsRepository,
|
||||
metricEvents: MetricEvents
|
||||
}
|
||||
|
||||
export class MetricsUseCases {
|
||||
private _projectService: ProjectsUseCases
|
||||
private _metricRepository: MetricsRepository
|
||||
private _metricEvents: MetricEvents
|
||||
|
||||
constructor(dependencies: MetricServiceDependencies) {
|
||||
this._projectService = dependencies.projectService
|
||||
this._metricRepository = dependencies.metricRepository
|
||||
this._metricEvents = dependencies.metricEvents
|
||||
}
|
||||
|
||||
async createMetric(context: ProjectContext, metricInfo: MetricCreationProperties): Promise<Metric> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project, caller} = context;
|
||||
|
||||
const metric = await this._metricRepository.createMetric(project, metricInfo.name)
|
||||
await this._metricEvents.onMetricCreated.emit({project, metric: metric, caller});
|
||||
return metric
|
||||
}
|
||||
|
||||
async updateMetric(context: ProjectContext, metric: Metric, metricInfo: MetricCreationProperties): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._metricRepository.updateMetric(metric, metricInfo.name)
|
||||
}
|
||||
|
||||
async getAllMetrics(context: ProjectContext, fetchValues?: boolean): Promise<Metric[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const metric = await this._metricRepository.findAll(context.project, fetchValues);
|
||||
return metric
|
||||
}
|
||||
|
||||
async getMetricById(context: ProjectContext, metricId: string, fetchValues?: boolean): Promise<Metric> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const metric = await this._metricRepository.findMetricById(context.project, metricId, fetchValues);
|
||||
if (!metric) {
|
||||
throw new MissingResource("Metric doesn't exist")
|
||||
}
|
||||
return metric
|
||||
}
|
||||
|
||||
async deleteMetric(context: ProjectContext, metric: Metric): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._metricRepository.deleteMetric(metric);
|
||||
}
|
||||
|
||||
async deleteMetricHistoryRecord(context: ProjectContext, metric: Metric, recordId: string): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._metricRepository.deleteHistoryRecord(metric, recordId);
|
||||
}
|
||||
|
||||
async setMetricValue(context: ProjectContext, metric: Metric, value: number, date?: number) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
const currentHistoryLength = await this._metricRepository.getHistoryLengthOfMetric(metric)
|
||||
|
||||
if (currentHistoryLength >= getMetricsHistoryLimit()) {
|
||||
throw new LimitReached("History limit reached (experimental feature)")
|
||||
}
|
||||
|
||||
await this._metricRepository.AddValueToHistory(metric, {
|
||||
date: date || Date.now(),
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
|
||||
export interface MetricCreationProperties {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MetricConstructor extends Identifiable {
|
||||
projectId: string
|
||||
name: string
|
||||
history: MetricValue[] | null
|
||||
currentValue: undefined | number
|
||||
}
|
||||
|
||||
export interface MetricValue extends Identifiable {
|
||||
metricId: string
|
||||
date: number // timestamp
|
||||
value: number
|
||||
}
|
||||
|
||||
export class Metric implements MetricConstructor {
|
||||
|
||||
public readonly id: string
|
||||
public readonly projectId: string
|
||||
public readonly name: string
|
||||
public readonly history: MetricValue[] | null
|
||||
public readonly currentValue: undefined | number
|
||||
|
||||
constructor(data: MetricConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.name = data.name
|
||||
this.history = data.history
|
||||
this.currentValue = data.currentValue
|
||||
}
|
||||
|
||||
hasValues(): boolean {
|
||||
return this.history !== null
|
||||
}
|
||||
}
|
1
server/src/core/features/metrics/entities/index.ts
Normal file
1
server/src/core/features/metrics/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./Metric"
|
35
server/src/core/features/metrics/index.ts
Normal file
35
server/src/core/features/metrics/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {MetricEvents} from "./MetricsEvents";
|
||||
import {MetricsRepository} from "./MetricsRepository";
|
||||
import {MetricsUseCases} from "./MetricsUseCases";
|
||||
|
||||
export * from "./MetricsUseCases"
|
||||
export * from "./entities"
|
||||
|
||||
export type MetricFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
}
|
||||
|
||||
export class MetricFeature extends Feature {
|
||||
|
||||
name = "Metric"
|
||||
events: MetricEvents = new MetricEvents()
|
||||
dependencies: MetricFeatureDependencies
|
||||
|
||||
repository: MetricsRepository
|
||||
useCases: MetricsUseCases
|
||||
|
||||
constructor(services: AppServices, dependencies: MetricFeatureDependencies) {
|
||||
super(services);
|
||||
this.repository = new MetricsRepository(services.db)
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new MetricsUseCases({
|
||||
projectService: this.dependencies.projectFeature.service,
|
||||
metricRepository: this.repository,
|
||||
metricEvents: this.events
|
||||
})
|
||||
}
|
||||
}
|
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
import {ApiKey} from "./entities/ApiKey";
|
||||
import {ApiKeyData} from "./entities/ApiKeyData";
|
||||
import {Project} from "./entities/Project";
|
||||
|
||||
export class ApiKeyRepository {
|
||||
|
||||
private _repository: Collection<Omit<ApiKeyData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._repository = db.collection("projectsApiKeys")
|
||||
}
|
||||
|
||||
async create(data: Omit<ApiKeyData, "id">): Promise<void> {
|
||||
await this._repository.insertOne(data);
|
||||
}
|
||||
|
||||
async delete(apiKey: ApiKey): Promise<void> {
|
||||
await this._repository.deleteOne({_id: new ObjectId(apiKey.id)});
|
||||
}
|
||||
|
||||
async findByKey(key: string): Promise<ApiKey | null> {
|
||||
const result = await this._repository.findOne({key});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return new ApiKey({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
const result = await this._repository.findOne({_id: new ObjectId(id)});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return new ApiKey({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async findAllByProject(project: Project): Promise<ApiKey[]> {
|
||||
const results = await this._repository.find({projectId: project.id}).toArray();
|
||||
const resultsWithId = results.map(value => new ApiKey(({...value, id: value._id.toString()})));
|
||||
return resultsWithId;
|
||||
}
|
||||
}
|
33
server/src/core/features/projects/ProjectContext.ts
Normal file
33
server/src/core/features/projects/ProjectContext.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {Project, Caller} from "./entities";
|
||||
import {MissingAuthorization} from "./exceptions/MissingAuthorization";
|
||||
|
||||
export enum ProjectAuthorization {
|
||||
Read = 'Read',
|
||||
Write = 'Write'
|
||||
}
|
||||
|
||||
export class ProjectContext {
|
||||
|
||||
project: Project
|
||||
caller: Caller
|
||||
|
||||
constructor(project: Project, caller: Caller) {
|
||||
this.project = project
|
||||
this.caller = caller
|
||||
}
|
||||
|
||||
enforceAuthorizations(authorizations: string[]): void {
|
||||
if (this.caller.isSystem())
|
||||
return
|
||||
if (this.caller.isUser() && this.caller.asUser().id === this.project.ownerId)
|
||||
return
|
||||
if (this.caller.isApiKey() && this.caller.asApiKey().projectId === this.project.id) {
|
||||
const hasAuthorizations = authorizations.every(authorization =>
|
||||
this.caller.asApiKey().hasAuthorization(authorization)
|
||||
)
|
||||
if (hasAuthorizations)
|
||||
return;
|
||||
}
|
||||
throw new MissingAuthorization("Missing authorization");
|
||||
}
|
||||
}
|
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Project, Caller} from "../projects/entities";
|
||||
|
||||
export type onProjectCreatedData = {
|
||||
project: Project
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class ProjectEvents {
|
||||
onProjectCreated: FeatureEvent<onProjectCreatedData> = new FeatureEvent()
|
||||
}
|
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {Project, ProjectData, Tag} from "./entities";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
|
||||
export class ProjectsRepository {
|
||||
|
||||
private _projects: Collection<Omit<ProjectData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._projects = db.collection("projects")
|
||||
}
|
||||
|
||||
async createProject(data: Omit<ProjectData, "id">): Promise<Project> {
|
||||
const createProject = await this._projects.insertOne(data);
|
||||
return this._format({...data, id: createProject.insertedId.toString()})
|
||||
}
|
||||
|
||||
async saveProject(project: Project): Promise<void> {
|
||||
await this._projects.updateOne(
|
||||
{_id: new ObjectId(project.id)},
|
||||
{$set: {...project.getState(), updatedAt: Date.now()}}
|
||||
)
|
||||
}
|
||||
|
||||
async addTag(project: Project, tag: Tag) : Promise<void> {
|
||||
await this._projects.updateOne({_id: new ObjectId(project.id)}, {$push: {tags: tag}});
|
||||
}
|
||||
|
||||
async findProjectsOfUser(user: User): Promise<Project[]> {
|
||||
const results = await this._projects.find({ownerId: user.id}).toArray();
|
||||
const resultsWithId = results.map(value => this._format({...value, id: value._id.toString()}));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findProjectById(id: string): Promise<Project | null> {
|
||||
const result = await this._projects.findOne({_id: new ObjectId(id)});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return this._format({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._projects.countDocuments();
|
||||
}
|
||||
|
||||
/*async createLog(data: Omit<LogData, "id">): Promise<void> {
|
||||
await this._projectsLogs.insertOne(data);
|
||||
}
|
||||
|
||||
async findLogsOfProject(project: Project): Promise<LogData[]> {
|
||||
const results = await this._projectsLogs.find({projectId: project.id}).toArray();
|
||||
const resultsWithId = results.map(value => ({...value, id: value._id.toString()}));
|
||||
return resultsWithId
|
||||
}*/
|
||||
|
||||
private _format(data: Partial<ProjectData>): Project {
|
||||
if (!data.id) {
|
||||
throw Error("Id is undefined")
|
||||
}
|
||||
if (!data.ownerId) {
|
||||
throw Error("ownerId is undefined")
|
||||
}
|
||||
return new Project ({
|
||||
id: data.id,
|
||||
ownerId: data.ownerId,
|
||||
name: data.name || "Missing name",
|
||||
tags: data.tags || [],
|
||||
createdAt: data.createdAt || 0,
|
||||
updatedAt: data.updatedAt || 0
|
||||
})
|
||||
}
|
||||
}
|
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {randomBytes} from "crypto";
|
||||
import {getAccountProjectsLimit} from "../users/utils";
|
||||
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||
import {ApiKey, Caller, Project} from "./entities";
|
||||
import {MissingResource} from "./exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "./ProjectContext";
|
||||
import {ProjectEvents} from "./ProjectsEvents";
|
||||
import {ProjectsRepository} from "./ProjectsRepository";
|
||||
|
||||
interface ProjectServiceDependencies {
|
||||
projectRepository: ProjectsRepository,
|
||||
projectEvents: ProjectEvents
|
||||
apiKeyRepository: ApiKeyRepository
|
||||
}
|
||||
|
||||
export class ProjectsUseCases {
|
||||
private _projectRepository: ProjectsRepository
|
||||
private _projectEvents: ProjectEvents
|
||||
private _apiKeyRepository: ApiKeyRepository
|
||||
|
||||
constructor(dependencies: ProjectServiceDependencies) {
|
||||
this._projectRepository = dependencies.projectRepository
|
||||
this._projectEvents = dependencies.projectEvents
|
||||
this._apiKeyRepository = dependencies.apiKeyRepository
|
||||
}
|
||||
|
||||
async createProject(owner: User, name: string): Promise<Project> {
|
||||
const userProjects = await this._projectRepository.findProjectsOfUser(owner);
|
||||
if (userProjects.length >= getAccountProjectsLimit()) {
|
||||
throw new LimitReached(`Maximum account limit (${getAccountProjectsLimit()}) reached`)
|
||||
}
|
||||
const project = await this._projectRepository.createProject({
|
||||
name,
|
||||
ownerId: owner.id,
|
||||
tags: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
await this._projectEvents.onProjectCreated.emit({project, caller: new Caller(owner)})
|
||||
return project;
|
||||
}
|
||||
|
||||
async getProjectsOfUser(user: User): Promise<Project[]> {
|
||||
const projects = await this._projectRepository.findProjectsOfUser(user);
|
||||
return projects;
|
||||
}
|
||||
|
||||
async getProjectById(caller: Caller, id: string): Promise<Project | null> {
|
||||
const project = await this._projectRepository.findProjectById(id);
|
||||
if (!project) {
|
||||
throw new MissingResource("Project not found")
|
||||
}
|
||||
const context = new ProjectContext(project, caller)
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return project;
|
||||
}
|
||||
|
||||
async addTagToProject(context: ProjectContext, {name, color}: {name: string, color: string}): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
await this._projectRepository.addTag(project, {id: project.tags.length + 1, name, color});
|
||||
}
|
||||
|
||||
async updateTagOfProject(context: ProjectContext, id: number, {name, color}: {name: string, color: string}): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
project.changeTag(id, name, color);
|
||||
await this._projectRepository.saveProject(project);
|
||||
}
|
||||
|
||||
async generateApiKey(context: ProjectContext, name: string): Promise<string> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const apiKey = randomBytes(32).toString("base64").replace(/=/gi, "");
|
||||
await this._apiKeyRepository.create({
|
||||
key: apiKey,
|
||||
projectId: context.project.id,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
return apiKey
|
||||
}
|
||||
|
||||
async getApiKeysOfProject(context: ProjectContext): Promise<ApiKey[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return this._apiKeyRepository.findAllByProject(context.project);
|
||||
}
|
||||
|
||||
async deleteApiKeys(context: ProjectContext, apiKey: ApiKey): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
return this._apiKeyRepository.delete(apiKey);
|
||||
}
|
||||
|
||||
async getApiKey(context: ProjectContext, apiKey: string): Promise<ApiKey | null> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return this._apiKeyRepository.findByKey(apiKey);
|
||||
}
|
||||
|
||||
async getApiKeyById(context: ProjectContext, keyId: string): Promise<ApiKey> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
const apiKey = await this._apiKeyRepository.findById(keyId);
|
||||
if (!apiKey) {
|
||||
throw new MissingResource("Api key not found")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
}
|
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {ProjectAuthorization} from "../ProjectContext";
|
||||
import {ApiKeyData} from "./ApiKeyData";
|
||||
|
||||
export interface ApiKey extends Readonly<ApiKeyData> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export class ApiKey {
|
||||
|
||||
authorizations: string[]
|
||||
|
||||
constructor(data: ApiKeyData) {
|
||||
Object.assign(this, data);
|
||||
this.authorizations = [ProjectAuthorization.Read, ProjectAuthorization.Write]
|
||||
}
|
||||
|
||||
hasAuthorization(authorization: string): boolean {
|
||||
return this.authorizations.includes(authorization);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.key
|
||||
}
|
||||
|
||||
getState(): ApiKeyData {
|
||||
return {
|
||||
id: this.id,
|
||||
key: this.key,
|
||||
name: this.name,
|
||||
projectId: this.projectId,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface ApiKeyData {
|
||||
id: string
|
||||
key: string
|
||||
projectId: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
73
server/src/core/features/projects/entities/Caller.ts
Normal file
73
server/src/core/features/projects/entities/Caller.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {User} from "@app/core/features/users/entities";
|
||||
import {ApiKey} from "./ApiKey";
|
||||
import {Project} from "./Project";
|
||||
|
||||
export class SystemCaller {
|
||||
|
||||
}
|
||||
|
||||
export class AdminUser {
|
||||
|
||||
}
|
||||
|
||||
export class Caller {
|
||||
|
||||
private _user?: User
|
||||
private _apiKey?: ApiKey
|
||||
private _isSystem = false
|
||||
private _isAdmin = false
|
||||
|
||||
constructor(caller: User | ApiKey | SystemCaller | AdminUser) {
|
||||
if (caller instanceof User) {
|
||||
this._user = caller
|
||||
} else if (caller instanceof ApiKey) {
|
||||
this._apiKey = caller
|
||||
} else if (caller instanceof SystemCaller) {
|
||||
this._isSystem = true
|
||||
} else if (caller instanceof AdminUser) {
|
||||
this._isAdmin = true
|
||||
} else {
|
||||
throw new Error("Wrong type for Caller constructor")
|
||||
}
|
||||
}
|
||||
|
||||
CanReadProject(project: Project): boolean {
|
||||
if (this?._user && this._user.id === project.ownerId)
|
||||
return true
|
||||
if (this?._apiKey && this._apiKey.projectId === project.id)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
CanWriteProject(project: Project): boolean {
|
||||
return this.CanReadProject(project)
|
||||
}
|
||||
|
||||
isApiKey(): boolean {
|
||||
return !!this._apiKey
|
||||
}
|
||||
|
||||
isUser(): boolean {
|
||||
return !!this._user
|
||||
}
|
||||
|
||||
isSystem(): boolean {
|
||||
return this._isSystem;
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return this._isAdmin;
|
||||
}
|
||||
|
||||
asApiKey(): ApiKey {
|
||||
if (!this.isApiKey())
|
||||
throw new Error("Trying to get caller User as ApiKey")
|
||||
return this._apiKey as ApiKey
|
||||
}
|
||||
|
||||
asUser(): User {
|
||||
if (!this.isUser())
|
||||
throw new Error("Trying to get caller ApiKey as User")
|
||||
return this._user as User
|
||||
}
|
||||
}
|
45
server/src/core/features/projects/entities/Project.ts
Normal file
45
server/src/core/features/projects/entities/Project.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {Tag} from "./Tag";
|
||||
import {ProjectData} from "./ProjectData";
|
||||
|
||||
export class Project implements ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
tags: Tag[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
|
||||
constructor(data: ProjectData) {
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
this.ownerId = data.ownerId
|
||||
this.tags = data.tags
|
||||
this.createdAt = data.createdAt
|
||||
this.updatedAt = data.updatedAt
|
||||
}
|
||||
|
||||
getState(): ProjectData {
|
||||
return {
|
||||
id: this.id,
|
||||
ownerId: this.ownerId,
|
||||
name: this.name,
|
||||
tags: this.tags,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
changeTag(id: number, name: string, color: string): Tag {
|
||||
const tag = this.tags.find(tag => tag.id === id)
|
||||
if (!tag) {
|
||||
throw new Error(`Tag '${id}' doesn't exist`);
|
||||
}
|
||||
tag.name = name;
|
||||
tag.color = color;
|
||||
return tag;
|
||||
}
|
||||
|
||||
hasTag(name: string): boolean {
|
||||
return this.tags.find(tag => tag.name === name) !== undefined
|
||||
}
|
||||
}
|
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {Tag} from "./Tag"
|
||||
|
||||
export interface ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
tags: Tag[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
5
server/src/core/features/projects/entities/Tag.ts
Normal file
5
server/src/core/features/projects/entities/Tag.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
6
server/src/core/features/projects/entities/index.ts
Normal file
6
server/src/core/features/projects/entities/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from "./ApiKey"
|
||||
export * from "./ApiKeyData"
|
||||
export * from "./Caller"
|
||||
export * from "./Project"
|
||||
export * from "./ProjectData"
|
||||
export * from "./Tag"
|
|
@ -0,0 +1,3 @@
|
|||
export class MissingAuthorization extends Error {
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export class MissingResource extends Error {
|
||||
|
||||
}
|
32
server/src/core/features/projects/index.ts
Normal file
32
server/src/core/features/projects/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {UserFeature} from "../users";
|
||||
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||
import {ProjectEvents} from "./ProjectsEvents";
|
||||
import {ProjectsRepository} from "./ProjectsRepository";
|
||||
import {ProjectsUseCases} from "./ProjectsUseCases";
|
||||
|
||||
export type ProjectFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
}
|
||||
|
||||
export class ProjectFeature extends Feature {
|
||||
|
||||
name = "Project"
|
||||
projectRepository: ProjectsRepository
|
||||
apiKeyRepository: ApiKeyRepository
|
||||
service: ProjectsUseCases
|
||||
dependencies: ProjectFeatureDependencies
|
||||
events: ProjectEvents = new ProjectEvents()
|
||||
|
||||
constructor(services: AppServices, dependencies: ProjectFeatureDependencies) {
|
||||
super(services);
|
||||
this.projectRepository = new ProjectsRepository(services.db)
|
||||
this.apiKeyRepository = new ApiKeyRepository(services.db)
|
||||
this.service = new ProjectsUseCases({
|
||||
projectRepository: this.projectRepository,
|
||||
projectEvents: this.events,
|
||||
apiKeyRepository: this.apiKeyRepository
|
||||
})
|
||||
this.dependencies = dependencies
|
||||
}
|
||||
}
|
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
import {UserFeature} from "../users";
|
||||
import {AuthenticationFailed} from "../users/exceptions/AuthenticationFailed";
|
||||
|
||||
interface ServerAdminDependencies {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature,
|
||||
metricFeature: MetricFeature,
|
||||
}
|
||||
|
||||
export class ServerAdminUseCases {
|
||||
private _userFeature: UserFeature
|
||||
private _projectFeature: ProjectFeature
|
||||
private _logFeature: LogFeature
|
||||
private _metricFeature: MetricFeature
|
||||
private _systemCaller = new Caller(new SystemCaller())
|
||||
|
||||
constructor(dependencies: ServerAdminDependencies) {
|
||||
this._userFeature = dependencies.userFeature
|
||||
this._projectFeature = dependencies.projectFeature
|
||||
this._logFeature = dependencies.logFeature
|
||||
this._metricFeature = dependencies.metricFeature
|
||||
}
|
||||
|
||||
async getGlobalStats(caller: Caller): Promise<GlobalStats> {
|
||||
if (!caller.isAdmin()) {
|
||||
throw new AuthenticationFailed("User needs to be Admin");
|
||||
}
|
||||
|
||||
const userCount = await this._userFeature.repository.count();
|
||||
const projectCount = await this._projectFeature.projectRepository.count();
|
||||
const logCount = await this._logFeature.logsRepository.count();
|
||||
const metricCount = await this._metricFeature.repository.count();
|
||||
const metricHistoryEntryCount = await this._metricFeature.repository.countHistory();
|
||||
|
||||
return {
|
||||
userCount,
|
||||
projectCount,
|
||||
logCount,
|
||||
metricCount,
|
||||
metricHistoryEntryCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GlobalStats = {
|
||||
userCount: number
|
||||
projectCount: number
|
||||
logCount: number
|
||||
metricCount: number
|
||||
metricHistoryEntryCount: number
|
||||
}
|
31
server/src/core/features/serverAdmin/index.ts
Normal file
31
server/src/core/features/serverAdmin/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {ServerAdminUseCases} from "./ServerAdminUseCases";
|
||||
|
||||
export type ServerAdminFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ServerAdminFeature extends Feature {
|
||||
|
||||
public name = "ActivityLogger"
|
||||
public useCases: ServerAdminUseCases
|
||||
public dependencies: ServerAdminFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: ServerAdminFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new ServerAdminUseCases({
|
||||
userFeature: this.dependencies.userFeature,
|
||||
projectFeature: this.dependencies.projectFeature,
|
||||
logFeature: this.dependencies.logFeature,
|
||||
metricFeature: this.dependencies.metricFeature
|
||||
})
|
||||
}
|
||||
}
|
12
server/src/core/features/users/UsersEvents.ts
Normal file
12
server/src/core/features/users/UsersEvents.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Caller} from "../projects/entities";
|
||||
import {User} from "./entities";
|
||||
|
||||
export type onUserCreatedData = {
|
||||
user: User
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class UserEvents {
|
||||
onMetricCreated: FeatureEvent<onUserCreatedData> = new FeatureEvent()
|
||||
}
|
46
server/src/core/features/users/UsersRepository.ts
Normal file
46
server/src/core/features/users/UsersRepository.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import {UserData} from "./entities/UserData";
|
||||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
import { User } from "./entities";
|
||||
|
||||
export class UsersRepository {
|
||||
|
||||
private _users: Collection<Omit<UserData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._users = db.collection("users")
|
||||
}
|
||||
|
||||
async createUser(data: Omit<UserData, "id">): Promise<void> {
|
||||
await this._users.insertOne(data);
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<UserData | null> {
|
||||
const userData = await this._users.findOne({_id: new ObjectId(id)});
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return {...userData, id: userData._id.toString()};
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string): Promise<UserData | null> {
|
||||
const userData = await this._users.findOne({email});
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return {...userData, id: userData._id.toString()};
|
||||
}
|
||||
|
||||
async findAll(): Promise<UserData[]> {
|
||||
const userData = await this._users.find().toArray();
|
||||
return userData.map(data => ({...data, id: data._id.toString()}));
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._users.countDocuments();
|
||||
}
|
||||
|
||||
async updateUser(user: UserData, data: Partial<Omit<UserData, "id">>): Promise<boolean> {
|
||||
const res = await this._users.updateOne({_id: new ObjectId(user.id)}, {$set: data})
|
||||
return res.matchedCount > 0
|
||||
}
|
||||
}
|
109
server/src/core/features/users/UsersUseCases.ts
Normal file
109
server/src/core/features/users/UsersUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {User} from "./entities/User";
|
||||
import {UsersRepository} from "./UsersRepository";
|
||||
import argon2 from "argon2";
|
||||
import {AuthenticationFailed} from "./exceptions/AuthenticationFailed";
|
||||
import {getAccountCreationLimit, isRegistrationEnabled} from "./utils";
|
||||
import {SignupFailed, SignupFailedCode} from "./exceptions/SignupFailed";
|
||||
import {UserEvents} from "./UsersEvents";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
|
||||
interface UsersUseCasesDependencies {
|
||||
repository: UsersRepository,
|
||||
events: UserEvents
|
||||
}
|
||||
|
||||
export class UsersUseCases {
|
||||
private _repository: UsersRepository
|
||||
private _events: UserEvents
|
||||
|
||||
constructor(dependencies: UsersUseCasesDependencies) {
|
||||
this._repository = dependencies.repository
|
||||
this._events = dependencies.events
|
||||
}
|
||||
|
||||
// [TODO] This function doesn't really belong here, make a new feature (ex: reporter)
|
||||
async getGlobalInfo(adminToken: string) {
|
||||
if (adminToken !== process.env.API_ADMIN_AUTHORIZATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
users: (await this._repository.findAll()).map(user => ({
|
||||
...user,
|
||||
createdAt: new Date(user.createdAt),
|
||||
password: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async adminChangeUserPassword(caller: Caller, email: string, newPassword: string) {
|
||||
if (!caller.isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this._repository.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new SignupFailed("Target user not found", SignupFailedCode.Unknown)
|
||||
}
|
||||
|
||||
return this._repository.updateUser(user, {password: await argon2.hash(newPassword)})
|
||||
}
|
||||
|
||||
async signupUser(email: string, password: string, adminToken?: string) {
|
||||
if (!isRegistrationEnabled(adminToken)) {
|
||||
throw new SignupFailed("User signup not enabled", SignupFailedCode.NotEnabled)
|
||||
}
|
||||
|
||||
if (await this._repository.count() >= getAccountCreationLimit()) {
|
||||
throw new SignupFailed(`Maximum account limit (${getAccountCreationLimit()}) reached`, SignupFailedCode.LimitReached)
|
||||
}
|
||||
|
||||
const user = await this._repository.findUserByEmail(email);
|
||||
if (user) {
|
||||
throw new SignupFailed("Email is already taken", SignupFailedCode.EmailAlreadyInUse)
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await this._repository.createUser({
|
||||
email,
|
||||
password: passwordHash,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
const createdUser = await this._repository.findUserByEmail(email)
|
||||
|
||||
if (!createdUser) {
|
||||
throw new SignupFailed("Error in user creation", SignupFailedCode.Unknown)
|
||||
}
|
||||
const caller = new Caller(new SystemCaller())
|
||||
await this._events.onMetricCreated.emit({user: new User(createdUser), caller})
|
||||
|
||||
/*if (!process.env.TEST) {*/
|
||||
/*await mailService.SendMail(*/
|
||||
/*`Confirm your email on Cronarium : https://my.awary.com/signup-email-confirmation?email=${email}&emailConfirmationToken=${createdUser?.GetDocument().emailConfirmationToken}`,*/
|
||||
/*[email]);*/
|
||||
/*}*/
|
||||
}
|
||||
|
||||
async logUser(email: string, password: string): Promise<User> {
|
||||
const userData = await this._repository.findUserByEmail(email);
|
||||
if (!userData) {
|
||||
throw new AuthenticationFailed("Wrong email or password")
|
||||
}
|
||||
const user = new User(userData);
|
||||
if (!await user.verifyPassword(password)) {
|
||||
throw new AuthenticationFailed("Wrong email or password")
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const userData = await this._repository.findUserByEmail(email);
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return new User(userData);
|
||||
}
|
||||
}
|
26
server/src/core/features/users/entities/User.ts
Normal file
26
server/src/core/features/users/entities/User.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {UserData} from "./UserData";
|
||||
import argon2 from "argon2"
|
||||
|
||||
export interface User extends Readonly<UserData> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export class User {
|
||||
constructor(data: UserData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
async verifyPassword(password: string): Promise<boolean> {
|
||||
return this.password ? argon2.verify(this.password, password) : false;
|
||||
}
|
||||
|
||||
getState(): UserData {
|
||||
return {
|
||||
id: this.id,
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
7
server/src/core/features/users/entities/UserData.ts
Normal file
7
server/src/core/features/users/entities/UserData.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface UserData {
|
||||
id: string
|
||||
email: string
|
||||
password: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
2
server/src/core/features/users/entities/index.ts
Normal file
2
server/src/core/features/users/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./User"
|
||||
export * from "./UserData"
|
|
@ -0,0 +1,3 @@
|
|||
export class AuthenticationFailed extends Error {
|
||||
|
||||
}
|
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export enum SignupFailedCode {
|
||||
NotEnabled,
|
||||
EmailAlreadyInUse,
|
||||
LimitReached,
|
||||
Unknown
|
||||
}
|
||||
|
||||
export class SignupFailed extends Error {
|
||||
constructor(message: string, public errorCode: SignupFailedCode) {
|
||||
super(message)
|
||||
}
|
||||
}
|
19
server/src/core/features/users/index.ts
Normal file
19
server/src/core/features/users/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {UserEvents} from "./UsersEvents";
|
||||
import {UsersRepository} from "./UsersRepository";
|
||||
import {UsersUseCases} from "./UsersUseCases";
|
||||
|
||||
export class UserFeature extends Feature {
|
||||
|
||||
name = "User"
|
||||
repository: UsersRepository
|
||||
useCases: UsersUseCases
|
||||
events: UserEvents
|
||||
|
||||
constructor(services: AppServices) {
|
||||
super(services);
|
||||
this.repository = new UsersRepository(services.db)
|
||||
this.events = new UserEvents()
|
||||
this.useCases = new UsersUseCases({repository: this.repository, events: this.events})
|
||||
}
|
||||
}
|
22
server/src/core/features/users/utils.ts
Normal file
22
server/src/core/features/users/utils.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export function isRegistrationEnabled(adminToken?: string): boolean {
|
||||
if (adminToken && adminToken === process.env.API_ADMIN_AUTHORIZATION) {
|
||||
return true;
|
||||
}
|
||||
return process.env.ENABLE_USER_REGISTRATION === "true";
|
||||
}
|
||||
|
||||
export function getAccountCreationLimit(): number {
|
||||
return process.env.MAX_ACCOUNT ? parseInt(process.env.MAX_ACCOUNT) : 9999999;
|
||||
}
|
||||
|
||||
export function getAccountProjectsLimit(): number {
|
||||
return process.env.MAX_PROJECT_PER_ACCOUNT ? parseInt(process.env.MAX_PROJECT_PER_ACCOUNT) : 9999999;
|
||||
}
|
||||
|
||||
export function getMetricsUpdateLimit(): number {
|
||||
return process.env.METRICS_MAX_UPDATE_PER_MINUTE ? parseInt(process.env.METRICS_MAX_UPDATE_PER_MINUTE) : 9999999;
|
||||
}
|
||||
|
||||
export function getMetricsHistoryLimit(): number {
|
||||
return process.env.METRICS_HISTORY_LENGTH ? parseInt(process.env.METRICS_HISTORY_LENGTH) : 9999999;
|
||||
}
|
81
server/src/core/features/views/ViewsRepository.ts
Normal file
81
server/src/core/features/views/ViewsRepository.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities";
|
||||
import {View, ViewProvider} from "./entities";
|
||||
|
||||
interface ViewDocument {
|
||||
projectId: ObjectId
|
||||
type: string
|
||||
name: string
|
||||
provider: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
interface ViewProperties {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
function viewDocumentToEntity(document: WithId<ViewDocument>): View {
|
||||
return new View({
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
type: document.type,
|
||||
name: document.name,
|
||||
provider: document.provider as ViewProvider,
|
||||
config: document.config
|
||||
})
|
||||
}
|
||||
|
||||
export class ViewsRepository {
|
||||
|
||||
private _views: Collection<ViewDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._views = db.collection("projectsViews")
|
||||
}
|
||||
|
||||
async createView(viewData: ViewProperties): Promise<View> {
|
||||
const viewDocument = await this._views.insertOne({
|
||||
...viewData,
|
||||
projectId: new ObjectId(viewData.projectId)
|
||||
});
|
||||
return viewDocumentToEntity({
|
||||
_id: viewDocument.insertedId,
|
||||
projectId: new ObjectId(viewData.projectId),
|
||||
type: viewData.type,
|
||||
name: viewData.name,
|
||||
provider: viewData.provider,
|
||||
config: viewData.config
|
||||
})
|
||||
}
|
||||
|
||||
async updateView(view: View, properties: Partial<ViewProperties>): Promise<void> {
|
||||
await this._views.updateOne({_id: new ObjectId(view.id)}, {
|
||||
$set: {
|
||||
name: properties.name,
|
||||
config: properties.config
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(project: Project, filter: Partial<ViewProperties> = {}): Promise<View[]> {
|
||||
const viewDocuments = await this._views.find({...filter, projectId: new ObjectId(project.id)}).toArray()
|
||||
return viewDocuments.map(viewDocumentToEntity)
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<View | null> {
|
||||
|
||||
const viewDocument = await this._views.findOne({_id: new ObjectId(id)})
|
||||
if (!viewDocument) {
|
||||
return null;
|
||||
}
|
||||
return viewDocumentToEntity(viewDocument);
|
||||
}
|
||||
|
||||
async deleteView(view: View): Promise<void> {
|
||||
await this._views.deleteOne({_id: new ObjectId(view.id)})
|
||||
}
|
||||
}
|
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {View, ViewProvider} from "./entities";
|
||||
import {ViewsRepository} from "./ViewsRepository";
|
||||
|
||||
interface ViewsUseCasesDependencies {
|
||||
viewsRepository: ViewsRepository
|
||||
}
|
||||
|
||||
interface ViewDataUpdate {
|
||||
name: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface ViewCreationProperties {
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export class ViewsUseCases {
|
||||
private _viewsRepository: ViewsRepository
|
||||
|
||||
constructor(dependencies: ViewsUseCasesDependencies) {
|
||||
this._viewsRepository = dependencies.viewsRepository
|
||||
}
|
||||
|
||||
async createView(context: ProjectContext, viewData: ViewCreationProperties): Promise<View> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
|
||||
const view = await this._viewsRepository.createView({
|
||||
projectId: project.id,
|
||||
name: viewData.name,
|
||||
type: viewData.type,
|
||||
provider: viewData.provider,
|
||||
config: viewData.config
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
async updateView(context: ProjectContext, view: View, data: ViewDataUpdate): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._viewsRepository.updateView(view, data)
|
||||
}
|
||||
|
||||
async getAllViews(context: ProjectContext): Promise<View[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._viewsRepository.findAll(context.project);
|
||||
}
|
||||
|
||||
async getViewsByType(context: ProjectContext, type: string): Promise<View[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._viewsRepository.findAll(context.project, {type});
|
||||
}
|
||||
}
|
53
server/src/core/features/views/entities/View.ts
Normal file
53
server/src/core/features/views/entities/View.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
export enum ViewProvider {
|
||||
Web = "Web",
|
||||
Custom = "Custom"
|
||||
}
|
||||
|
||||
export interface ViewCreationProperties {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface ViewConstructor extends Identifiable {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface WebViewProvider {
|
||||
panels: {
|
||||
name: string
|
||||
order: number
|
||||
metrics: {
|
||||
metricId: string
|
||||
order: number
|
||||
config: unknown
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export class View implements ViewConstructor {
|
||||
|
||||
readonly id: string
|
||||
readonly projectId: string
|
||||
readonly name: string
|
||||
readonly type: string
|
||||
readonly provider: ViewProvider
|
||||
readonly config: unknown
|
||||
|
||||
constructor(data: ViewConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.type = data.type
|
||||
this.name = data.name
|
||||
this.provider = data.provider
|
||||
this.config = data.config
|
||||
}
|
||||
}
|
1
server/src/core/features/views/entities/index.ts
Normal file
1
server/src/core/features/views/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./View"
|
28
server/src/core/features/views/index.ts
Normal file
28
server/src/core/features/views/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ViewsRepository} from "./ViewsRepository";
|
||||
import {ViewsUseCases} from "./ViewsUseCases";
|
||||
|
||||
export * from "./entities"
|
||||
|
||||
export type ViewsFeatureDependencies = {
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ViewsFeature extends Feature {
|
||||
|
||||
name = "Views"
|
||||
dependencies:ViewsFeatureDependencies
|
||||
|
||||
repository: ViewsRepository
|
||||
useCases: ViewsUseCases
|
||||
|
||||
constructor(services: AppServices, dependencies: ViewsFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.repository = new ViewsRepository(services.db)
|
||||
this.useCases = new ViewsUseCases({
|
||||
viewsRepository: this.repository
|
||||
})
|
||||
}
|
||||
}
|
1
server/src/core/index.ts
Normal file
1
server/src/core/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./App"
|
143
server/src/http/HttpServer.ts
Normal file
143
server/src/http/HttpServer.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import fastifyCors from "@fastify/cors";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import {TypeBoxTypeProvider} from "@fastify/type-provider-typebox";
|
||||
import {App} from "@app/core";
|
||||
import {Caller} from "@app/core/features/projects/entities/Caller";
|
||||
import {Project} from "@app/core/features/projects/entities/Project";
|
||||
import {MissingAuthorization} from "@app/core/features/projects/exceptions/MissingAuthorization";
|
||||
import {MissingResource} from "@app/core/features/projects/exceptions/MissingResource";
|
||||
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||
import {SignupFailed, SignupFailedCode} from "@app/core/features/users/exceptions/SignupFailed";
|
||||
import fastify, {FastifyInstance, FastifyLoggerInstance, InjectOptions, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault} from "fastify";
|
||||
import {Logger} from "utils/logger";
|
||||
import {projectsRoutes, usersRoutes, logsRoutes, metricsRoutes} from "./routes";
|
||||
import {viewsRoutes} from "./routes/views.routes";
|
||||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {adminRoutes} from "./routes/admin.routes";
|
||||
|
||||
export type FastifyTypebox = FastifyInstance<
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression<RawServerDefault>,
|
||||
RawReplyDefaultExpression<RawServerDefault>,
|
||||
FastifyLoggerInstance,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { email: string } // payload type is used for signing and verifying
|
||||
user: { email: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
data: { // [TODO] This file shouldn't know about these classes
|
||||
user: User
|
||||
caller: Caller
|
||||
project: Project
|
||||
context: ProjectContext
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpServer {
|
||||
|
||||
server: FastifyTypebox
|
||||
app: App
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.server = fastify({
|
||||
ajv: {
|
||||
customOptions: {
|
||||
strict: 'log',
|
||||
keywords: ['kind', 'modifier']
|
||||
}
|
||||
}
|
||||
}).withTypeProvider<TypeBoxTypeProvider>();
|
||||
}
|
||||
|
||||
async listen() {
|
||||
this.server.listen({host: process.env.SERVER_HOST || "0.0.0.0", port: parseInt(process.env.SERVER_PORT || "8080")}, (err, address) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Server listening at ${address}`);
|
||||
});
|
||||
}
|
||||
|
||||
async inject(data: InjectOptions) {
|
||||
return this.server.inject(data);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw Error("Missing JWT_SECRET env var");
|
||||
}
|
||||
|
||||
await this.server.register(fastifyCors);
|
||||
await this.server.register(fastifyJwt, {secret: process.env.JWT_SECRET});
|
||||
|
||||
this.server.addHook("onSend", (req, reply, payload, done) => {
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
done();
|
||||
return ;
|
||||
}
|
||||
|
||||
if (reply.statusCode >= 200 && reply.statusCode < 300)
|
||||
Logger.success(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
else if (reply.statusCode >= 500)
|
||||
Logger.error(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
else
|
||||
Logger.warn(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
done();
|
||||
});
|
||||
|
||||
this.server.setErrorHandler((err, req, reply) => {
|
||||
let statusCode
|
||||
if (err instanceof AuthenticationFailed) {
|
||||
statusCode = 401
|
||||
} else if (err instanceof SignupFailed) {
|
||||
statusCode = 500
|
||||
if (err.errorCode === SignupFailedCode.EmailAlreadyInUse) {
|
||||
statusCode = 409
|
||||
} else if (err.errorCode === SignupFailedCode.NotEnabled) {
|
||||
statusCode = 401
|
||||
}
|
||||
} else if (err instanceof MissingAuthorization) {
|
||||
statusCode = 401
|
||||
} else if (err instanceof MissingResource) {
|
||||
statusCode = 404;
|
||||
} else if (err instanceof LimitReached) {
|
||||
statusCode = 422;
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
reply.status(statusCode).send({error: err.message})
|
||||
} else {
|
||||
reply.send(err)
|
||||
}
|
||||
})
|
||||
|
||||
this.server.addHook("onError", (req, reply, error, done) => {
|
||||
if (!error.validation) {
|
||||
Logger.error(error);
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
this.server.register(usersRoutes(this.app));
|
||||
this.server.register(projectsRoutes(this.app));
|
||||
this.server.register(logsRoutes(this.app));
|
||||
this.server.register(metricsRoutes(this.app));
|
||||
this.server.register(viewsRoutes(this.app));
|
||||
this.server.register(adminRoutes(this.app));
|
||||
|
||||
return this.server;
|
||||
}
|
||||
}
|
1
server/src/http/index.ts
Normal file
1
server/src/http/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./HttpServer"
|
20
server/src/http/routes/admin.routes.ts
Normal file
20
server/src/http/routes/admin.routes.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
|
||||
export function adminRoutes(app: App) {
|
||||
const adminUseCases = app.serverAdminFeature.useCases
|
||||
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/admin/server-stats",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
return adminUseCases.getGlobalStats(caller);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
146
server/src/http/routes/admin.test.ts
Normal file
146
server/src/http/routes/admin.test.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import {ADMIN_TOKEN, getAdminSucessTagId} from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Admin", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1Id: string
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
users = await setupNewUsers(server.server);
|
||||
users[2].SetAuthorization(ADMIN_TOKEN);
|
||||
});
|
||||
|
||||
it ("Return error 401 if not using the ADMIN_TOKEN", async function() {
|
||||
const resGet = await users[1].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Return 0 projects, 3 users, 0 logs, 0 metrics on initial state", async function() {
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 0,
|
||||
logCount: 0,
|
||||
metricCount: 0,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
describe("Server stats", function() {
|
||||
|
||||
beforeEach(async function () {
|
||||
// Create basic projects
|
||||
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"}); // Yep, don't need all projects
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
project1Id = resAddProject1.body.id;
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 3 logs, 0 metrics", async function() {
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 3,
|
||||
metricCount: 0,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 4 logs, 1 metrics", async function() {
|
||||
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 1"
|
||||
})
|
||||
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
expect(resGet.statusCode).to.deep.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 4,
|
||||
metricCount: 1,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 5 logs, 2 metrics, 3 metric history entries", async function() {
|
||||
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 1"
|
||||
})
|
||||
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 2"
|
||||
})
|
||||
|
||||
const metricId = resAddMetric.body.id;
|
||||
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 1});
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 2});
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 3});
|
||||
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
expect(resGet.statusCode).to.deep.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 5,
|
||||
metricCount: 2,
|
||||
metricHistoryEntryCount: 3
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
describe("Internal log to admin project", function() {
|
||||
|
||||
beforeEach(async function () {
|
||||
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||
project1Id = resAddProject1.body.id;
|
||||
const resAddTag = await users[0].Post(`/projects/${project1Id}/tags`, {
|
||||
name: "Success",
|
||||
color: "#00ff00"
|
||||
});
|
||||
process.env.ADMIN_PROJECT_ID = project1Id
|
||||
process.env.ADMIN_SUCCESS_TAG_ID = resAddTag.body.id
|
||||
});
|
||||
|
||||
it("Add log when an user is created", async function() {
|
||||
const resGetLogs1 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||
expect(resGetLogs1.statusCode).to.equals(200);
|
||||
expect(resGetLogs1.body).to.have.length(1);
|
||||
|
||||
const userEmail = "user.email@something.happy"
|
||||
const resAddUser = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: userEmail,
|
||||
password: "SomePassword",
|
||||
}
|
||||
});
|
||||
expect(resAddUser.statusCode).to.equals(201);
|
||||
|
||||
const resGetLogs2 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||
expect(resGetLogs2.statusCode).to.equals(200);
|
||||
expect(resGetLogs2.body).to.have.length(2);
|
||||
expect(resGetLogs2.body[0].title).to.equals(`New user: ${userEmail}`);
|
||||
expect(resGetLogs2.body[0].tags).to.have.length(1);
|
||||
expect(resGetLogs2.body[0].tags[0].id).to.equals(getAdminSucessTagId());
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
delete process.env.ADMIN_PROJECT_ID
|
||||
delete process.env.ADMIN_SUCCESS_TAG_ID
|
||||
})
|
||||
})
|
||||
});
|
4
server/src/http/routes/index.ts
Normal file
4
server/src/http/routes/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./users.routes";
|
||||
export * from "./projects.routes";
|
||||
export * from "./logs.routes";
|
||||
export * from "./metrics.routes";
|
36
server/src/http/routes/logs.def.ts
Normal file
36
server/src/http/routes/logs.def.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateLogBody = Type.Object({
|
||||
title: Type.String(),
|
||||
content: Type.String(),
|
||||
tags: Type.Array(Type.String({minLength: 24, maxLength: 24}), {maxItems: 2})
|
||||
});
|
||||
|
||||
export const CreateLogParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteLogParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
logId: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateTagBody = Type.Object({
|
||||
name: Type.String(),
|
||||
color: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateTagBody = Type.Object({
|
||||
name: Type.String(),
|
||||
color: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateTagParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
tagId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteTagParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
tagId: Type.String(),
|
||||
});
|
121
server/src/http/routes/logs.routes.ts
Normal file
121
server/src/http/routes/logs.routes.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {CreateLogBody, CreateLogParams, CreateTagBody, DeleteLogParams, DeleteTagParams, UpdateTagBody, UpdateTagParams} from "./logs.def";
|
||||
import {LogsUseCases} from "@app/core/features/logs/LogsUseCases";
|
||||
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
|
||||
export type MetricRoutesDependencies = {
|
||||
logService: LogsUseCases,
|
||||
projectService: ProjectsUseCases,
|
||||
userService: UsersUseCases
|
||||
}
|
||||
|
||||
export function logsRoutes(app: App) {
|
||||
const logUseCases = app.logFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/projects/:projectId/logs",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const logs = await logUseCases.getLogs(context);
|
||||
const tags = await logUseCases.getTags(context);
|
||||
const formattedLogs = logs.map(log => ({
|
||||
...log,
|
||||
tags: log.tags.map(logTag => tags.find(tag => tag.id === logTag)).filter(t => t)
|
||||
}))
|
||||
reply.status(200).send(formattedLogs);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateLogBody>, Params: Static<typeof CreateLogParams>}>(
|
||||
"/projects/:projectId/logs",
|
||||
{
|
||||
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||
schema: {body: CreateLogBody, params: CreateLogParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {title, content, tags} = request.body;
|
||||
await logUseCases.addLog(context, {title, content, tags})
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteLogParams>}>(
|
||||
"/projects/:projectId/logs/:logId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteLogParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {logId} = request.params
|
||||
const log = await logUseCases.getLogById(context, logId)
|
||||
await logUseCases.deleteLog(context, log);
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects/:projectId/tags",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const tags = await logUseCases.getTags(context);
|
||||
reply.status(200).send(tags);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateTagBody>}>(
|
||||
"/projects/:projectId/tags",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateTagBody}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name, color} = request.body;
|
||||
const tag = await logUseCases.createTag(context, {name, color})
|
||||
reply.status(201).send({id: tag.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateTagBody>, Params: Static<typeof UpdateTagParams>}>(
|
||||
"/projects/:projectId/tags/:tagId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateTagBody, params: UpdateTagParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name, color} = request.body;
|
||||
const {tagId} = request.params;
|
||||
const tag = await logUseCases.getTag(context, tagId);
|
||||
await logUseCases.updateTag(context, tag, {name, color})
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteTagParams>}>(
|
||||
"/projects/:projectId/tags/:tagId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteTagParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {tagId} = request.params
|
||||
const tag = await logUseCases.getTag(context, tagId)
|
||||
await logUseCases.deleteTag(context, tag);
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
129
server/src/http/routes/logs.test.ts
Normal file
129
server/src/http/routes/logs.test.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Logs", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1: Record<string, unknown>
|
||||
let project1ApiKey1: TestApiKey
|
||||
|
||||
let project2: Record<string, unknown>
|
||||
let project2ApiKey1: TestApiKey
|
||||
|
||||
beforeEach(async function () {
|
||||
// Setup the server and app
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
// Create basic users
|
||||
users = await setupNewUsers(server.server);
|
||||
// Create basic projects
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
project1 = response.body[0];
|
||||
project2 = response.body[1];
|
||||
|
||||
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
expect(response.body[0].name).to.equals("key1")
|
||||
expect(response.body[0].projectId).to.equals(project1.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
expect(response.body[1].name).to.equals("key2")
|
||||
expect(response.body[1].projectId).to.equals(project1.id)
|
||||
expect(response.body[1].key).to.not.be.undefined
|
||||
|
||||
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
new TestApiKey(server.server, response.body[1].key)
|
||||
|
||||
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
expect(response.body[0].name).to.equals("key3")
|
||||
expect(response.body[0].projectId).to.equals(project2.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
|
||||
project2ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
});
|
||||
|
||||
it ("[User 1] Get logs with just the welcome log", async function() {
|
||||
const response = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Get logs with just the welcome log", async function() {
|
||||
const response = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Add log to p1", async function() {
|
||||
const resAddLog = await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||
title: "title1",
|
||||
content: "content1",
|
||||
tags: []
|
||||
});
|
||||
expect(resAddLog.statusCode).to.equals(201);
|
||||
|
||||
const resGetLogs = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||
expect(resGetLogs.statusCode).to.equals(200);
|
||||
expect(resGetLogs.body).to.be.a("array");
|
||||
expect(resGetLogs.body).to.have.length(2);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Get logs with 2 entries", async function() {
|
||||
await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||
title: "title1",
|
||||
content: "content1",
|
||||
tags: []
|
||||
});
|
||||
|
||||
const resGetLogs = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
expect(resGetLogs.statusCode).to.equals(200);
|
||||
expect(resGetLogs.body).to.be.a("array");
|
||||
expect(resGetLogs.body).to.have.length(2);
|
||||
});
|
||||
|
||||
it ("[K2-1 > P1] Return error (401)", async function() {
|
||||
const response = await project2ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("[K2-1 > P2] Get logs with just the welcome log", async function() {
|
||||
const response = await project2ApiKey1.Get(`/projects/${project2.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
});
|
34
server/src/http/routes/metrics.def.ts
Normal file
34
server/src/http/routes/metrics.def.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateMetricBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateMetricParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateMetricBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateMetricParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String(),
|
||||
});
|
||||
|
||||
export const SetMetricValueBody = Type.Object({
|
||||
value: Type.Number(),
|
||||
date: Type.Optional(Type.Number())
|
||||
});
|
||||
|
||||
export const SetMetricValueParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String()
|
||||
});
|
||||
|
||||
export const DeleteHistoryRecordParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String(),
|
||||
recordId: Type.String()
|
||||
});
|
124
server/src/http/routes/metrics.routes.ts
Normal file
124
server/src/http/routes/metrics.routes.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {App} from "@app/core";
|
||||
import {SetMetricValueBody, SetMetricValueParams, CreateMetricBody, CreateMetricParams, UpdateMetricBody, UpdateMetricParams, DeleteHistoryRecordParams} from "./metrics.def";
|
||||
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
import {MetricsUseCases} from "@app/core/features/metrics";
|
||||
|
||||
export type MetricRouteDependencies = {
|
||||
metricService: MetricsUseCases,
|
||||
projectService: ProjectsUseCases,
|
||||
userService: UsersUseCases
|
||||
}
|
||||
|
||||
export function metricsRoutes(app: App) {
|
||||
const metricsUseCases = app.metricFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/projects/:projectId/metrics",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const metrics = await metricsUseCases.getAllMetrics(context, true);
|
||||
reply.status(200).send(metrics);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateMetricBody>, Params: Static<typeof CreateMetricParams>}>(
|
||||
"/projects/:projectId/metrics",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateMetricBody, params: CreateMetricParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name} = request.body;
|
||||
const metric = await metricsUseCases.createMetric(context, {name})
|
||||
reply.status(201).send({id: metric.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateMetricBody>, Params: Static<typeof UpdateMetricParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateMetricBody, params: UpdateMetricParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const {name} = request.body;
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
if (!metric) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
await metricsUseCases.updateMetric(context, metric, {name});
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.get<{Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId, true)
|
||||
reply.status(200).send(metric);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof SetMetricValueBody>, Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||
schema: {body: SetMetricValueBody, params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const {value, date} = request.body;
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.setMetricValue(context, metric, value, date);
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.deleteMetric(context, metric)
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteHistoryRecordParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId/history/:recordId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteHistoryRecordParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId, recordId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.deleteMetricHistoryRecord(context, metric, recordId);
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
166
server/src/http/routes/metrics.test.ts
Normal file
166
server/src/http/routes/metrics.test.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import {sleep} from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Metrics", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1: Record<string, unknown>
|
||||
let project1ApiKey1: TestApiKey
|
||||
|
||||
let project2: Record<string, unknown>
|
||||
|
||||
beforeEach(async function () {
|
||||
// Setup the server and app
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
// Create basic users
|
||||
users = await setupNewUsers(server.server);
|
||||
// Create basic projects
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
project1 = response.body[0];
|
||||
project2 = response.body[1];
|
||||
|
||||
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
expect(response.body[0].name).to.equals("key1")
|
||||
expect(response.body[0].projectId).to.equals(project1.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
expect(response.body[1].name).to.equals("key2")
|
||||
expect(response.body[1].projectId).to.equals(project1.id)
|
||||
expect(response.body[1].key).to.not.be.undefined
|
||||
|
||||
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
new TestApiKey(server.server, response.body[1].key)
|
||||
|
||||
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
expect(response.body[0].name).to.equals("key3")
|
||||
expect(response.body[0].projectId).to.equals(project2.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
});
|
||||
|
||||
it ("Return empty metric list", async function() {
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(0);
|
||||
});
|
||||
|
||||
it ("Return error 400 on bad payload", async function() {
|
||||
let resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {});
|
||||
|
||||
expect(resGet.statusCode).to.equals(400);
|
||||
|
||||
resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
type: "numeric"
|
||||
});
|
||||
|
||||
expect(resGet.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it ("Add a metric", async function() {
|
||||
const resAdd = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
expect(resAdd.statusCode).to.equals(201);
|
||||
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("Get metrics with [Api Key 1]", async function() {
|
||||
await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const response = await project1ApiKey1.Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("Set value of a metric as [Api Key 1]", async function() {
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const metricId = resAddMetric.body.id
|
||||
const resSetValue = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 42
|
||||
});
|
||||
expect(resSetValue.statusCode).to.equals(201);
|
||||
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(1);
|
||||
expect(resGet.body[0].history).to.have.length(1);
|
||||
expect(resGet.body[0].history[0].value).to.equals(42);
|
||||
});
|
||||
|
||||
it ("Simple rate limit test for [Api Key 1]", async function() {
|
||||
process.env.RATE_LIMIT_ENABLED = 'true'
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const metricId = resAddMetric.body.id
|
||||
const response1 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 42
|
||||
});
|
||||
|
||||
expect(response1.statusCode).to.equals(201);
|
||||
|
||||
const response2 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 43
|
||||
});
|
||||
|
||||
expect(response2.statusCode).to.equals(429);
|
||||
|
||||
await sleep(1100)
|
||||
|
||||
const response3 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 44
|
||||
});
|
||||
|
||||
expect(response3.statusCode).to.equals(201);
|
||||
|
||||
process.env.RATE_LIMIT_ENABLED = 'false'
|
||||
});
|
||||
});
|
18
server/src/http/routes/projects.def.ts
Normal file
18
server/src/http/routes/projects.def.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateProjectBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateApiKeyBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateApiKeyParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteApiKeyParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
apiKeyId: Type.String(),
|
||||
});
|
90
server/src/http/routes/projects.routes.ts
Normal file
90
server/src/http/routes/projects.routes.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {CreateApiKeyBody, CreateApiKeyParams, CreateProjectBody, DeleteApiKeyParams} from "./projects.def";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
import {ProjectAuthorization} from "@app/core/features/projects/ProjectContext";
|
||||
|
||||
export function projectsRoutes(app: App) {
|
||||
const projectService = app.projectFeature.service
|
||||
|
||||
return async (server: FastifyInstance) => {
|
||||
server.post<{Body: Static<typeof CreateProjectBody>}>("/projects",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])],
|
||||
schema: {body: CreateProjectBody}
|
||||
},
|
||||
async (request, reply) => {
|
||||
const {name} = request.body;
|
||||
const {caller} = request.data;
|
||||
|
||||
const project = await projectService.createProject(caller.asUser(), name);
|
||||
|
||||
reply.status(201).send({id: project.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
const projects = await projectService.getProjectsOfUser(caller.asUser());
|
||||
return projects.map(project => project.getState())
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects/:projectId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request) {
|
||||
const {context} = request.data;
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return context.project.getState();
|
||||
});
|
||||
|
||||
|
||||
|
||||
server.get("/projects/:projectId/apiKeys",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request) {
|
||||
const {context} = request.data;
|
||||
const apiKeys = projectService.getApiKeysOfProject(context);
|
||||
return apiKeys
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateApiKeyBody>, Params: Static<typeof CreateApiKeyParams>}>(
|
||||
"/projects/:projectId/apiKeys",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateApiKeyBody, params: CreateApiKeyParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name} = request.body;
|
||||
const apiKey = await projectService.generateApiKey(context, name);
|
||||
reply.status(201).send({apiKey});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteApiKeyParams>}>(
|
||||
"/projects/:projectId/apiKeys/:apiKeyId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteApiKeyParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {apiKeyId} = request.params;
|
||||
const apiKey = await projectService.getApiKeyById(context, apiKeyId);
|
||||
await projectService.deleteApiKeys(context, apiKey);
|
||||
reply.status(200).send({apiKey});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
118
server/src/http/routes/projects.test.ts
Normal file
118
server/src/http/routes/projects.test.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Projects", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
users = await setupNewUsers(server.server);
|
||||
});
|
||||
|
||||
it ("Return empty project list", async function() {
|
||||
const responseUser1 = await users[0].Get("/projects");
|
||||
expect(responseUser1.statusCode).to.equals(200);
|
||||
expect(responseUser1.body).to.be.a("array");
|
||||
expect(responseUser1.body).to.have.length(0)
|
||||
|
||||
const responseUser2 = await users[1].Get("/projects");
|
||||
expect(responseUser2.statusCode).to.equals(200);
|
||||
expect(responseUser2.body).to.be.a("array");
|
||||
expect(responseUser2.body).to.have.length(0)
|
||||
});
|
||||
|
||||
it ("Add 1 project", async function() {
|
||||
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||
expect(addRes.statusCode).to.equals(201);
|
||||
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(200);
|
||||
expect(getRes.body).to.be.a("array");
|
||||
expect(getRes.body).to.have.length(1)
|
||||
});
|
||||
|
||||
it ("Add 2 projects", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||
expect(addRes1.statusCode).to.equals(201);
|
||||
expect(addRes2.statusCode).to.equals(201);
|
||||
|
||||
const getUser1Res = await users[0].Get("/projects");
|
||||
expect(getUser1Res.statusCode).to.equals(200);
|
||||
expect(getUser1Res.body).to.be.a("array");
|
||||
expect(getUser1Res.body).to.have.length(2)
|
||||
|
||||
// I just want to be sure the 2nd user cannot get them
|
||||
const getUser2Res = await users[1].Get("/projects");
|
||||
expect(getUser2Res.statusCode).to.equals(200);
|
||||
expect(getUser2Res.body).to.be.a("array");
|
||||
expect(getUser2Res.body).to.have.length(0)
|
||||
});
|
||||
|
||||
it ("Check projects properties", async function() {
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(200);
|
||||
expect(getRes.body).to.be.a("array");
|
||||
expect(getRes.body).to.have.length(2)
|
||||
expect(getRes.body[0].name).to.equals("p1")
|
||||
expect(getRes.body[1].name).to.equals("p2")
|
||||
});
|
||||
|
||||
it ("Fail to add project (no valid authorization token)", async function() {
|
||||
await users[0].Logout();
|
||||
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||
expect(addRes.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Fail to get projects (no valid authorization token)", async function() {
|
||||
await users[0].Logout();
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Projects have no Api key when created", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||
|
||||
const project1Id = addRes1.body.id;
|
||||
const project2Id = addRes2.body.id;
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
response = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(0);
|
||||
|
||||
response = await users[0].Get(`/projects/${project2Id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(0);
|
||||
});
|
||||
|
||||
it ("Add Api key to project", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const project1Id = addRes1.body.id;
|
||||
|
||||
const response = await users[0].Post(`/projects/${project1Id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
const resGetKeys = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||
expect(resGetKeys.statusCode).to.equals(200);
|
||||
expect(resGetKeys.body).to.be.a("array");
|
||||
expect(resGetKeys.body).to.have.length(1);
|
||||
expect(resGetKeys.body[0].name).to.equals("key1");
|
||||
});
|
||||
});
|
26
server/src/http/routes/users.def.ts
Normal file
26
server/src/http/routes/users.def.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const InfoBody = Type.Object({
|
||||
adminToken: Type.String()
|
||||
});
|
||||
|
||||
export const SignupBody = Type.Object({
|
||||
email: Type.String({format: "email"}),
|
||||
password: Type.String({minLength: 6}),
|
||||
adminToken: Type.Optional(Type.String())
|
||||
});
|
||||
|
||||
export const LoginBody= Type.Object({
|
||||
email: Type.String(),
|
||||
password: Type.String(),
|
||||
});
|
||||
|
||||
export const VerifyEmailQuerystring= Type.Object({
|
||||
email: Type.String(),
|
||||
emailConfirmationToken: Type.String(),
|
||||
});
|
||||
|
||||
export const AdminChangeUserPasswordBody = Type.Object({
|
||||
email: Type.String(),
|
||||
newPassword: Type.String()
|
||||
});
|
87
server/src/http/routes/users.routes.ts
Normal file
87
server/src/http/routes/users.routes.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {AdminChangeUserPasswordBody, InfoBody, LoginBody, SignupBody} from "./users.def";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
import {isRegistrationEnabled} from "@app/core/features/users/utils";
|
||||
|
||||
export function usersRoutes(app: App) {
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
const usersUseCases = app.userFeature.useCases
|
||||
|
||||
server.post<{Body: Static<typeof SignupBody>}>("/signup",
|
||||
{
|
||||
schema: {body: SignupBody}
|
||||
},
|
||||
async (request, reply) => {
|
||||
const {email, password, adminToken} = request.body;
|
||||
await usersUseCases.signupUser(email, password, adminToken)
|
||||
reply.status(201).send({success: true});
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof LoginBody>}>("/login",
|
||||
{
|
||||
schema: {body: LoginBody}
|
||||
},
|
||||
async (request) => {
|
||||
const {email, password} = request.body;
|
||||
const user = await usersUseCases.logUser(email, password)
|
||||
|
||||
const token = server.jwt.sign({email});
|
||||
|
||||
return {...user.getState(), password: undefined, token};
|
||||
}
|
||||
);
|
||||
|
||||
/*server.get<{Querystring: Static<typeof VerifyEmailQuerystring>}>*/
|
||||
/*("/signup-email-confirmation", {schema: {querystring: VerifyEmailQuerystring}}, async function (request, reply) {*/
|
||||
/*[>const {email, emailConfirmationToken} = request.query;<]*/
|
||||
/*[>const user = await app.userService.getUser({email});<]*/
|
||||
/*[>if (!user || !await user.verifyEmail(email, emailConfirmationToken)) {<]*/
|
||||
/*[>reply.status(401);<]*/
|
||||
/*[>return {};<]*/
|
||||
/*[>}<]*/
|
||||
/*reply.send({success: true});*/
|
||||
/*});*/
|
||||
|
||||
server.post<{Body: Static<typeof InfoBody>}>("/info",
|
||||
{
|
||||
schema: {body: InfoBody},
|
||||
preValidation: [rateLimit(1, 1000)],
|
||||
},
|
||||
async (request) => {
|
||||
const {adminToken} = request.body;
|
||||
return usersUseCases.getGlobalInfo(adminToken)
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof AdminChangeUserPasswordBody>}>("/admin/change-user-password",
|
||||
{
|
||||
schema: {body: AdminChangeUserPasswordBody},
|
||||
preValidation: [withData(app, [AppData.Caller]), rateLimit(1, 60000)],
|
||||
},
|
||||
async (request) => {
|
||||
const {email, newPassword} = request.body;
|
||||
const {caller} = request.data
|
||||
return usersUseCases.adminChangeUserPassword(caller, email, newPassword)
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/auth",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
return {...caller.asUser().getState(), password: undefined};
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/isUserRegistrationEnabled", async function () {
|
||||
return {enabled: isRegistrationEnabled()};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
294
server/src/http/routes/users.test.ts
Normal file
294
server/src/http/routes/users.test.ts
Normal file
|
@ -0,0 +1,294 @@
|
|||
import { ADMIN_TOKEN } from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {LightMyRequestResponse} from "fastify";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase} from "testUtils/apiTestHelper";
|
||||
|
||||
const users = [
|
||||
{
|
||||
email: "user1@test.com",
|
||||
password: "user1_password"
|
||||
},
|
||||
{
|
||||
email: "user2@test.com",
|
||||
password: "user2_password"
|
||||
},
|
||||
{
|
||||
email: "user3@test.com",
|
||||
password: "user3_password"
|
||||
}
|
||||
];
|
||||
|
||||
type TestRequestBody = {
|
||||
method: string,
|
||||
url: string,
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function TestRequiredBody(server: HttpServer, OriginalRequest: TestRequestBody) {
|
||||
const payloadKeys = Object.keys(OriginalRequest.payload);
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
|
||||
const request = JSON.parse(JSON.stringify(OriginalRequest));
|
||||
delete request.payload[key];
|
||||
const response = await server.inject(request);
|
||||
expect(response.statusCode).to.equals(400);
|
||||
}
|
||||
}
|
||||
|
||||
let server: HttpServer;
|
||||
|
||||
async function logReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||
return server.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function signupReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||
return server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("Users", function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
});
|
||||
|
||||
describe("Signup", function() {
|
||||
it ("User registration is enabled", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/isUserRegistrationEnabled",
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.json().enabled).to.equals(true)
|
||||
});
|
||||
|
||||
it ("Fail get current user (401) because no Bearer token is provided", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email has wrong format", async function () {
|
||||
const res = await signupReq("wrong", users[1].password)
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email is an empty string", async function () {
|
||||
const res = await signupReq("", users[1].password)
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because password is an empty string", async function () {
|
||||
const res = await signupReq(users[1].email, "")
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email is already taken", async function () {
|
||||
const resSignup1 = await signupReq(users[0].email, users[0].password)
|
||||
expect(resSignup1.statusCode).to.equals(201);
|
||||
|
||||
const resSignup2 = await signupReq(users[0].email, users[1].password)
|
||||
expect(resSignup2.statusCode).to.equals(409);
|
||||
});
|
||||
|
||||
it("Succeed to sign up a new user", async function () {
|
||||
const res = await signupReq(users[0].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(201);
|
||||
});
|
||||
})
|
||||
|
||||
/*it("Should fail to log in with the new user because email is not verified", async function () {*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "POST",*/
|
||||
/*url: "/login",*/
|
||||
/*payload: {*/
|
||||
/*email: users[0].email,*/
|
||||
/*password: users[0].password,*/
|
||||
/*}*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(403);*/
|
||||
/*});*/
|
||||
|
||||
/*it("Fail to verify the email (wrong token)", async function () {*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=this_is_a_random_string`,*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(401);*/
|
||||
/*});*/
|
||||
|
||||
/*it("Verify the email", async function () {*/
|
||||
|
||||
/*const user = await server.app.managers.userManager.FetchUserByEmail(users[0].email);*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=${user?.GetDocument().emailConfirmationToken}`,*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(200);*/
|
||||
/*});*/
|
||||
|
||||
describe("Login", function() {
|
||||
beforeEach(async function() {
|
||||
const response = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
})
|
||||
|
||||
it ("Fail (400) when a required property is missing", async function() {
|
||||
await TestRequiredBody(server, {
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Fail to log in with wrong credentials", async function () {
|
||||
const res = await logReq("abc", "def")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Fail to get current authenticated user because of wrong jwt token", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
headers: {
|
||||
["Authorization"]: "Bearer abcd"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with password of another user", async function () {
|
||||
const res = await logReq(users[1].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with a total random password", async function () {
|
||||
const res = await logReq(users[1].email, "qwertyasdfgzxcvb")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with an unknown email but existing password", async function () {
|
||||
const res = await logReq("this_email@doesnt.exist", users[0].password)
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with an unknown email and unknown password", async function () {
|
||||
const res = await logReq("this_email@doesnt.exist", "this_password_doesnt_exist")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Succeed to log in", async function () {
|
||||
const res = await logReq(users[0].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(200);
|
||||
expect(res.json().token).to.not.be.undefined;
|
||||
expect(res.json().password).to.be.undefined;
|
||||
});
|
||||
|
||||
it ("Succeed to get current authenticated user", async function() {
|
||||
const resLogin = await logReq(users[0].email, users[0].password)
|
||||
expect(resLogin.statusCode).to.equals(200);
|
||||
|
||||
const token = resLogin.json().token
|
||||
|
||||
const resAuth = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
expect(resAuth.statusCode).to.equals(200);
|
||||
expect(resAuth.json().password).to.be.undefined;
|
||||
});
|
||||
})
|
||||
|
||||
describe("Password reset", function() {
|
||||
beforeEach(async function() {
|
||||
const response = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
})
|
||||
|
||||
it ("Admin succeed to force change an user password", async function() {
|
||||
const newPassword = "my new password"
|
||||
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resWrongPassword.statusCode).to.equals(401);
|
||||
const resChangePassword = await server.inject({
|
||||
method: "post",
|
||||
url: "/admin/change-user-password",
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${ADMIN_TOKEN}`
|
||||
},
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
newPassword: newPassword
|
||||
}
|
||||
});
|
||||
expect(resChangePassword.statusCode).to.equals(200);
|
||||
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resGoodPassword.statusCode).to.equals(200);
|
||||
});
|
||||
|
||||
it ("Non Admin fail to force change an user password", async function() {
|
||||
const newPassword = "my new password"
|
||||
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resWrongPassword.statusCode).to.equals(401);
|
||||
const resChangePassword = await server.inject({
|
||||
method: "post",
|
||||
url: "/admin/change-user-password",
|
||||
headers: {
|
||||
["Authorization"]: "Something random"
|
||||
},
|
||||
payload: {
|
||||
password: newPassword
|
||||
}
|
||||
});
|
||||
expect(resChangePassword.statusCode).to.equals(401);
|
||||
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resGoodPassword.statusCode).to.equals(401);
|
||||
});
|
||||
})
|
||||
});
|
29
server/src/http/routes/views.def.ts
Normal file
29
server/src/http/routes/views.def.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import {ViewProvider} from "@app/core/features/views";
|
||||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateViewBody = Type.Object({
|
||||
type: Type.String(),
|
||||
name: Type.String(),
|
||||
provider: Type.Enum(ViewProvider),
|
||||
config: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateViewParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateViewBody = Type.Object({
|
||||
name: Type.String(),
|
||||
config: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateViewParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
viewId: Type.String(),
|
||||
});
|
||||
|
||||
|
||||
export const GetViewsQuerystring = Type.Object({
|
||||
type: Type.Optional(Type.String()),
|
||||
name: Type.Optional(Type.String()),
|
||||
});
|
60
server/src/http/routes/views.routes.ts
Normal file
60
server/src/http/routes/views.routes.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
import {CreateViewBody, CreateViewParams, GetViewsQuerystring, UpdateViewBody, UpdateViewParams} from "./views.def";
|
||||
|
||||
export function viewsRoutes(app: App) {
|
||||
const viewsUseCases = app.viewsFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get<{Querystring: Static<typeof GetViewsQuerystring>}>("/projects/:projectId/views",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {querystring: GetViewsQuerystring}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {type} = request.query
|
||||
let views = []
|
||||
if (type) {
|
||||
views = await viewsUseCases.getViewsByType(context, type)
|
||||
} else {
|
||||
views = await viewsUseCases.getAllViews(context)
|
||||
}
|
||||
reply.status(200).send(views);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateViewBody>, Params: Static<typeof CreateViewParams>}>(
|
||||
"/projects/:projectId/views",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateViewBody, params: CreateViewParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
await viewsUseCases.createView(context, request.body)
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateViewBody>, Params: Static<typeof UpdateViewParams>}>(
|
||||
"/projects/:projectId/views/:viewId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateViewBody, params: UpdateViewParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {viewId} = request.params
|
||||
const view = await app.viewsFeature.repository.findOne(viewId)
|
||||
if (!view) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
await viewsUseCases.updateView(context, view, request.body)
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
109
server/src/http/utils.ts
Normal file
109
server/src/http/utils.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {App} from "@app/core";
|
||||
import {AdminUser, Caller} from "@app/core/features/projects/entities/Caller";
|
||||
import {Project} from "@app/core/features/projects/entities/Project";
|
||||
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||
import {User} from "@app/core/features/users/entities";
|
||||
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||
import {ADMIN_TOKEN} from "@app/utils";
|
||||
import {FastifyReply, FastifyRequest} from "fastify";
|
||||
|
||||
export function rateLimit(count: number, ms: number) {
|
||||
let rateLimitCache: { [callerId: string]: { [url: string]: number } } = {}
|
||||
setInterval(() => { rateLimitCache = {} }, ms)
|
||||
|
||||
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (process.env.RATE_LIMIT_ENABLED !== 'true') {
|
||||
return ;
|
||||
}
|
||||
let callerId = "";
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
callerId = request.user.email
|
||||
} catch (e) {
|
||||
callerId = request.headers.authorization?.split(' ')[1] || ""
|
||||
}
|
||||
if (!rateLimitCache[callerId]) {
|
||||
rateLimitCache[callerId] = {}
|
||||
}
|
||||
if (!rateLimitCache[callerId][request.url]) {
|
||||
rateLimitCache[callerId][request.url] = 0
|
||||
}
|
||||
if (rateLimitCache[callerId][request.url] >= count) {
|
||||
return reply.status(429).send({message: "Rate limit reached"});
|
||||
}
|
||||
|
||||
rateLimitCache[callerId][request.url] += 1
|
||||
}
|
||||
}
|
||||
|
||||
export enum AppData {
|
||||
Caller,
|
||||
Project,
|
||||
Context
|
||||
}
|
||||
|
||||
export function withData(app: App, required: AppData[]) {
|
||||
const projectRepository = app.projectFeature.projectRepository
|
||||
const userRepository = app.userFeature.repository
|
||||
const apiKeyRepository = app.projectFeature.apiKeyRepository
|
||||
return async (request: FastifyRequest) => {
|
||||
let user
|
||||
let caller
|
||||
let project
|
||||
let context
|
||||
|
||||
if (required.includes(AppData.Caller) || required.includes(AppData.Context)) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userData = await userRepository.findUserByEmail(request.user.email);
|
||||
if (userData) {
|
||||
user = new User(userData)
|
||||
caller = new Caller(new User(userData))
|
||||
}
|
||||
} catch (e) {
|
||||
const token = request.headers.authorization?.split(' ')[1]
|
||||
if (typeof token !== "string") {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
if (token === ADMIN_TOKEN) {
|
||||
caller = new Caller(new AdminUser())
|
||||
} else {
|
||||
const apiKey = await apiKeyRepository.findByKey(token);
|
||||
if (!apiKey) {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
caller = new Caller(apiKey)
|
||||
}
|
||||
}
|
||||
if (!caller) {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
}
|
||||
|
||||
if (required.includes(AppData.Project) || required.includes(AppData.Context)) {
|
||||
const params = request.params as Record<string, unknown>
|
||||
const projectId = params.projectId as string
|
||||
project = await projectRepository.findProjectById(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Unknown project id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (required.includes(AppData.Context)) {
|
||||
if (!caller || !project) {
|
||||
throw new Error("Missing information to create a project Context")
|
||||
}
|
||||
context = new ProjectContext(project, caller)
|
||||
}
|
||||
|
||||
request.data = {
|
||||
user: user as User,
|
||||
caller: caller as Caller,
|
||||
project: project as Project,
|
||||
context: context as ProjectContext
|
||||
}
|
||||
|
||||
return request.data
|
||||
}
|
||||
}
|
24
server/src/index.ts
Normal file
24
server/src/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {App} from "@app/core";
|
||||
import {MongoClient, MongoClientOptions} from "mongodb";
|
||||
import {HttpServer} from "./http";
|
||||
|
||||
(async () => {
|
||||
console.log(`node version: ${process.version}`)
|
||||
const {
|
||||
DB_HOST,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
DB_PASSWORD,
|
||||
DB_NAME,
|
||||
} = process.env;
|
||||
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}`;
|
||||
const mongoOptions: MongoClientOptions = {ignoreUndefined: true}
|
||||
const client = await MongoClient.connect(mongoUrl, mongoOptions);
|
||||
const db = client.db(DB_NAME)
|
||||
const app = new App(db);
|
||||
await app.start();
|
||||
|
||||
const server = new HttpServer(app);
|
||||
server.setup();
|
||||
server.listen();
|
||||
})()
|
241
server/src/testUtils/apiTestHelper.ts
Normal file
241
server/src/testUtils/apiTestHelper.ts
Normal file
|
@ -0,0 +1,241 @@
|
|||
import {expect} from "chai";
|
||||
import {App} from "@app/core";
|
||||
import {LightMyRequestResponse} from "fastify";
|
||||
import {FastifyTypebox, HttpServer} from "../http";
|
||||
import {MongoClient} from "mongodb";
|
||||
|
||||
const {
|
||||
DB_HOST,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
DB_PASSWORD,
|
||||
DB_NAME,
|
||||
} = process.env;
|
||||
|
||||
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}?authSource=admin`;
|
||||
const mongoDbName = DB_NAME as string;
|
||||
|
||||
export async function deleteDatabase(): Promise<boolean> {
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
return client.db(mongoDbName).dropDatabase();
|
||||
}
|
||||
|
||||
const usersCredientials = [
|
||||
{
|
||||
email: "user1@test.com",
|
||||
password: "user1_password"
|
||||
},
|
||||
{
|
||||
email: "user2@test.com",
|
||||
password: "user2_password"
|
||||
},
|
||||
{
|
||||
email: "user3@test.com",
|
||||
password: "user3_password"
|
||||
}
|
||||
];
|
||||
|
||||
export async function buildTestServer(): Promise<HttpServer> {
|
||||
|
||||
await deleteDatabase();
|
||||
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
const db = client.db(DB_NAME)
|
||||
const app = new App(db);
|
||||
await app.start();
|
||||
|
||||
const server = new HttpServer(app);
|
||||
server.setup();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export interface ResponseHelper {
|
||||
response: LightMyRequestResponse,
|
||||
/* eslint-disable-next-line */
|
||||
body: any,
|
||||
statusCode: number
|
||||
}
|
||||
|
||||
export class TestApiKey {
|
||||
private _server: FastifyTypebox;
|
||||
private _authorization = "";
|
||||
|
||||
public get authorization() {return this._authorization;}
|
||||
|
||||
constructor(server: FastifyTypebox, apiKey: string) {
|
||||
this._server = server;
|
||||
this._authorization = apiKey;
|
||||
}
|
||||
|
||||
async Get(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "PUT",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Delete(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "DELETE",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
}
|
||||
|
||||
export class TestUser {
|
||||
private _server: FastifyTypebox;
|
||||
private _email = "";
|
||||
private _password = "";
|
||||
private _authorization = "";
|
||||
|
||||
public get authorization() {return this._authorization;}
|
||||
|
||||
constructor(server: FastifyTypebox,) {
|
||||
this._server = server;
|
||||
}
|
||||
|
||||
async Login(email: string, password: string): Promise<boolean> {
|
||||
this._email = email;
|
||||
this._password = password;
|
||||
|
||||
/*const user = await this._app.userService.getUser({email});*/
|
||||
|
||||
let response = await this._server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
|
||||
/*response = await this._server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${email}&emailConfirmationToken=${user?.getData().emailConfirmationToken}`,*/
|
||||
/*});*/
|
||||
|
||||
response = await this._server.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
this._authorization = body.token;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async Logout() {
|
||||
this._authorization = ""
|
||||
}
|
||||
|
||||
async SetAuthorization(authorization: string) {
|
||||
this._authorization = authorization;
|
||||
}
|
||||
|
||||
async Get(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "PUT",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Delete(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "DELETE",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupNewUsers(server: FastifyTypebox): Promise<TestUser[]> {
|
||||
const usersWithAuthorization = await Promise.all(usersCredientials.map(async (userCredientials) =>{
|
||||
const user = new TestUser(server);
|
||||
await user.Login(userCredientials.email, userCredientials.password);
|
||||
return user;
|
||||
}));
|
||||
return usersWithAuthorization;
|
||||
}
|
||||
|
||||
export async function TestRequiredBody(method: string, url: string, user: TestUser, originalPayload: Record<string, unknown>) {
|
||||
const payloadKeys = Object.keys(originalPayload);
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
|
||||
const payload = JSON.parse(JSON.stringify(originalPayload));
|
||||
delete payload[key];
|
||||
if (method === "POST") {
|
||||
const {response} = await user.Post(url, payload);
|
||||
expect(response.statusCode).to.equals(400, `Key '${key}' in '${method} ${url}' is required but the server did not return a bad request error`);
|
||||
}
|
||||
}
|
||||
}
|
19
server/src/utils/index.ts
Normal file
19
server/src/utils/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export const ADMIN_TOKEN = process.env.API_ADMIN_AUTHORIZATION as string;
|
||||
|
||||
export function getAdminProjectId(): string | undefined {
|
||||
return process.env.ADMIN_PROJECT_ID;
|
||||
}
|
||||
|
||||
export function getAdminSucessTagId(): string | undefined {
|
||||
return process.env.ADMIN_SUCCESS_TAG_ID;
|
||||
}
|
||||
|
||||
export function getAdminFailTagId(): string | undefined {
|
||||
return process.env.ADMIN_FAIL_TAG_ID;
|
||||
}
|
44
server/src/utils/logger.ts
Normal file
44
server/src/utils/logger.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
const consoleColors = {
|
||||
RESET: "\x1b[0m",
|
||||
SUCCESS: "\x1b[32m",
|
||||
WARNING: "\x1b[33m",
|
||||
ERROR: "\x1b[31m",
|
||||
DEBUG: "\x1b[36m"
|
||||
};
|
||||
|
||||
export class Logger {
|
||||
static info(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
static debug(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.log(`${consoleColors.DEBUG}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static success(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.SUCCESS}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static warn(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.WARNING}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static error(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.ERROR}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
}
|
111
server/tsconfig.json
Normal file
111
server/tsconfig.json
Normal file
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"@app/*": ["./src/*"],
|
||||
"utils/*": ["./src/utils/*"],
|
||||
"http/*": ["./src/http/*"],
|
||||
"testUtils/*": ["./src/testUtils/*"]
|
||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue