├── yarn.lock ├── .gitignore ├── front ├── .env.example ├── assets │ ├── flagFR.png │ ├── flagITA.png │ ├── grasslight-big5.jpg │ ├── icon-dirtybiology.jpeg │ ├── checkMark.svg │ └── css │ │ └── fix-html-vertical-scrollbar.scss ├── static │ ├── favicon.ico │ └── README.md ├── .editorconfig ├── components │ ├── atoms │ │ ├── AppError.vue │ │ ├── icons │ │ │ ├── AppDoneIcon.vue │ │ │ ├── AppHomeIcon.vue │ │ │ ├── AppFlagIcon.vue │ │ │ ├── AppLogInIcon.vue │ │ │ ├── AppCreateIcon.vue │ │ │ ├── AppPlaceIcon.vue │ │ │ ├── AppHelpIcon.vue │ │ │ ├── AppDismissIcon.vue │ │ │ ├── AppPersonIcon.vue │ │ │ ├── AppInfoIcon.vue │ │ │ └── AppRegisterIcon.vue │ │ ├── AppLink.vue │ │ ├── AppStep.vue │ │ ├── AppInputFrame.vue │ │ ├── AppCheckbox.vue │ │ └── AppButton.vue │ ├── organisms │ │ ├── SubscribeForm.vue │ │ ├── AppCard.vue │ │ ├── AppAlert.vue │ │ └── TheHeader.vue │ └── molecules │ │ └── AppStepLine.vue ├── Dockerfile ├── pages │ ├── invalid_email.vue │ └── about.vue ├── .eslintrc.js ├── README.md ├── layouts │ ├── default.vue │ └── error.vue ├── .vscode │ └── launch.json ├── package.json ├── js │ ├── ratio-rectangle-distribution.js │ └── flag.js ├── .gitignore ├── nuxt.config.js ├── .dockerignore └── tailwind.config.js ├── back ├── .gitignore ├── .dockerignore ├── flag-service │ ├── .prettierrc │ ├── nest-cli.json │ ├── src │ │ ├── main.ts │ │ ├── user │ │ │ ├── AuthBackend.ts │ │ │ ├── decorators │ │ │ │ ├── Public.ts │ │ │ │ ├── UserId.ts │ │ │ │ └── spec │ │ │ │ │ ├── PublicDecorator.spec.ts │ │ │ │ │ └── UserIdDecorator.spec.ts │ │ │ ├── errors │ │ │ │ ├── IncorrectPasswordError.ts │ │ │ │ ├── InvalidDirectusTokenError.ts │ │ │ │ ├── MissingDirectusTokenError.ts │ │ │ │ ├── EmailNotFoundError.ts │ │ │ │ ├── EmailAlreadyTakenError.ts │ │ │ │ ├── NicknameAlreadyTakenError.ts │ │ │ │ └── UserIdNotFoundError.ts │ │ │ ├── User.ts │ │ │ ├── dto │ │ │ │ ├── LoginDto.ts │ │ │ │ ├── ChangeNicknameDto.ts │ │ │ │ ├── RegisterDto.ts │ │ │ │ └── ChangePasswordDto.ts │ │ │ ├── UserRepository.ts │ │ │ ├── guards │ │ │ │ ├── AuthGuard.ts │ │ │ │ ├── FouloscopieAuthGuard.ts │ │ │ │ └── spec │ │ │ │ │ ├── FouloscopieAuthGuard.spec.ts │ │ │ │ │ └── AuthGuard.spec.ts │ │ │ ├── UserController.ts │ │ │ ├── jwt │ │ │ │ ├── JwtService.ts │ │ │ │ └── spec │ │ │ │ │ └── JwtService.spec.ts │ │ │ ├── UserModule.ts │ │ │ ├── spec │ │ │ │ ├── UserController.spec.ts │ │ │ │ └── UserService.spec-integration.ts │ │ │ └── UserService.ts │ │ ├── flag │ │ │ ├── FlagRepository.ts │ │ │ ├── pixel │ │ │ │ ├── dto │ │ │ │ │ └── GetPixelDto.ts │ │ │ │ ├── Pixel.ts │ │ │ │ ├── PixelModule.ts │ │ │ │ ├── PixelRepository.ts │ │ │ │ └── spec │ │ │ │ │ └── PixelRepository.spec-integration.ts │ │ │ ├── snapshot │ │ │ │ ├── dto │ │ │ │ │ └── FlagSnapshotDto.ts │ │ │ │ ├── pixel │ │ │ │ │ ├── FlagSnapshotPixel.ts │ │ │ │ │ ├── FlagSnapshotPixelRepository.ts │ │ │ │ │ ├── FlagSnapshotPixelService.ts │ │ │ │ │ └── spec │ │ │ │ │ │ └── FlagSnapshotPixelService.spec-integration.ts │ │ │ │ ├── FlagSnapshot.ts │ │ │ │ ├── FlagSnapshotRepository.ts │ │ │ │ ├── FlagSnapshotModule.ts │ │ │ │ ├── spec │ │ │ │ │ ├── FlagSnapshotService.spec.ts │ │ │ │ │ └── FlagSnapshotService.spec-integration.ts │ │ │ │ └── FlagSnapshotService.ts │ │ │ ├── errors │ │ │ │ ├── UserHasNoPixelError.ts │ │ │ │ ├── PixelDoesNotExistError.ts │ │ │ │ ├── UserAlreadyOwnsAPixelError.ts │ │ │ │ └── UserActionIsOnCooldownError.ts │ │ │ ├── dto │ │ │ │ └── ChangePixelColorDto.ts │ │ │ ├── utils.ts │ │ │ ├── FlagModule.ts │ │ │ ├── image │ │ │ │ └── ImageService.ts │ │ │ ├── FlagController.ts │ │ │ ├── FlagService.ts │ │ │ └── spec │ │ │ │ └── FlagController.spec.ts │ │ ├── authentication │ │ │ └── errors │ │ │ │ ├── InvalidJsonWebTokenError.ts │ │ │ │ └── ExpiredJsonWebTokenError.ts │ │ └── bootstrap.ts │ ├── .env.example │ ├── test │ │ ├── jest-e2e.json │ │ ├── util │ │ │ └── registerAndLogin.ts │ │ ├── flag.spec-e2e.ts │ │ └── user.spec-e2e.ts │ ├── jest-integration.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── .gitignore │ ├── .dockerignore │ ├── .eslintrc.js │ ├── generate-pixels.ts │ └── package.json ├── package.json ├── library │ ├── .gitignore │ ├── .dockerignore │ ├── database │ │ ├── object │ │ │ ├── DatabaseObject.ts │ │ │ └── event │ │ │ │ └── DatabaseEvent.ts │ │ ├── DatabaseModule.ts │ │ ├── client │ │ │ ├── DatabaseClientService.ts │ │ │ └── spec │ │ │ │ └── DatabaseClientService.spec-integration.ts │ │ └── repository │ │ │ ├── DatabaseRepository.ts │ │ │ └── spec │ │ │ └── DatabaseRepository.spec-integration.ts │ ├── jest-integration.json │ ├── tsconfig.json │ ├── package.json │ └── .eslintrc.js └── Dockerfile ├── package.json ├── LICENSE ├── .github └── workflows │ └── back.yml └── README.md /yarn.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .idea/ 3 | -------------------------------------------------------------------------------- /front/.env.example: -------------------------------------------------------------------------------- 1 | API_URL = api_url_here -------------------------------------------------------------------------------- /back/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .idea/ 3 | -------------------------------------------------------------------------------- /back/.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .idea/ 3 | -------------------------------------------------------------------------------- /back/flag-service/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /back/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": ["library", "flag-service"] 4 | } -------------------------------------------------------------------------------- /front/assets/flagFR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorikairox/micronation/HEAD/front/assets/flagFR.png -------------------------------------------------------------------------------- /front/assets/flagITA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorikairox/micronation/HEAD/front/assets/flagITA.png -------------------------------------------------------------------------------- /front/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorikairox/micronation/HEAD/front/static/favicon.ico -------------------------------------------------------------------------------- /back/flag-service/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /back/flag-service/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from './bootstrap'; 2 | 3 | bootstrap(process.env.PORT); 4 | -------------------------------------------------------------------------------- /front/assets/grasslight-big5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorikairox/micronation/HEAD/front/assets/grasslight-big5.jpg -------------------------------------------------------------------------------- /front/assets/icon-dirtybiology.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorikairox/micronation/HEAD/front/assets/icon-dirtybiology.jpeg -------------------------------------------------------------------------------- /back/flag-service/src/user/AuthBackend.ts: -------------------------------------------------------------------------------- 1 | export enum AuthBackend { 2 | FOULOSCOPIE = 'fouloscopie', 3 | INTERNAL = 'internal', 4 | } 5 | -------------------------------------------------------------------------------- /back/library/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env.test.integration 3 | 4 | # Generated files 5 | *.d.ts 6 | *.js 7 | *.js.map 8 | tsconfig.tsbuildinfo 9 | -------------------------------------------------------------------------------- /back/library/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env.test.integration 3 | 4 | # Generated files 5 | *.d.ts 6 | *.js 7 | *.js.map 8 | tsconfig.tsbuildinfo 9 | -------------------------------------------------------------------------------- /back/library/database/object/DatabaseObject.ts: -------------------------------------------------------------------------------- 1 | import { ObjectID } from "mongodb"; 2 | 3 | export class DatabaseObject { 4 | _id?: ObjectID; 5 | createdAt?: Date; 6 | } 7 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/FlagRepository.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseRepository } from 'library/database/repository/DatabaseRepository'; 2 | 3 | export class FlagRepository extends DatabaseRepository {} 4 | -------------------------------------------------------------------------------- /front/assets/checkMark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "heroku-postbuild": "cd back/ && yarn && yarn workspace flag-service build", 4 | "start": "node back/flag-service/dist/main.js" 5 | }, 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/pixel/dto/GetPixelDto.ts: -------------------------------------------------------------------------------- 1 | export class GetPixelDto { 2 | entityId: string; 3 | hexColor: string; 4 | lastUpdate: Date; 5 | author: string; 6 | createdAt: Date; 7 | indexInFlag: number; 8 | } 9 | -------------------------------------------------------------------------------- /back/flag-service/src/user/decorators/Public.ts: -------------------------------------------------------------------------------- 1 | import { CustomDecorator, SetMetadata } from "@nestjs/common/decorators/core/set-metadata.decorator"; 2 | 3 | export const Public = (): CustomDecorator => SetMetadata('public', true); 4 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/dto/FlagSnapshotDto.ts: -------------------------------------------------------------------------------- 1 | import { FlagSnapshotPixel } from '../pixel/FlagSnapshotPixel'; 2 | 3 | export class FlagSnapshotDto { 4 | lastEventId: number; 5 | _id: string; 6 | pixels: FlagSnapshotPixel[]; 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI='mongodb://127.0.0.1:27017' 2 | CHANGE_COOLDOWN=5 3 | JWT_SECRET=changeme 4 | 5 | #AUTH_BACKEND=internal 6 | AUTH_BACKEND=fouloscopie 7 | DIRECTUS_URL=https://dev.fouloscopie.com 8 | EVENTS_PER_SNAPSHOT=100 9 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/errors/UserHasNoPixelError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class UserHasNoPixelError extends BadRequestException { 4 | constructor() { 5 | super('User has no pixel.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/errors/PixelDoesNotExistError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class PixelDoesNotExistError extends BadRequestException { 4 | constructor() { 5 | super('Pixel does not exist.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": "\\.spec-e2e\\.ts$", 6 | "transform": { 7 | "^.+\\.(t)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /back/library/jest-integration.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": "\\.spec-integration\\.ts$", 6 | "transform": { 7 | "^.+\\.(t)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /back/flag-service/jest-integration.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": "\\.spec-integration\\.ts$", 6 | "transform": { 7 | "^.+\\.(t)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /front/assets/css/fix-html-vertical-scrollbar.scss: -------------------------------------------------------------------------------- 1 | // Override vuetify's reset rule for vertical scrollbar 2 | // Use of !important because I can't figure out how to change the css loading order of vuetify vs global css 3 | html { 4 | overflow-y: auto !important; 5 | } 6 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/pixel/Pixel.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | export class Pixel { 4 | constructor( 5 | public ownerId: string, 6 | public hexColor: string, 7 | public pixId: string = v4(), 8 | public indexInFlag?: number, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/IncorrectPasswordError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class IncorrectPasswordError extends BadRequestException { 4 | constructor(field: string) { 5 | super(field, 'Incorrect password.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/errors/UserAlreadyOwnsAPixelError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class UserAlreadyOwnsAPixelError extends BadRequestException { 4 | constructor() { 5 | super('Current user already owns a pixel.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*.spec.ts", 8 | "**/*.spec-integration.ts", 9 | "**/*.spec-e2e.ts", 10 | "generate-pixels.ts", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /front/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /back/flag-service/src/authentication/errors/InvalidJsonWebTokenError.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from "@nestjs/common"; 2 | 3 | export class InvalidJsonWebTokenError extends ForbiddenException { 4 | constructor() { 5 | super('header: Authorization', 'Invalid JWT.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/authentication/errors/ExpiredJsonWebTokenError.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from "@nestjs/common"; 2 | 3 | export class ExpiredJsonWebTokenError extends UnauthorizedException { 4 | constructor() { 5 | super('header: Authorization', 'Expired JWT.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/InvalidDirectusTokenError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class InvalidDirectusTokenError extends BadRequestException { 4 | constructor() { 5 | super('header: Authorization', 'Invalid directus token.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/MissingDirectusTokenError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class MissingDirectusTokenError extends BadRequestException { 4 | constructor() { 5 | super('header: Authorization', 'Missing directus token.'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /front/components/atoms/AppError.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/EmailNotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class EmailNotFoundError extends BadRequestException { 4 | constructor(field: string, email: string) { 5 | super(field, `Email ${email} not found in database.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/dto/ChangePixelColorDto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, Matches } from 'class-validator'; 2 | 3 | export class ChangePixelColorDto { 4 | 5 | @IsString() 6 | @Matches( /^#[0-9a-f]{6}$/i) 7 | public hexColor: string; 8 | 9 | @IsString() 10 | public pixelId: string; 11 | } 12 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/EmailAlreadyTakenError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class EmailAlreadyTakenError extends BadRequestException { 4 | constructor(field: string, email: string) { 5 | super(field, `Email address ${email} is already taken.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/NicknameAlreadyTakenError.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from "@nestjs/common"; 2 | 3 | export class NicknameAlreadyTakenError extends BadRequestException { 4 | constructor(field: string, nickname: string) { 5 | super(field, `Nickname ${nickname} already taken.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/pixel/PixelModule.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PixelRepository } from './PixelRepository'; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [], 7 | providers: [PixelRepository], 8 | exports: [PixelRepository], 9 | }) 10 | export class PixelModule {} 11 | -------------------------------------------------------------------------------- /back/flag-service/src/user/User.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseObject } from "library/database/object/DatabaseObject"; 2 | 3 | export class User extends DatabaseObject { 4 | constructor( 5 | public email: string, 6 | public password: string, 7 | public nickname: string, 8 | ) { 9 | super(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/pixel/FlagSnapshotPixel.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseObject } from 'library/database/object/DatabaseObject'; 2 | 3 | export class FlagSnapshotPixel extends DatabaseObject { 4 | entityId: string; 5 | hexColor: string; 6 | author: string; 7 | indexInFlag: number; 8 | snapshotId: string; 9 | } 10 | -------------------------------------------------------------------------------- /back/flag-service/src/user/dto/LoginDto.ts: -------------------------------------------------------------------------------- 1 | import { IsAlphanumeric, IsEmail, Matches, MinLength } from 'class-validator'; 2 | 3 | export class LoginDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @MinLength(6) 8 | @IsAlphanumeric() 9 | @Matches(/[a-zA-Z]/) 10 | @Matches(/[0-9]/) 11 | password: string; 12 | } 13 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppDoneIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /back/flag-service/src/user/errors/UserIdNotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { InternalServerErrorException } from "@nestjs/common"; 2 | import { ObjectID } from "mongodb"; 3 | 4 | export class UserIdNotFoundError extends InternalServerErrorException { 5 | constructor(userId: string | ObjectID) { 6 | super(`No user found for id=${userId}`); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /back/flag-service/src/user/decorators/UserId.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export function getUserIdFromCtx(data: any, ctx: ExecutionContext) { 4 | const request = ctx.switchToHttp().getRequest(); 5 | return request.userId; 6 | } 7 | 8 | export const UserId = createParamDecorator(getUserIdFromCtx); 9 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppHomeIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /back/flag-service/src/user/dto/ChangeNicknameDto.ts: -------------------------------------------------------------------------------- 1 | import { IsAlphanumeric, Matches, MinLength } from 'class-validator'; 2 | 3 | export class ChangeNicknameDto { 4 | @MinLength(6) 5 | @IsAlphanumeric() 6 | @Matches(/[a-zA-Z]/) 7 | @Matches(/[0-9]/) 8 | password: string; 9 | 10 | @MinLength(3) 11 | @Matches(/^[a-z0-9_-]+$/) 12 | newNickname: string; 13 | } 14 | -------------------------------------------------------------------------------- /back/library/database/object/event/DatabaseEvent.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseObject } from "../DatabaseObject"; 2 | 3 | export class DatabaseEvent extends DatabaseObject { 4 | action: "creation" | "update"; 5 | data: Partial; 6 | author: string; 7 | entityId: string; 8 | eventId?: number; 9 | ignored?: boolean; 10 | ip?: string; 11 | useragent?: string; 12 | } 13 | -------------------------------------------------------------------------------- /front/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:14-alpine as build-stage 3 | WORKDIR /app 4 | ARG API_URL 5 | ENV API_URL=${API_URL} 6 | COPY package*.json ./ 7 | RUN yarn 8 | COPY . . 9 | RUN yarn build 10 | 11 | # production stage 12 | FROM nginx:stable-alpine as production-stage 13 | COPY --from=build-stage /app/dist /usr/share/nginx/html 14 | EXPOSE 80 15 | CMD ["nginx", "-g", "daemon off;"] 16 | -------------------------------------------------------------------------------- /front/pages/invalid_email.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/errors/UserActionIsOnCooldownError.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from "@nestjs/common"; 2 | 3 | export class UserActionIsOnCooldownError extends HttpException { 4 | constructor(remainingTimeInMilliseconds: number) { 5 | super({ 6 | message: 'Please retry later.', 7 | retryAfter: remainingTimeInMilliseconds, 8 | }, HttpStatus.TOO_MANY_REQUESTS); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/FlagSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseObject } from 'library/database/object/DatabaseObject'; 2 | import { GetPixelDto } from '../pixel/dto/GetPixelDto'; 3 | 4 | export class FlagSnapshot extends DatabaseObject { 5 | lastEventId: number; 6 | 7 | /** 8 | * @deprecated Use SnapshotPixel collections 9 | */ 10 | pixels?: GetPixelDto[]; 11 | complete: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /back/library/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "incremental": true, 12 | "typeRoots": ["../node_modules/@types"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /front/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"], 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | }, 10 | rules: { 11 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 12 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | # front 2 | 3 | ## Build Setup 4 | 5 | ```bash 6 | # install dependencies 7 | $ yarn install 8 | 9 | # serve with hot reload at localhost:3000 10 | $ yarn dev 11 | 12 | # build for production and launch server 13 | $ yarn build 14 | $ yarn start 15 | 16 | # generate static project 17 | $ yarn generate 18 | ``` 19 | 20 | For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). 21 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppFlagIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppLogInIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /back/flag-service/src/user/dto/RegisterDto.ts: -------------------------------------------------------------------------------- 1 | import { IsAlphanumeric, IsEmail, Matches, MinLength } from 'class-validator'; 2 | 3 | export class RegisterDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @MinLength(6) 8 | @IsAlphanumeric() 9 | @Matches(/[a-zA-Z]/) 10 | @Matches(/[0-9]/) 11 | password: string; 12 | 13 | passwordConfirmation: string; 14 | 15 | @MinLength(3) 16 | @Matches(/^[a-z0-9_-]+$/) 17 | nickname: string; 18 | } 19 | -------------------------------------------------------------------------------- /back/flag-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /front/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /back/library/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "library", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "test:integration": "dotenv -e .env.test.integration -- jest --config ./jest-integration.json" 6 | }, 7 | "dependencies": { 8 | "@nestjs/common": "^8.0.0", 9 | "@types/jest": "^26.0.24", 10 | "@types/mongodb": "^3.6.20", 11 | "jest": "^27.0.6", 12 | "mongodb": "^3.6.10", 13 | "ts-jest": "^27.0.3", 14 | "typescript": "^4.3.5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppCreateIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /front/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /back/flag-service/src/user/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { DatabaseRepository } from 'library/database/repository/DatabaseRepository'; 3 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 4 | import { User } from "./User"; 5 | 6 | @Injectable() 7 | export class UserRepository extends DatabaseRepository { 8 | constructor(@Inject('DATABASE_CLIENT') dbClient: DatabaseClientService) { 9 | super(dbClient, 'users'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /front/components/atoms/AppLink.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppPlaceIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/FlagSnapshotRepository.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 3 | import { DatabaseRepository } from 'library/database/repository/DatabaseRepository'; 4 | import { FlagSnapshot } from './FlagSnapshot'; 5 | 6 | export class FlagSnapshotRepository extends DatabaseRepository { 7 | constructor(@Inject('DATABASE_CLIENT') dbClient: DatabaseClientService) { 8 | super(dbClient, 'flag-snapshot'); 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /back/flag-service/src/user/dto/ChangePasswordDto.ts: -------------------------------------------------------------------------------- 1 | import { IsAlphanumeric, Matches, MinLength } from 'class-validator'; 2 | 3 | export class ChangePasswordDto { 4 | @MinLength(6) 5 | @IsAlphanumeric() 6 | @Matches(/[a-zA-Z]/) 7 | @Matches(/[0-9]/) 8 | currentPassword: string; 9 | 10 | @MinLength(6) 11 | @IsAlphanumeric() 12 | @Matches(/[a-zA-Z]/) 13 | @Matches(/[0-9]/) 14 | newPassword: string; 15 | 16 | @MinLength(6) 17 | @IsAlphanumeric() 18 | @Matches(/[a-zA-Z]/) 19 | @Matches(/[0-9]/) 20 | newPasswordConfirmation: string; 21 | } 22 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppHelpIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/pixel/FlagSnapshotPixelRepository.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 3 | import { DatabaseRepository } from 'library/database/repository/DatabaseRepository'; 4 | import { FlagSnapshotPixel } from './FlagSnapshotPixel'; 5 | 6 | export class FlagSnapshotPixelRepository extends DatabaseRepository { 7 | constructor(@Inject('DATABASE_CLIENT') dbClient: DatabaseClientService) { 8 | super(dbClient, 'flag-snapshot-pixel'); 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /back/flag-service/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | .env 6 | .env.test.integration 7 | .env.test.e2e 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # OS 18 | .DS_Store 19 | 20 | # Tests 21 | /coverage 22 | /.nyc_output 23 | 24 | # IDEs and editors 25 | /.idea 26 | .project 27 | .classpath 28 | .c9/ 29 | *.launch 30 | .settings/ 31 | *.sublime-workspace 32 | 33 | # IDE - VSCode 34 | .vscode/* 35 | !.vscode/settings.json 36 | !.vscode/tasks.json 37 | !.vscode/launch.json 38 | !.vscode/extensions.json 39 | -------------------------------------------------------------------------------- /back/flag-service/.dockerignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | .env 6 | .env.test.integration 7 | .env.test.e2e 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # OS 18 | .DS_Store 19 | 20 | # Tests 21 | /coverage 22 | /.nyc_output 23 | 24 | # IDEs and editors 25 | /.idea 26 | .project 27 | .classpath 28 | .c9/ 29 | *.launch 30 | .settings/ 31 | *.sublime-workspace 32 | 33 | # IDE - VSCode 34 | .vscode/* 35 | !.vscode/settings.json 36 | !.vscode/tasks.json 37 | !.vscode/launch.json 38 | !.vscode/extensions.json 39 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppDismissIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /front/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | // Adding Edge console debug for an immersive workflow 4 | { 5 | "name": "Launch Edge Console Debug Mode", 6 | "request": "launch", 7 | "type": "pwa-msedge", 8 | "url": "http://localhost:3000", 9 | "webRoot": "${workspaceFolder}" 10 | }, 11 | { 12 | "name": "Launch Microsoft Edge and open the Edge DevTools", 13 | "request": "launch", 14 | "type": "vscode-edge-devtools.debug", 15 | "url": "http://localhost:3000" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /front/components/atoms/icons/AppPersonIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /back/flag-service/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { FlagModule } from './flag/FlagModule'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { AuthBackend } from "./user/AuthBackend"; 5 | import { config } from 'dotenv'; 6 | 7 | 8 | export async function bootstrap(appListenPort: string | number = '3000') { 9 | config(); 10 | const app = await NestFactory.create(FlagModule.register(process.env.AUTH_BACKEND as AuthBackend)); 11 | app.enableShutdownHooks(); 12 | app.useGlobalPipes(new ValidationPipe()); 13 | app.enableCors({ origin: [/\.fouloscopie\.com$/] }); 14 | await app.listen(appListenPort); 15 | return app; 16 | } 17 | -------------------------------------------------------------------------------- /back/flag-service/src/user/decorators/spec/PublicDecorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Public } from "../Public"; 2 | import { Reflector } from "@nestjs/core"; 3 | 4 | describe('PublicDecorator', () => { 5 | const reflector = new Reflector(); 6 | 7 | class Test { 8 | static defaultRoute() { 9 | // 10 | } 11 | 12 | @Public() 13 | static publicRoute() { 14 | // 15 | } 16 | } 17 | 18 | it('does nothing when not present', () => { 19 | expect(reflector.get('public', Test.defaultRoute)).toStrictEqual(undefined); 20 | }); 21 | 22 | it('adds metadata', () => { 23 | expect(reflector.get('public', Test.publicRoute)).toStrictEqual(true); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /back/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine AS development 2 | 3 | WORKDIR /usr/src/app/ 4 | 5 | COPY . . 6 | 7 | RUN yarn --only=development 8 | 9 | RUN yarn workspace flag-service build 10 | 11 | RUN ls 12 | 13 | FROM node:14-alpine as production 14 | 15 | ARG NODE_ENV=production 16 | ENV NODE_ENV=${NODE_ENV} 17 | 18 | WORKDIR /usr/src/app 19 | 20 | COPY ./package.json . 21 | COPY ./flag-service/package.json ./flag-service/package.json 22 | COPY --from=development /usr/src/app/library ./library 23 | 24 | RUN yarn --only=production 25 | 26 | COPY --from=development /usr/src/app/flag-service/dist ./flag-service/dist 27 | 28 | WORKDIR /usr/src/app/flag-service/ 29 | 30 | CMD ["yarn", "start:prod"] 31 | -------------------------------------------------------------------------------- /back/library/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | root: true, 12 | env: { 13 | node: true, 14 | jest: true, 15 | }, 16 | ignorePatterns: ['.eslintrc.js'], 17 | rules: { 18 | '@typescript-eslint/interface-name-prefix': 'off', 19 | '@typescript-eslint/explicit-function-return-type': 'off', 20 | '@typescript-eslint/explicit-module-boundary-types': 'off', 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /back/flag-service/test/util/registerAndLogin.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import request from 'supertest'; 3 | 4 | export async function registerAndLogin(app: INestApplication, email: string, password: string, nickname: string): Promise<{ jwt: string, user: any }> { 5 | await request(app.getHttpServer()) 6 | .post('/user/register') 7 | .send({ 8 | email: email, 9 | password: password, 10 | passwordConfirmation: password, 11 | nickname: nickname, 12 | }); 13 | const res = await request(app.getHttpServer()) 14 | .post('/user/login') 15 | .send({ 16 | email: email, 17 | password: password, 18 | }); 19 | return res.body; 20 | } 21 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/FlagSnapshotModule.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PixelModule } from '../pixel/PixelModule'; 3 | import { FlagSnapshotPixelService } from './pixel/FlagSnapshotPixelService'; 4 | import { FlagSnapshotPixelRepository } from './pixel/FlagSnapshotPixelRepository'; 5 | import { FlagSnapshotRepository } from './FlagSnapshotRepository'; 6 | import { FlagSnapshotService } from './FlagSnapshotService'; 7 | 8 | @Module({ 9 | imports: [PixelModule], 10 | controllers: [], 11 | providers: [FlagSnapshotRepository, FlagSnapshotService, FlagSnapshotPixelRepository, FlagSnapshotPixelService], 12 | exports: [FlagSnapshotService], 13 | }) 14 | export class FlagSnapshotModule {} 15 | -------------------------------------------------------------------------------- /back/flag-service/src/user/decorators/spec/UserIdDecorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpArgumentsHost } from '@nestjs/common/interfaces'; 2 | import { getUserIdFromCtx } from '../UserId'; 3 | 4 | describe('UserIdDecorator', () => { 5 | describe('getUserIdFromCtx', () => { 6 | it ('returns userId', () => { 7 | const fakectx = { 8 | switchToHttp(): HttpArgumentsHost { 9 | return { 10 | getRequest(): T { 11 | return { 12 | userId: 'fakeUserId', 13 | } as any; 14 | }, 15 | } as any; 16 | }, 17 | } as any; 18 | expect(getUserIdFromCtx(null, fakectx)).toEqual('fakeUserId'); 19 | }); 20 | }) 21 | }); 22 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppInfoIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /back/library/database/DatabaseModule.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule } from "@nestjs/common"; 2 | import { DatabaseClientService } from "./client/DatabaseClientService"; 3 | 4 | @Module({}) 5 | export class DatabaseModule { 6 | static register(options): DynamicModule { 7 | return { 8 | global: true, 9 | module: DatabaseModule, 10 | providers: [ 11 | { 12 | provide: 'CONFIG_OPTIONS', 13 | useValue: options, 14 | }, 15 | { 16 | provide: 'DATABASE_CLIENT', 17 | useClass: DatabaseClientService, 18 | } 19 | ], 20 | exports: ['DATABASE_CLIENT'] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /back/library/database/client/DatabaseClientService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; 2 | import { MongoClient } from "mongodb"; 3 | 4 | @Injectable() 5 | export class DatabaseClientService implements OnModuleInit, OnApplicationShutdown { 6 | public client: MongoClient; 7 | 8 | constructor(@Inject("CONFIG_OPTIONS") private options) { 9 | this.client = new MongoClient(options.uri, { useUnifiedTopology: true, maxPoolSize: 5 }); 10 | } 11 | 12 | async onModuleInit(): Promise { 13 | this.client = await this.client.connect(); 14 | } 15 | 16 | async onApplicationShutdown() { 17 | await this.client.close(); 18 | } 19 | 20 | getDb() { 21 | return this.client.db(this.options.dbName); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/pixel/FlagSnapshotPixelService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GetPixelDto } from '../../pixel/dto/GetPixelDto'; 3 | import { FlagSnapshotPixelRepository } from './FlagSnapshotPixelRepository'; 4 | 5 | @Injectable() 6 | export class FlagSnapshotPixelService { 7 | 8 | constructor(private snapshotPixelRepository: FlagSnapshotPixelRepository) {} 9 | 10 | async saveSnapshotPixels(snapshotId: string, pixelsData: Array) { 11 | const snapshotPixelsArray = pixelsData.map(p => ({ ...p, snapshotId })); 12 | return this.snapshotPixelRepository.createMany(snapshotPixelsArray); 13 | } 14 | 15 | async getSnapshotPixels(snapshotId: string) { 16 | return this.snapshotPixelRepository.find({ snapshotId },{ indexInFlag: 1 }, { snapshotId: 0, _id: 0, createdAt: 0 }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /back/library/database/client/spec/DatabaseClientService.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseClientService } from "../DatabaseClientService"; 2 | import { Db } from "mongodb"; 3 | 4 | describe('Database Client Service', () => { 5 | let databaseClientService: DatabaseClientService; 6 | beforeAll(async () => { 7 | databaseClientService = new DatabaseClientService({ 8 | uri: process.env.DATABASE_URI, 9 | dbName: 'testDb' 10 | }); 11 | await databaseClientService.onModuleInit(); 12 | }); 13 | afterAll(async () => { 14 | await databaseClientService.client.close(); 15 | }); 16 | it('getDb returns db object', async () => { 17 | const db = databaseClientService.getDb(); 18 | expect(db).toBeInstanceOf(Db); 19 | expect(db.databaseName).toEqual('testDb'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/utils.ts: -------------------------------------------------------------------------------- 1 | export function mapCoordinatesToTargetRatioRectangleDistribution( 2 | pixelCount, 3 | targetRatio 4 | ): Array<{x: number, y: number}> { 5 | const map = []; 6 | 7 | let currentX = 0; 8 | let currentY = 1; 9 | 10 | function mapIndexToColumn(index) { 11 | const x = currentX; 12 | const y = index - currentX * currentY; 13 | map[index] = { x, y }; 14 | 15 | if (y >= currentY - 1) { 16 | currentX++; 17 | } 18 | } 19 | 20 | function mapIndexToRow(index) { 21 | const x = index - currentX * currentY; 22 | const y = currentY; 23 | map[index] = { x, y }; 24 | 25 | if (x >= currentX - 1) { 26 | currentY++; 27 | } 28 | } 29 | 30 | for (let i = 0; i < pixelCount; i++) { 31 | const hasEnoughRows = Math.floor(currentX * targetRatio) <= currentY - 1; 32 | if (hasEnoughRows) { 33 | mapIndexToColumn(i); 34 | } else { 35 | mapIndexToRow(i); 36 | } 37 | } 38 | 39 | return map; 40 | } 41 | -------------------------------------------------------------------------------- /front/components/atoms/icons/AppRegisterIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /front/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 33 | 34 | 41 | -------------------------------------------------------------------------------- /back/flag-service/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | root: true, 12 | env: { 13 | node: true, 14 | jest: true, 15 | }, 16 | ignorePatterns: ['.eslintrc.js'], 17 | rules: { 18 | '@typescript-eslint/interface-name-prefix': 'off', 19 | '@typescript-eslint/explicit-function-return-type': 'off', 20 | '@typescript-eslint/explicit-module-boundary-types': 'off', 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | '@typescript-eslint/no-unused-vars': 'error', 23 | 'indent': ['error', 2, { 'FunctionExpression': { 'body': 1, 'parameters': 2 } }], 24 | 'object-curly-spacing': ['error', 'always'], 25 | '@typescript-eslint/ban-types': [ 26 | 'error', 27 | { 28 | 'types': { 29 | 'Function': false, 30 | } 31 | } 32 | ], 33 | 'comma-dangle': ['error', 'always-multiline'], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micronation", 3 | "version": "3.0.8", 4 | "private": true, 5 | "scripts": { 6 | "build": "nuxt build", 7 | "lint": "vue-cli-service lint", 8 | "dev": "nuxt", 9 | "generate": "nuxt generate", 10 | "start": "nuxt start" 11 | }, 12 | "dependencies": { 13 | "@chenfengyuan/vue-countdown": "^1.1.5", 14 | "@nuxtjs/axios": "^5.13.1", 15 | "@nuxtjs/dotenv": "^1.4.1", 16 | "@tailwindcss/typography": "^0.4.1", 17 | "core-js": "^3.9.1", 18 | "dat-gui": "^0.5.0", 19 | "fouloscopie": "^2.3.1", 20 | "nuxt": "^2.15.3", 21 | "stats-js": "^1.0.1", 22 | "three": "^0.131.3", 23 | "vue-awesome-countdown": "^1.1.4", 24 | "vue-color": "^2.8.1", 25 | "zoid": "^9.0.73" 26 | }, 27 | "devDependencies": { 28 | "@nuxtjs/tailwindcss": "^4.0.1", 29 | "@nuxtjs/vuetify": "^1.12.1", 30 | "@vue/cli-plugin-eslint": "~4.5.0", 31 | "@vue/cli-service": "^4.5.13", 32 | "@vue/eslint-config-prettier": "^6.0.0", 33 | "eslint": "^6.7.2", 34 | "eslint-plugin-prettier": "^3.3.1", 35 | "eslint-plugin-vue": "^6.2.2", 36 | "postcss": "^8.2.8", 37 | "prettier": "^2.2.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/FlagModule.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { FlagController } from './FlagController'; 3 | import { FlagService } from './FlagService'; 4 | import { ImageService } from './image/ImageService'; 5 | import { PixelModule } from './pixel/PixelModule'; 6 | import { DatabaseModule } from 'library/database/DatabaseModule'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | import { UserModule } from "../user/UserModule"; 9 | import { AuthBackend } from "../user/AuthBackend"; 10 | import { FlagSnapshotModule } from './snapshot/FlagSnapshotModule'; 11 | 12 | @Module({}) 13 | export class FlagModule { 14 | static register(authBackend: AuthBackend): DynamicModule { 15 | return { 16 | module: FlagModule, 17 | imports: [ 18 | ConfigModule.forRoot({ isGlobal: true }), 19 | DatabaseModule.register({ 20 | uri: process.env.DATABASE_URI, 21 | dbName: 'micronation', 22 | }), 23 | PixelModule, 24 | FlagSnapshotModule, 25 | UserModule.register(authBackend), 26 | ], 27 | controllers: [FlagController], 28 | providers: [FlagService, ImageService], 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /.github/workflows/back.yml: -------------------------------------------------------------------------------- 1 | name: Flag Service Test 2 | 3 | 4 | on: 5 | push: 6 | branches: 7 | - 'main' 8 | pull_request: 9 | paths: 10 | - 'back/**/*' 11 | - '.github/workflows/back.yml' 12 | 13 | defaults: 14 | run: 15 | working-directory: back/flag-service 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | node-version: [14.x] 24 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 25 | 26 | services: 27 | mongodb: 28 | image: mongo 29 | ports: 30 | - 27017:27017 31 | env: 32 | DATABASE_URI: 'mongodb://localhost:27017' 33 | DATABASE_NAME: 'micronation' 34 | PORT: '3000' 35 | CHANGE_COOLDOWN: 5 36 | AUTH_BACKEND: 'fouloscopie' 37 | EVENTS_PER_SNAPSHOT: 100 38 | DIRECTUS_URL: 'directusurl' 39 | JWT_SECRET: 'WHATANICESECRET' 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: Use Node.js ${{ matrix.node-version }} 43 | uses: actions/setup-node@v2 44 | with: 45 | node-version: ${{ matrix.node-version }} 46 | - run: yarn 47 | - run: yarn lint 48 | - run: yarn test 49 | - run: yarn test:integration 50 | - run: yarn test:e2e 51 | -------------------------------------------------------------------------------- /back/flag-service/src/user/guards/AuthGuard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 2 | import { Reflector } from "@nestjs/core"; 3 | import { JwtPayload } from 'jsonwebtoken'; 4 | import { JwtService } from "../jwt/JwtService"; 5 | import { UserDataJwtPayload } from "../UserService"; 6 | 7 | @Injectable() 8 | export class AuthGuard implements CanActivate { 9 | constructor( 10 | private reflector: Reflector, 11 | private jwtService: JwtService, 12 | ) { 13 | } 14 | 15 | async canActivate( 16 | context: ExecutionContext, 17 | ): Promise { 18 | const request = context.switchToHttp().getRequest(); 19 | 20 | const jwt = request.headers.authorization; 21 | const publicMetadata = this.reflector.get('public', context.getHandler()); 22 | 23 | if (jwt) { 24 | const payload = await this.jwtService.verify(jwt); 25 | 26 | request.jwtPayload = payload; 27 | request.userId = payload.userData._id; 28 | return true; 29 | } else if (publicMetadata) { 30 | return true; 31 | } 32 | 33 | throw new ForbiddenException(); 34 | } 35 | } 36 | 37 | declare module "@nestjs/common" { 38 | interface Request { 39 | jwtPayload: JwtPayload; 40 | userId: string; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /front/js/ratio-rectangle-distribution.js: -------------------------------------------------------------------------------- 1 | export const DESIRED_FLAG_RATIO = 1 / 2; 2 | 3 | export function mapCoordinatesToTargetRatioRectangleDistribution( 4 | pixelCount, 5 | targetRatio 6 | ) { 7 | const map = []; 8 | 9 | let currentX = 0; 10 | let currentY = 1; 11 | 12 | function mapIndexToColumn(index) { 13 | const x = currentX; 14 | const y = index - currentX * currentY; 15 | map[index] = { x, y }; 16 | 17 | if (y >= currentY - 1) { 18 | currentX++; 19 | } 20 | } 21 | 22 | function mapIndexToRow(index) { 23 | const x = index - currentX * currentY; 24 | const y = currentY; 25 | map[index] = { x, y }; 26 | 27 | if (x >= currentX - 1) { 28 | currentY++; 29 | } 30 | } 31 | 32 | for (let i = 0; i < pixelCount; i++) { 33 | const hasEnoughRows = Math.floor(currentX * targetRatio) <= currentY - 1; 34 | if (hasEnoughRows) { 35 | mapIndexToColumn(i); 36 | } else { 37 | mapIndexToRow(i); 38 | } 39 | } 40 | 41 | return map; 42 | } 43 | 44 | export function getFlagResolutionFromIndexToCoordinateMap( 45 | indexToCoordinateMap 46 | ) { 47 | let width = 0, 48 | height = 0; 49 | for (let i = 0; i < indexToCoordinateMap.length; i++) { 50 | width = Math.max(width, indexToCoordinateMap[i].x + 1); 51 | height = Math.max(height, indexToCoordinateMap[i].y + 1); 52 | } 53 | return { width, height }; 54 | } 55 | -------------------------------------------------------------------------------- /back/flag-service/src/user/guards/FouloscopieAuthGuard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { InvalidDirectusTokenError } from "../errors/InvalidDirectusTokenError"; 3 | import { Reflector } from "@nestjs/core"; 4 | import { Directus } from "@directus/sdk"; 5 | import { MissingDirectusTokenError } from "../errors/MissingDirectusTokenError"; 6 | 7 | @Injectable() 8 | export class FouloscopieAuthGuard implements CanActivate { 9 | constructor( 10 | private readonly reflector: Reflector, 11 | ) { 12 | } 13 | 14 | 15 | public async canActivate(context: ExecutionContext): Promise { 16 | const request = context.switchToHttp().getRequest(); 17 | 18 | const token = request.headers.authorization; 19 | const publicMetadata = this.reflector.get('public', context.getHandler()); 20 | 21 | if (publicMetadata) { 22 | return true; 23 | } else if (!token) { 24 | throw new MissingDirectusTokenError(); 25 | } else { 26 | const directus = new Directus(process.env.DIRECTUS_URL); 27 | if (!await directus.auth.static(token)) { 28 | throw new InvalidDirectusTokenError(); 29 | } 30 | const user = (await directus.users.me.read({ fields: ['id', 'email_valid'] })); 31 | if (!user.email_valid) { 32 | return false; 33 | } 34 | request.userId = user.id; 35 | return true; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /front/components/organisms/SubscribeForm.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /front/components/molecules/AppStepLine.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 55 | 56 | 63 | -------------------------------------------------------------------------------- /front/components/atoms/AppStep.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 46 | 47 | 67 | -------------------------------------------------------------------------------- /front/js/flag.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | export function sanitizeFlagData(rawFlagData) { 4 | return rawFlagData 5 | .filter((p) => !!p) 6 | .sort((a, b) => a.indexInflag - b.indexInFlag); 7 | } 8 | 9 | export function mapFlagDataToWorldCoordinates( 10 | flagData, 11 | flagIndexToCoordinateMap 12 | ) { 13 | const worldMap = []; 14 | 15 | for (let i = 0; i < flagData.length; i++) { 16 | const { x, y } = flagIndexToCoordinateMap[i] || { x: -1, y: -1 }; 17 | if (!worldMap[x]) { 18 | worldMap[x] = []; 19 | } 20 | worldMap[x][y] = flagData[i]; 21 | } 22 | 23 | return worldMap; 24 | } 25 | 26 | export function makeFlagTextureArray(width, height, flagPixelMap) { 27 | const textureArray = new Uint8Array(4 * width * height); 28 | 29 | for (let x = 0; x < width; x++) { 30 | for (let y = 0; y < height; y++) { 31 | const pixel = flagPixelMap[x]?.[y]; 32 | const index = x + y * width; 33 | const strideIndex = index * 4; 34 | 35 | if (pixel) { 36 | const color = new THREE.Color(pixel.hexColor); 37 | const r = Math.floor(color.r * 255); 38 | const g = Math.floor(color.g * 255); 39 | const b = Math.floor(color.b * 255); 40 | 41 | textureArray[strideIndex] = r; 42 | textureArray[strideIndex + 1] = g; 43 | textureArray[strideIndex + 2] = b; 44 | textureArray[strideIndex + 3] = 255; 45 | } else { 46 | textureArray[strideIndex] = 0; 47 | textureArray[strideIndex + 1] = 0; 48 | textureArray[strideIndex + 2] = 0; 49 | textureArray[strideIndex + 3] = 0; 50 | } 51 | } 52 | } 53 | return textureArray; 54 | } 55 | -------------------------------------------------------------------------------- /front/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | ./static/pixel-events.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # Nuxt generate 74 | dist 75 | 76 | # vuepress build output 77 | .vuepress/dist 78 | 79 | # Serverless directories 80 | .serverless 81 | 82 | # IDE / Editor 83 | .idea 84 | 85 | # Service worker 86 | sw.* 87 | 88 | # macOS 89 | .DS_Store 90 | 91 | # Vim swap files 92 | *.swp 93 | /static/pixel-events.json 94 | -------------------------------------------------------------------------------- /back/flag-service/generate-pixels.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 2 | import { MongoClient } from 'mongodb'; 3 | import { config } from 'dotenv'; 4 | import { v4 } from 'uuid'; 5 | import { Pixel } from './src/flag/pixel/Pixel'; 6 | 7 | config(); 8 | 9 | let client = new MongoClient(process.env.DATABASE_URI, { useUnifiedTopology: true }); 10 | 11 | const main = async () => { 12 | const args = process.argv.slice(2); 13 | if (args.length === 0) { 14 | console.log('You need to put the number of pixel you want to generate as argument. Example : ts-node generate-pixels 5'); 15 | } 16 | const numberOfPixelToGenerate = Number(args[0]); 17 | 18 | client = await client.connect(); 19 | const db = client.db('micronation'); 20 | const counter = (await db.collection('counter').findOne({ name: 'pixelCounter' }))?.counter || 0; 21 | let lastEventId = (await db.collection('counter').findOne({ name: 'pixelEventCounter' }))?.counter || 0; 22 | let i = counter + 1; 23 | while (i <= counter + numberOfPixelToGenerate) { 24 | const event = new DatabaseEvent() 25 | event.action = 'creation'; 26 | event.author = v4(); 27 | event.entityId = v4(); 28 | event.eventId = ++lastEventId; 29 | event.data = new Pixel(event.author, `${"#000000".replace(/0/g,function(){return (~~(Math.random()*16)).toString(16)})}`, event.entityId, i); 30 | event.createdAt = new Date(); 31 | await db.collection('pixel-events').insertOne(event); 32 | i++; 33 | } 34 | await db.collection('counter').findOneAndUpdate({ name: 'pixelCounter' }, { $set : { counter : counter + numberOfPixelToGenerate } }, { upsert: true }); 35 | await db.collection('counter').findOneAndUpdate({ name: 'pixelEventCounter' }, { $set : { counter : lastEventId } }, { upsert: true }); 36 | await client.close(); 37 | } 38 | 39 | 40 | main(); 41 | -------------------------------------------------------------------------------- /front/pages/about.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 28 | 29 | 40 | -------------------------------------------------------------------------------- /back/flag-service/src/user/UserController.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, Post, Put, Req, Request } from '@nestjs/common'; 2 | import { ChangeNicknameDto } from './dto/ChangeNicknameDto'; 3 | import { ChangePasswordDto } from './dto/ChangePasswordDto'; 4 | import { LoginDto } from './dto/LoginDto'; 5 | import { RegisterDto } from './dto/RegisterDto'; 6 | import { UserService } from "./UserService"; 7 | import { Public } from "./decorators/Public"; 8 | 9 | 10 | @Controller({ path: "/user" }) 11 | export class UserController { 12 | constructor( 13 | private userService: UserService, 14 | ) { 15 | } 16 | 17 | @Post('/register') 18 | @Public() 19 | async register( 20 | @Body() { email, password, passwordConfirmation, nickname }: RegisterDto, 21 | ): Promise<{ success: true }> { 22 | if (passwordConfirmation !== password) { 23 | throw new BadRequestException('passwordConfirmation', 'Password confirmation doesn\'t match.'); 24 | } 25 | 26 | await this.userService.register(email, password, nickname); 27 | return { success: true }; 28 | } 29 | 30 | @Post('/login') 31 | @Public() 32 | async login( 33 | @Body() { email, password }: LoginDto, 34 | ): Promise { 35 | const jwtPayload = await this.userService.login(email, password); 36 | return jwtPayload; 37 | } 38 | 39 | @Put('/change-password') 40 | async changePassword( 41 | @Req() request: Request, 42 | @Body() { currentPassword, newPassword, newPasswordConfirmation }: ChangePasswordDto, 43 | ): Promise<{ success: true }> { 44 | if (newPasswordConfirmation !== newPassword) { 45 | throw new BadRequestException('newPasswordConfirmation', 'New password confirmation doesn\'t match.'); 46 | } 47 | await this.userService.changePassword(request.userId, currentPassword, newPassword); 48 | return { success: true }; 49 | } 50 | 51 | @Put('/change-nickname') 52 | async changeNickname( 53 | @Req() request: Request, 54 | @Body() { password, newNickname }: ChangeNicknameDto, 55 | ): Promise<{ nickname: string }> { 56 | const updatedUser = await this.userService.changeNickname(request.userId, password, newNickname); 57 | return { nickname: updatedUser.nickname }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /back/flag-service/src/user/jwt/JwtService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import jwt, { JsonWebTokenError, JwtPayload, TokenExpiredError } from "jsonwebtoken"; 3 | import { v4 } from "uuid"; 4 | import { ExpiredJsonWebTokenError } from "../../authentication/errors/ExpiredJsonWebTokenError"; 5 | import { InvalidJsonWebTokenError } from "../../authentication/errors/InvalidJsonWebTokenError"; 6 | 7 | @Injectable() 8 | export class JwtService { 9 | async onModuleInit(): Promise { 10 | if (!this.secret || this.secret.length === 0 || this.secret === 'changeme') { 11 | throw new Error('JWT_SECRET environment variable is not configured!'); 12 | } 13 | } 14 | 15 | public async sign(payload: ExtendedJwtPayload, expiresIn: string): Promise { 16 | return await new Promise((resolve, reject) => { 17 | jwt.sign(payload, this.secret, { 18 | jwtid: v4(), 19 | expiresIn: expiresIn, 20 | }, (err, signedToken) => { 21 | if (err) return reject(err); 22 | 23 | resolve(signedToken); 24 | }); 25 | }); 26 | } 27 | 28 | public async verify(token: string): Promise> { 29 | let payload: JwtPayload & PayloadFormat; 30 | try { 31 | payload = await new Promise>((resolve, reject) => { 32 | jwt.verify(token, this.secret, (err, decodedPayload) => { 33 | if (err) return reject(err); 34 | 35 | resolve(decodedPayload as ExtendedJwtPayload); 36 | }); 37 | }); 38 | } catch (e) { 39 | if (e instanceof TokenExpiredError) { 40 | throw new ExpiredJsonWebTokenError(); 41 | } else if (e instanceof JsonWebTokenError) { 42 | throw new InvalidJsonWebTokenError(); 43 | } else { 44 | throw e; 45 | } 46 | } 47 | return payload; 48 | } 49 | 50 | private get secret(): string { 51 | return process.env.JWT_SECRET; 52 | } 53 | } 54 | 55 | // eslint-disable-next-line @typescript-eslint/ban-types 56 | export type PayloadFormatBaseType = string | object | Buffer; 57 | 58 | export type ExtendedJwtPayload = JwtPayload & T; 59 | -------------------------------------------------------------------------------- /front/components/atoms/AppInputFrame.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /front/nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode 3 | ssr: false, 4 | server: { 5 | port: 3500, // default: 3000 6 | }, 7 | 8 | // Global page headers: https://go.nuxtjs.dev/config-head 9 | head: { 10 | title: "Micronation", 11 | htmlAttrs: { 12 | lang: "fr", 13 | }, 14 | meta: [ 15 | { charset: "utf-8" }, 16 | { name: "viewport", content: "width=device-width, initial-scale=1" }, 17 | { hid: "description", name: "description", content: "" }, 18 | ], 19 | link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }], 20 | }, 21 | 22 | // Global CSS: https://go.nuxtjs.dev/config-css 23 | css: ["@/assets/css/fix-html-vertical-scrollbar.scss"], 24 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 25 | plugins: [], 26 | 27 | // Auto import components: https://go.nuxtjs.dev/config-components 28 | components: [ 29 | "~/components/atoms", 30 | "~/components/molecules", 31 | "~/components/organisms", 32 | ], 33 | 34 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 35 | buildModules: [ 36 | // https://go.nuxtjs.dev/tailwindcss 37 | "@nuxtjs/vuetify", 38 | "@nuxtjs/tailwindcss", 39 | ["@nuxtjs/dotenv", { systemvars: true }], 40 | ], 41 | 42 | // Modules: https://go.nuxtjs.dev/config-modules 43 | modules: [ 44 | // https://go.nuxtjs.dev/axios 45 | "@nuxtjs/axios", 46 | ], 47 | 48 | // Axios module configuration: https://go.nuxtjs.dev/config-axios 49 | axios: {}, 50 | 51 | // Build Configuration: https://go.nuxtjs.dev/config-build 52 | build: { 53 | postcss: { 54 | // Add plugin names as key and arguments as value 55 | // Install them before as dependencies with npm or yarn 56 | plugins: { 57 | // Disable a plugin by passing false as value 58 | "postcss-import": {}, 59 | }, 60 | preset: { 61 | // Change the postcss-preset-env settings 62 | autoprefixer: { 63 | grid: true, 64 | }, 65 | }, 66 | }, 67 | }, 68 | 69 | env: { 70 | apiUrl: process.env.API_URL || "http://localhost:3000", 71 | }, 72 | router: { 73 | // middleware: ["auth"], 74 | }, 75 | 76 | // Other Configs 77 | 78 | vuetify: { 79 | treeShake: true, 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/spec/FlagSnapshotService.spec.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { FlagSnapshotRepository } from '../FlagSnapshotRepository'; 3 | import { FlagSnapshotService } from "../FlagSnapshotService"; 4 | import { FlagSnapshotPixelService } from '../pixel/FlagSnapshotPixelService'; 5 | 6 | describe(FlagSnapshotService.name, () => { 7 | const flagSnapshotRepository = new FlagSnapshotRepository(undefined); 8 | const snapshotPixelService = new FlagSnapshotPixelService(null); 9 | const flagSnapshotService = new FlagSnapshotService(flagSnapshotRepository, null, null, snapshotPixelService); 10 | 11 | describe(FlagSnapshotService.prototype.createNewEmptySnapshot, () => { 12 | let repositoryCreateWatcher; 13 | let snapshot; 14 | beforeAll(async () => { 15 | repositoryCreateWatcher = jest.spyOn(flagSnapshotRepository, 'createAndReturn') 16 | .mockImplementation(async (flagSnapshotData) => flagSnapshotData); 17 | snapshot = await flagSnapshotService.createNewEmptySnapshot(42); 18 | }); 19 | 20 | it('calls ' + FlagSnapshotRepository.prototype.createAndReturn + ' once', async () => { 21 | expect(repositoryCreateWatcher).toHaveBeenCalledTimes(1); 22 | }); 23 | 24 | it('returns the snapshot', () => { 25 | expect(snapshot.lastEventId).toBe(42); 26 | expect(snapshot.complete).toBe(false); 27 | expect(snapshot.pixels).toBe(undefined); 28 | }); 29 | }); 30 | 31 | describe(FlagSnapshotService.prototype.createSnapshot, () => { 32 | let serviceCreateSnapshotWatcher; 33 | beforeAll(async () => { 34 | jest.spyOn(flagSnapshotService, 'getPixelsForSnapshot') 35 | .mockImplementation(async () => []); 36 | serviceCreateSnapshotWatcher = jest.spyOn(flagSnapshotService, 'createNewEmptySnapshot') 37 | .mockImplementation(async () => ({ _id: new ObjectId() } as any)); 38 | jest.spyOn(snapshotPixelService, 'saveSnapshotPixels') 39 | .mockImplementation(async () => ({} as any)); 40 | jest.spyOn(flagSnapshotRepository, 'updateAndReturnOne') 41 | .mockImplementation(() => ({} as any)); 42 | await flagSnapshotService.createSnapshot(1); 43 | }); 44 | 45 | it('calls ' + FlagSnapshotService.prototype.createNewEmptySnapshot + ' once', async () => { 46 | expect(serviceCreateSnapshotWatcher).toHaveBeenCalledTimes(1); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /front/.dockerignore: -------------------------------------------------------------------------------- 1 | ### Node template 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | -------------------------------------------------------------------------------- /back/flag-service/src/user/UserModule.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, DynamicModule, Module, Provider, Type } from "@nestjs/common"; 2 | import { UserRepository } from "./UserRepository"; 3 | import { UserController } from "./UserController"; 4 | import { APP_GUARD } from "@nestjs/core"; 5 | import { JwtService } from "./jwt/JwtService"; 6 | import { FouloscopieAuthGuard } from "./guards/FouloscopieAuthGuard"; 7 | import { AuthGuard } from "./guards/AuthGuard"; 8 | import { AuthBackend } from "./AuthBackend"; 9 | import { UserService } from "./UserService"; 10 | import { ForwardReference } from "@nestjs/common/interfaces/modules/forward-reference.interface"; 11 | import { Abstract } from "@nestjs/common/interfaces/abstract.interface"; 12 | 13 | @Module({}) 14 | export class UserModule { 15 | public static register(authBackend: AuthBackend): DynamicModule { 16 | return { 17 | module: UserModule, 18 | imports: [], 19 | controllers: this.getControllers(authBackend), 20 | providers: this.getProviders(authBackend), 21 | exports: this.getExports(authBackend), 22 | }; 23 | } 24 | 25 | private static getControllers(authBackend: AuthBackend): Type[] { 26 | const controllers = []; 27 | 28 | if (authBackend === AuthBackend.INTERNAL) { 29 | controllers.push(UserController); 30 | } 31 | 32 | return controllers; 33 | } 34 | 35 | private static getProviders(authBackend: AuthBackend): Provider[] { 36 | const providers: Provider[] = [ 37 | JwtService, 38 | { 39 | provide: APP_GUARD, 40 | useClass: this.getAuthGuardClass(authBackend), 41 | }, 42 | UserRepository, 43 | ]; 44 | 45 | if (authBackend === AuthBackend.INTERNAL) { 46 | providers.push(UserService); 47 | } 48 | 49 | return providers; 50 | } 51 | 52 | private static getExports(authBackend: AuthBackend): Array | string | symbol | Provider | ForwardReference | Abstract | Function> { 53 | const repositories = []; 54 | 55 | if (authBackend === AuthBackend.INTERNAL) { 56 | repositories.push(UserRepository); 57 | } 58 | 59 | return repositories; 60 | } 61 | 62 | private static getAuthGuardClass(authBackend: AuthBackend): Type { 63 | switch (authBackend) { 64 | case AuthBackend.FOULOSCOPIE: 65 | return FouloscopieAuthGuard; 66 | case AuthBackend.INTERNAL: 67 | return AuthGuard; 68 | default: 69 | throw new Error('Invalid AUTH_BACKEND: ' + authBackend); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/image/ImageService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { format, isAfter, set, sub } from 'date-fns'; 3 | import Jimp from 'jimp'; 4 | import { FlagService } from '../FlagService'; 5 | import { GetPixelDto } from '../pixel/dto/GetPixelDto'; 6 | import { mapCoordinatesToTargetRatioRectangleDistribution } from '../utils'; 7 | 8 | @Injectable() 9 | export class ImageService { 10 | 11 | constructor(private flagService: FlagService) { 12 | } 13 | 14 | async generateFlagImage() { 15 | let actualDate = new Date(); 16 | actualDate = set(actualDate, { year: 2021, date: 21, hours: 12 }); 17 | while (isAfter(actualDate, new Date(2021, 10, 1))) { 18 | const pixels = await this.flagService.getFlagAtDate(actualDate); 19 | const image = await this.generateImageFromPixelArray(pixels, 131815, 1/2); 20 | const filename = 'flag-' + format(actualDate, 'yyyy-MM-dd') + '.png'; 21 | await image.writeAsync('./' + filename); 22 | console.log('generated ' + filename); 23 | actualDate = sub(actualDate, { hours: 24 }); 24 | } 25 | } 26 | 27 | generateImageFromPixelArray(pixelArray: Array, flagTotalPixelCount: number, ratio: number, scaleFactor = 8) { 28 | const localIndexToPixelCoordinateMap = mapCoordinatesToTargetRatioRectangleDistribution(flagTotalPixelCount, ratio); 29 | const width = localIndexToPixelCoordinateMap.reduce((max: number, actualCoordinate) => { 30 | if (max < actualCoordinate.x) { 31 | max = actualCoordinate.x; 32 | } 33 | return max; 34 | }, 0) + 1; 35 | const height = localIndexToPixelCoordinateMap.reduce((max: number, actualCoordinate) => { 36 | if (max < actualCoordinate.y) { 37 | max = actualCoordinate.y; 38 | } 39 | return max; 40 | }, 0) + 1; 41 | const image = new Jimp(width * scaleFactor, height * scaleFactor); 42 | let i = 0; 43 | while (i < pixelArray.length) { 44 | const isColorValid = /^#[0-9a-f]{6}$/i.test(pixelArray[i].hexColor); 45 | const coordinates = localIndexToPixelCoordinateMap[i]; 46 | let xScale = 0; 47 | while (xScale < scaleFactor) { 48 | let yScale = 0; 49 | while (yScale < scaleFactor) { 50 | image.setPixelColor(Jimp.cssColorToHex(isColorValid ? pixelArray[i].hexColor : '#FFFFFF'), coordinates.x * scaleFactor + xScale, coordinates.y * scaleFactor + yScale); 51 | yScale++; 52 | } 53 | xScale++; 54 | } 55 | i++; 56 | } 57 | return image; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /front/components/atoms/AppCheckbox.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | 32 | 98 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/FlagController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | InternalServerErrorException, 6 | Param, 7 | Post, 8 | Put, Req, 9 | } from '@nestjs/common'; 10 | import { Request } from 'express'; 11 | import { RealIP } from 'nestjs-real-ip'; 12 | import { Public } from '../user/decorators/Public'; 13 | import { UserId } from '../user/decorators/UserId'; 14 | import { ChangePixelColorDto } from './dto/ChangePixelColorDto'; 15 | import { FlagService } from './FlagService'; 16 | import { parseISO } from 'date-fns'; 17 | 18 | @Controller('') 19 | export class FlagController { 20 | constructor(private flagService: FlagService) { 21 | } 22 | 23 | @Post('pixel') 24 | async addPixel( 25 | @UserId() ownerId: string, 26 | @Body('hexColor') hexColor: string, 27 | ) { 28 | const event = await this.flagService.addPixel(ownerId, hexColor); 29 | return event; 30 | } 31 | 32 | @Put('pixel') 33 | async changePixelColor( 34 | @UserId() currentUserId: string, 35 | @Body() changeColorDTO: ChangePixelColorDto, 36 | @RealIP() ip: string, 37 | @Req() request: Request, 38 | ) { 39 | const event = await this.flagService.changePixelColor(currentUserId, changeColorDTO.pixelId, changeColorDTO.hexColor, ip, request.header('user-agent')); 40 | return event; 41 | } 42 | 43 | @Get('pixel') 44 | async getUserPixel( 45 | @UserId() userId: string, 46 | ) { 47 | return this.flagService.getOrCreateUserPixel(userId); 48 | } 49 | 50 | @Get('flag') 51 | @Public() 52 | async getFlag() { 53 | try { 54 | const flag = await this.flagService.getFlag(); 55 | return flag; 56 | } catch (e) { 57 | throw new InternalServerErrorException(); 58 | } 59 | } 60 | 61 | @Get('flag/:date') 62 | @Public() 63 | async getFlagAtDate(@Param('date') requestedDate: Date) { 64 | try { 65 | const flag = await this.flagService.getFlagAtDate(requestedDate); 66 | return flag; 67 | } catch (e) { 68 | throw new InternalServerErrorException(); 69 | } 70 | } 71 | 72 | @Get('flag/after/:date') 73 | @Public() 74 | async getFlagAfterDate(@Param('date') requestedDate: Date | string) { 75 | try { 76 | if (typeof requestedDate === 'string') { 77 | requestedDate = parseISO(requestedDate); 78 | } 79 | const flag = await this.flagService.getFlagAfterDate(requestedDate); 80 | return flag; 81 | } catch (e) { 82 | throw new InternalServerErrorException(); 83 | } 84 | } 85 | 86 | @Get('cooldown') 87 | @Public() 88 | getChangeCooldown() { 89 | return { 90 | cooldown: Number(process.env.CHANGE_COOLDOWN), 91 | }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /back/library/database/repository/DatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseClientService } from "../client/DatabaseClientService"; 2 | import { DatabaseObject } from "../object/DatabaseObject"; 3 | import { FilterQuery, ProjectionOperators, QuerySelector, SchemaMember, SortOptionObject } from 'mongodb'; 4 | 5 | export class DatabaseRepository { 6 | 7 | constructor(protected dbClient: DatabaseClientService, 8 | protected collectionName: string) { 9 | } 10 | 11 | async createAndReturn(data: T): Promise { 12 | data.createdAt = new Date(); 13 | const insertOperation = await this.dbClient.getDb().collection(this.collectionName).insertOne(data); 14 | return insertOperation.ops[0]; 15 | } 16 | 17 | async createMany(data: Array) { 18 | return this.dbClient.getDb().collection(this.collectionName).insertMany(data); 19 | } 20 | 21 | async find(filter: FilterQuery, sort?: SortOptionObject, select?: SchemaMember): Promise> { 22 | const dataArray = await this.dbClient.getDb().collection(this.collectionName).find(filter, {sort, projection: select}).toArray(); 23 | return dataArray; 24 | } 25 | 26 | async findOne(filter: FilterQuery): Promise { 27 | return this.dbClient.getDb().collection(this.collectionName).findOne(filter); 28 | } 29 | 30 | async findLastByDate(filter: FilterQuery): Promise { 31 | return this.dbClient.getDb().collection(this.collectionName).findOne(filter, { sort: { createdAt: -1 } }); 32 | } 33 | 34 | async findLastByEventId(filter: FilterQuery): Promise { 35 | return this.dbClient.getDb().collection(this.collectionName).findOne(filter, { sort: { eventId: -1 } }); 36 | } 37 | 38 | async updateAndReturnOne(filter: Partial, partialUpdateObject: Partial): Promise { 39 | const updateOperation = await this.dbClient.getDb().collection(this.collectionName).findOneAndUpdate(filter, { $set : partialUpdateObject}, { 40 | returnDocument: 'after' 41 | }); 42 | return updateOperation.value 43 | } 44 | 45 | async updateAndReturnMany(filter: Partial, partialUpdateObject: Partial): Promise { 46 | const updateOperation = await this.dbClient.getDb().collection(this.collectionName).updateMany(filter, { $set : partialUpdateObject}); 47 | return updateOperation.modifiedCount; 48 | } 49 | 50 | getCollectionName() { 51 | return this.collectionName; 52 | } 53 | 54 | getCollection() { 55 | return this.dbClient.getDb().collection(this.collectionName); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /front/components/organisms/AppCard.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /front/components/organisms/AppAlert.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /back/flag-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flag-service", 3 | "version": "4.1.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "yarn --cwd ../library/ tsc && yarn nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "yarn --cwd ../library/ tsc && yarn nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:integration": "dotenv -e .env.test.integration -- jest --config ./jest-integration.json --runInBand", 22 | "test:e2e": "dotenv -e .env.test.e2e -- jest --config test/jest-e2e.json --runInBand" 23 | }, 24 | "dependencies": { 25 | "@directus/sdk": "^9.0.0-rc.91", 26 | "@nestjs/common": "^8.0.0", 27 | "@nestjs/config": "^1.0.0", 28 | "@nestjs/core": "^8.0.0", 29 | "@nestjs/platform-express": "^8.0.0", 30 | "@types/date-fns": "^2.6.0", 31 | "@types/mongodb": "^3.6.20", 32 | "@types/uuid": "^8.3.1", 33 | "argon2": "^0.28.2", 34 | "class-transformer": "^0.4.0", 35 | "class-validator": "^0.13.1", 36 | "date-fns": "^2.22.1", 37 | "dotenv-cli": "^4.0.0", 38 | "jimp": "^0.16.1", 39 | "jsonwebtoken": "^8.5.1", 40 | "library": "^0.0.1", 41 | "mongodb": "^3.6.10", 42 | "nestjs-real-ip": "^2.0.0", 43 | "reflect-metadata": "^0.1.13", 44 | "rimraf": "^3.0.2", 45 | "rxjs": "^7.2.0", 46 | "uuid": "^8.3.2" 47 | }, 48 | "devDependencies": { 49 | "@nestjs/cli": "^8.0.0", 50 | "@nestjs/schematics": "^8.0.0", 51 | "@nestjs/testing": "^8.0.0", 52 | "@types/express": "^4.17.13", 53 | "@types/jest": "^26.0.24", 54 | "@types/jsonwebtoken": "^8.5.4", 55 | "@types/node": "^16.0.0", 56 | "@types/supertest": "^2.0.11", 57 | "@typescript-eslint/eslint-plugin": "^4.28.2", 58 | "@typescript-eslint/parser": "^4.28.2", 59 | "eslint": "^7.30.0", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-plugin-prettier": "^3.4.0", 62 | "jest": "^27.0.6", 63 | "prettier": "^2.3.2", 64 | "supertest": "^6.1.3", 65 | "ts-jest": "^27.0.3", 66 | "ts-loader": "^9.2.3", 67 | "ts-node": "^10.0.0", 68 | "tsconfig-paths": "^3.10.1", 69 | "typescript": "^4.3.5" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".*\\.spec\\.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "collectCoverageFrom": [ 83 | "**/*.(t|j)s" 84 | ], 85 | "coverageDirectory": "../coverage", 86 | "testEnvironment": "node" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Required tools 4 | 5 | To run micronation, you will need : 6 | 7 | - [NodeJS](https://nodejs.org/en/) >= 14.17.3 8 | - [Yarn](https://classic.yarnpkg.com/lang/en/) 1.21 as your package manager 9 | - A [MongoDB](https://www.mongodb.com/) database for data storage 10 | 11 | ## Installation 12 | 13 | First, clone the repository with : 14 | 15 | `git clone https://github.com/Sorikairox/micronation` 16 | 17 | 18 | Install all dependencies by using `yarn install` in `back` and `front` folders. 19 | 20 | Copy `.env.example` content to a `.env`, `.env.test.integration` and `.env.test.e2e` file with your own configuration for each case. 21 | 22 | ## Running the apps 23 | 24 | The entire project is meant to be considered as an **experiment**, thus is made to be visited from the **Fouloscopie** plateform. 25 | As a result, expose the **front** server on port `3500` and the back one on port `3000` and then visit https://preprod.fouloscopie.com/experiment/1 26 | 27 | ### Back 28 | Microservices are built with NestJS framework and use their scripts. 29 | 30 | Before running, `library` must be built **at least once** with `tsc --build library/tsconfig.json` (or `cd library && yarn tsc`). 31 | 32 | - `yarn start:dev` to run and watch for changes 33 | - `yarn start` to run the app 34 | - `yarn test` to run base tests 35 | - `yarn test:integration` to run integration tests 36 | - `yarn test:e2e` to run end-to-end tests 37 | 38 | ### Front 39 | 40 | The Nuxt environnement is being used for the front integration, in addition with `yarn` scripts. 41 | 42 | Go to the `front` folder and enter `yarn` to install all the dependencies. 43 | Then, start the dev mode with `yarn dev` or build the entire project with `yarn build`. 44 | 45 | ## Contributing 46 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 47 | 48 | Please make sure to update tests as appropriate. 49 | 50 | ### Naming conventions 51 | 52 | We use [DDD (Domain Driven Design)](https://en.wikipedia.org/wiki/Domain-driven_design) principles. 53 | 54 | #### Ground rules 55 | 56 | - The entrypoint is named `main.ts`. 57 | - Every class must be defined in its own file. 58 | - Files are named after the class they define (PascalCase). Example: `FlagRepository.ts` is expected to contain the definition of the `FlagRepository` class. 59 | - Files are sorted in folders that make a domain structure. Example: `flag/pixel/Pixel.ts` is located in the domain `pixel` which is a subdomain of the domain `flag`. 60 | 61 | #### Tests 62 | 63 | - Test files are named after the class (PascalCase) or domain (kebab-case) they test and end with `.spec.ts`, `.spec-integration.ts` or `.spec-e2e.ts`. Example: `FlagService.spec-integration.ts` is expected to contain all integration tests of the `FlagService` class. 64 | - Test files are located in a `spec` folder inside the domain folder they relate to. Example: all tests of stuff in the domain `flag` are located in `flag/spec/`. 65 | - End to end tests are located in the `test` folder at the root of the module. 66 | 67 | ## License 68 | [unlicense](https://choosealicense.com/licenses/unlicense/) 69 | -------------------------------------------------------------------------------- /front/components/organisms/TheHeader.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 93 | 94 | 99 | -------------------------------------------------------------------------------- /back/flag-service/src/user/spec/UserController.spec.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '@nestjs/common'; 2 | import { UserController } from '../UserController'; 3 | import { UserService } from '../UserService'; 4 | import { JwtService } from '../jwt/JwtService'; 5 | 6 | describe('UserController', () => { 7 | let userController: UserController; 8 | let userService: UserService; 9 | 10 | beforeAll(async () => { 11 | userService = new UserService(null, new JwtService()); 12 | userController = new UserController(userService); 13 | }); 14 | 15 | afterEach(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | 20 | describe('register', () => { 21 | let registerSpy; 22 | let res; 23 | beforeAll(async () => { 24 | registerSpy = jest 25 | .spyOn(userService, 'register') 26 | .mockReturnValue({ fake: true } as any); 27 | res = await userController.register({ 28 | email: 'user@example.com', 29 | password: 'password', 30 | passwordConfirmation: 'password', 31 | nickname: 'jane', 32 | }); 33 | }); 34 | it('calls register from service', () => { 35 | expect(registerSpy).toBeCalledTimes(1); 36 | }); 37 | it('returns register return value', () => { 38 | expect(res).toStrictEqual({ success: true }); 39 | }); 40 | }); 41 | 42 | describe('login', () => { 43 | let loginSpy; 44 | let res; 45 | beforeAll(async () => { 46 | loginSpy = jest 47 | .spyOn(userService, 'login') 48 | .mockReturnValue({ fake: true } as any); 49 | res = await userController.login({ email: 'user@example.com', password: 'password123' }); 50 | }); 51 | it('calls login from service', () => { 52 | expect(loginSpy).toBeCalledTimes(1); 53 | }); 54 | it('returns login return value', () => { 55 | expect(res).toStrictEqual({ fake: true }); 56 | }); 57 | }); 58 | 59 | describe('changePassword', () => { 60 | let changePasswordSpy; 61 | let res; 62 | beforeAll(async () => { 63 | changePasswordSpy = jest 64 | .spyOn(userService, 'changePassword') 65 | .mockReturnValue({ fake: true } as any); 66 | res = await userController.changePassword({ userId: '' } as Request, { 67 | currentPassword: 'oldpassword135', 68 | newPassword: 'password123', 69 | newPasswordConfirmation: 'password123', 70 | }); 71 | }); 72 | it('calls changePassword from service', () => { 73 | expect(changePasswordSpy).toBeCalledTimes(1); 74 | }); 75 | it('returns changePassword return value', () => { 76 | expect(res).toStrictEqual({ success: true }); 77 | }); 78 | }); 79 | 80 | describe('changeNickname', () => { 81 | let changeNicknameSpy; 82 | let res; 83 | beforeAll(async () => { 84 | changeNicknameSpy = jest 85 | .spyOn(userService, 'changeNickname') 86 | .mockReturnValue({ nickname: 'jane2' } as any); 87 | res = await userController.changeNickname({ userId: '' } as Request, { 88 | password: 'password123', 89 | newNickname: 'jane2', 90 | }); 91 | }); 92 | it('calls changeNickname from service', () => { 93 | expect(changeNicknameSpy).toBeCalledTimes(1); 94 | }); 95 | it('returns changeNickname return value', () => { 96 | expect(res).toStrictEqual({ nickname: 'jane2' }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/pixel/spec/FlagSnapshotPixelService.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { DatabaseModule } from 'library/database/DatabaseModule'; 4 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 5 | import { FlagSnapshotPixelService } from '../FlagSnapshotPixelService'; 6 | import { FlagSnapshotPixelRepository } from '../FlagSnapshotPixelRepository'; 7 | 8 | let flagSnapshotPixelService: FlagSnapshotPixelService; 9 | let flagSnapshotPixelRepository: FlagSnapshotPixelRepository; 10 | let dbClientService: DatabaseClientService; 11 | let app : TestingModule; 12 | 13 | async function clean() { 14 | await dbClientService 15 | .getDb() 16 | .collection(flagSnapshotPixelRepository.getCollectionName()) 17 | .deleteMany({}); 18 | } 19 | 20 | async function init() { 21 | app = await Test.createTestingModule({ 22 | imports: [ 23 | ConfigModule.forRoot({ isGlobal: true }), 24 | DatabaseModule.register({ 25 | uri: process.env.DATABASE_URI, 26 | dbName: 'testDb', 27 | }), 28 | ], 29 | controllers: [], 30 | providers: [FlagSnapshotPixelService, FlagSnapshotPixelRepository], 31 | }).compile(); 32 | flagSnapshotPixelService = app.get(FlagSnapshotPixelService); 33 | flagSnapshotPixelRepository = app.get(FlagSnapshotPixelRepository); 34 | dbClientService = app.get('DATABASE_CLIENT'); 35 | await dbClientService.onModuleInit(); 36 | } 37 | 38 | describe('FlagSnapshotPixelService', () => { 39 | 40 | beforeAll(async () => { 41 | await init(); 42 | }); 43 | 44 | afterAll(async () => { 45 | await app.close(); 46 | await dbClientService.client.close(); 47 | }); 48 | 49 | afterEach(async () => { 50 | await app.close(); 51 | await init(); 52 | await clean(); 53 | }); 54 | 55 | describe('saveSnapshotPixels', () => { 56 | it('save snapshot pixel to DB', async () => { 57 | await flagSnapshotPixelService.saveSnapshotPixels('snapshotId', [{ 58 | pixelData: 'data', 59 | } as any]); 60 | const savedPixels = await flagSnapshotPixelRepository.find({ 61 | snapshotId: 'snapshotId', 62 | }); 63 | expect(savedPixels.length).toEqual(1); 64 | expect(savedPixels[0].snapshotId).toEqual('snapshotId'); 65 | }); 66 | }); 67 | 68 | 69 | describe('getSnapshotPixels', () => { 70 | it('get snapshot pixels from DB', async () => { 71 | await flagSnapshotPixelRepository.createMany([{ 72 | snapshotId: 'snapshotId', 73 | entityId: 'entityId1', 74 | hexColor: '#FFFFFF', 75 | author: 'author', 76 | createdAt: new Date(), 77 | indexInFlag: 1, 78 | }, 79 | { 80 | snapshotId: 'snapshotId', 81 | entityId: 'entityId2', 82 | hexColor: '#FFFFFF', 83 | author: 'author2', 84 | createdAt: new Date(), 85 | indexInFlag: 2, 86 | }, 87 | { 88 | snapshotId: 'snapshotId2', 89 | entityId: 'entityId2', 90 | hexColor: '#FFFFFF', 91 | author: 'author2', 92 | createdAt: new Date(), 93 | indexInFlag: 2, 94 | }]); 95 | const snapshotPixels = await flagSnapshotPixelService.getSnapshotPixels('snapshotId'); 96 | expect(snapshotPixels.length).toEqual(2); 97 | expect(snapshotPixels[0].indexInFlag).toEqual(1); 98 | }); 99 | }); 100 | 101 | }); 102 | -------------------------------------------------------------------------------- /back/flag-service/src/user/jwt/spec/JwtService.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from "../JwtService"; 2 | import jwt from "jsonwebtoken"; 3 | import { InvalidJsonWebTokenError } from "../../../authentication/errors/InvalidJsonWebTokenError"; 4 | import { ExpiredJsonWebTokenError } from "../../../authentication/errors/ExpiredJsonWebTokenError"; 5 | 6 | let secret; 7 | beforeAll(() => { 8 | secret = process.env.JWT_SECRET; 9 | }); 10 | 11 | afterAll(() => { 12 | process.env.JWT_SECRET = secret; 13 | }); 14 | 15 | describe('JwtService', () => { 16 | const jwtService = new JwtService(); 17 | 18 | beforeAll(async () => { 19 | process.env.JWT_SECRET = 'validvalueforsecret'; 20 | await jwtService.onModuleInit(); 21 | }); 22 | 23 | describe('onModuleInit', () => { 24 | let jwtService; 25 | beforeEach(() => { 26 | jwtService = new JwtService(); 27 | }); 28 | 29 | it('should throw on undefined', async () => { 30 | delete process.env.JWT_SECRET; 31 | await expect(jwtService.onModuleInit).rejects; 32 | }); 33 | 34 | it('should throw on empty', async () => { 35 | process.env.JWT_SECRET = ''; 36 | await expect(jwtService.onModuleInit).rejects; 37 | }); 38 | 39 | it('should throw on "changeme"', async () => { 40 | process.env.JWT_SECRET = 'changeme'; 41 | await expect(jwtService.onModuleInit).rejects; 42 | }); 43 | 44 | it('should not throw with sane value', async () => { 45 | process.env.JWT_SECRET = 'rBelDaTOxeiyBDnQsChz7xA5a7XnNdDtkuqJlyirJQldY9TlBwHk5fLd34x6gpvFAupZjRXLXd6aBVj2KEOvjShQUpaFO7PNZl2vyDA2QObw6TwSXngeHsOUnP1zmony'; 46 | await expect(jwtService.onModuleInit).resolves; 47 | }); 48 | }); 49 | 50 | describe('sign', function () { 51 | let token; 52 | beforeAll(async () => { 53 | token = await jwtService.sign({ some: 'data' }, '15 days'); 54 | }); 55 | 56 | it('returns a non-empty string', async () => { 57 | expect(token).toBeDefined(); 58 | expect(token.length).toBeGreaterThan(0); 59 | }); 60 | 61 | it('signs with the right key', async () => { 62 | expect(jwt.verify(token, process.env.JWT_SECRET)).toBeTruthy(); 63 | }); 64 | 65 | it('outputs containing the input data', async () => { 66 | expect(jwt.decode(token)).toMatchObject({ some: 'data' }); 67 | }); 68 | 69 | it('adds jti, iat and exp standard fields', async () => { 70 | const payload = jwt.decode(token); 71 | expect(payload).toHaveProperty('jti'); 72 | expect(payload).toHaveProperty('iat'); 73 | expect(payload).toHaveProperty('exp'); 74 | }); 75 | }); 76 | 77 | describe('verify', function () { 78 | it('returns the right payload when valid', async () => { 79 | const payload = { 80 | jti: 'id', 81 | exp: Math.floor(Date.now() / 1000) + 10, // in 10s 82 | iat: Math.floor(Date.now() / 1000), 83 | }; 84 | const token = jwt.sign(payload, process.env.JWT_SECRET); 85 | await expect(jwtService.verify(token)).resolves.toStrictEqual(payload); 86 | }); 87 | 88 | it('throws when token was signed with a different key', async () => { 89 | const token = jwt.sign({ some: 'data' }, 'not the right key'); 90 | await expect(jwtService.verify(token)).rejects.toThrow(InvalidJsonWebTokenError); 91 | }); 92 | 93 | it('throws when token has expired', async () => { 94 | const token = jwt.sign({ 95 | jti: 'id', 96 | exp: Math.floor(Date.now() / 1000) - 1, // 1s ago 97 | }, process.env.JWT_SECRET); 98 | await expect(jwtService.verify(token)).rejects.toThrow(ExpiredJsonWebTokenError); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /front/components/atoms/AppButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 106 | 107 | 119 | -------------------------------------------------------------------------------- /back/flag-service/src/user/UserService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { UserRepository } from "./UserRepository"; 3 | import { User } from "./User"; 4 | import argon2 from "argon2"; 5 | import { EmailAlreadyTakenError } from "./errors/EmailAlreadyTakenError"; 6 | import { NicknameAlreadyTakenError } from "./errors/NicknameAlreadyTakenError"; 7 | import { EmailNotFoundError } from "./errors/EmailNotFoundError"; 8 | import { IncorrectPasswordError } from "./errors/IncorrectPasswordError"; 9 | import { JwtService } from "./jwt/JwtService"; 10 | import { UserIdNotFoundError } from "./errors/UserIdNotFoundError"; 11 | import { ObjectID } from "mongodb"; 12 | 13 | @Injectable() 14 | export class UserService { 15 | constructor( 16 | private readonly userRepository: UserRepository, 17 | private readonly jwtService: JwtService, 18 | ) { 19 | } 20 | 21 | async register(email: string, password: string, nickname: string): Promise { 22 | const existingUserWithEmail = await this.userRepository.findOne({ email: email }); 23 | if (existingUserWithEmail) { 24 | throw new EmailAlreadyTakenError('email', email); 25 | } 26 | 27 | const existingUserWithNickname = await this.userRepository.findOne({ nickname: nickname }); 28 | if (existingUserWithNickname) { 29 | throw new NicknameAlreadyTakenError('nickname', nickname); 30 | } 31 | 32 | return await this.userRepository.createAndReturn({ 33 | email: email, 34 | password: await argon2.hash(password), 35 | nickname: nickname, 36 | }); 37 | } 38 | 39 | async login(email: string, password: string): Promise<{ user: User, jwt: string }> { 40 | const user = await this.userRepository.findOne({ email: email }); 41 | if (!user) { 42 | throw new EmailNotFoundError('email', email); 43 | } 44 | 45 | if (!await argon2.verify(user.password, password)) { 46 | throw new IncorrectPasswordError('password'); 47 | } 48 | 49 | return { 50 | user: user, 51 | jwt: await this.jwtService.sign({ 52 | sub: user._id.toHexString(), 53 | userData: { 54 | _id: user._id.toHexString(), 55 | email: user.email, 56 | nickname: user.nickname, 57 | createdAt: user.createdAt.toISOString(), 58 | }, 59 | }, '15 days'), 60 | }; 61 | } 62 | 63 | async changePassword(userId: string | ObjectID, currentPassword: string, newPassword: string): Promise { 64 | const user = await this.userRepository.findOne({ _id: new ObjectID(userId) }); 65 | if (!user) { 66 | throw new UserIdNotFoundError(userId); 67 | } 68 | 69 | if (!await argon2.verify(user.password, currentPassword)) { 70 | throw new IncorrectPasswordError('currentPassword'); 71 | } 72 | 73 | return await this.userRepository.updateAndReturnOne( 74 | { _id: user._id }, 75 | { password: await argon2.hash(newPassword) }, 76 | ); 77 | } 78 | 79 | async changeNickname(userId: string | ObjectID, password: string, newNickname: string): Promise { 80 | const user = await this.userRepository.findOne({ _id: new ObjectID(userId) }); 81 | if (!user) { 82 | throw new UserIdNotFoundError(userId); 83 | } 84 | 85 | if (!await argon2.verify(user.password, password)) { 86 | throw new IncorrectPasswordError('password'); 87 | } 88 | 89 | const existingUserWithNickname = await this.userRepository.findOne({ nickname: newNickname }); 90 | if (existingUserWithNickname) { 91 | throw new NicknameAlreadyTakenError('newNickname', newNickname); 92 | } 93 | 94 | return await this.userRepository.updateAndReturnOne( 95 | { _id: user._id }, 96 | { nickname: newNickname }, 97 | ); 98 | } 99 | } 100 | 101 | export type UserDataJwtPayload = { 102 | userData: { 103 | _id: string, 104 | email: string, 105 | nickname: string, 106 | createdAt: string, 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/FlagService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 3 | import { PixelDoesNotExistError } from './errors/PixelDoesNotExistError'; 4 | import { GetPixelDto } from './pixel/dto/GetPixelDto'; 5 | import { Pixel } from './pixel/Pixel'; 6 | import { PixelRepository } from './pixel/PixelRepository'; 7 | import { differenceInMilliseconds } from 'date-fns'; 8 | import { UserAlreadyOwnsAPixelError } from "./errors/UserAlreadyOwnsAPixelError"; 9 | import { UserActionIsOnCooldownError } from "./errors/UserActionIsOnCooldownError"; 10 | import { FlagSnapshotService } from './snapshot/FlagSnapshotService'; 11 | 12 | @Injectable() 13 | export class FlagService { 14 | constructor(private pixelRepository: PixelRepository, private flagSnapshotService: FlagSnapshotService) { 15 | } 16 | 17 | 18 | async addPixel(ownerId: string, hexColor = '#FFFFFF') { 19 | const pixelUserAlreadyOwn = await this.pixelRepository.findOne({ 20 | author: ownerId, 21 | action: 'creation', 22 | }); 23 | if (pixelUserAlreadyOwn) { 24 | throw new UserAlreadyOwnsAPixelError(); 25 | } 26 | const pixel = new Pixel(ownerId, hexColor); 27 | const createdEvent = await this.pixelRepository.createAndReturn({ 28 | action: 'creation', 29 | author: ownerId, 30 | entityId: pixel.pixId, 31 | data: { ...pixel }, 32 | }); 33 | this.flagSnapshotService.createSnapshotIfEventIdMeetThreshold(createdEvent.eventId); 34 | return createdEvent; 35 | } 36 | 37 | async changePixelColor(performingUserId: string, pixelId: string, hexColor: string, ip: string, useragent: string) { 38 | const lastUserAction = await this.pixelRepository.findLastByDate({ 39 | author: performingUserId, 40 | }); 41 | const lastPixelEvent = await this.pixelRepository.findLastByDate({ 42 | entityId: pixelId, 43 | }); 44 | if (!lastPixelEvent) { 45 | throw new PixelDoesNotExistError(); 46 | } 47 | 48 | const changeCooldownInMilliseconds = Number(process.env.CHANGE_COOLDOWN) * 60 * 1000; 49 | await this.checkUserIsNotOnCooldown(lastUserAction, changeCooldownInMilliseconds); 50 | 51 | const createdEvent = await this.pixelRepository.createAndReturn({ 52 | action: 'update', 53 | author: performingUserId, 54 | entityId: lastPixelEvent.entityId, 55 | data: { ...lastPixelEvent.data, hexColor }, 56 | ip, 57 | useragent, 58 | }); 59 | this.flagSnapshotService.createSnapshotIfEventIdMeetThreshold(createdEvent.eventId); 60 | return createdEvent; 61 | } 62 | 63 | async checkUserIsNotOnCooldown(lastUserAction: DatabaseEvent | null, cooldownTimeInMs: number) { 64 | if (lastUserAction) { 65 | const timeSinceLastUserAction = differenceInMilliseconds(new Date(), lastUserAction.createdAt); 66 | const remainingTime = cooldownTimeInMs - timeSinceLastUserAction; 67 | if (lastUserAction.action === 'update' && remainingTime > 0) { 68 | throw new UserActionIsOnCooldownError(remainingTime); 69 | } 70 | } 71 | } 72 | 73 | async getFlag(): Promise { 74 | const latestSnapshot = await this.flagSnapshotService.getLatestSnapshot(); 75 | if (!latestSnapshot) { 76 | return this.pixelRepository.getPixels(); 77 | } else { 78 | const arrayPixelNotInSnapshot = await this.pixelRepository.getPixelsAfterEventId(latestSnapshot.lastEventId); 79 | return this.flagSnapshotService.mergeArray(latestSnapshot.pixels, arrayPixelNotInSnapshot); 80 | } 81 | } 82 | 83 | async getFlagAtDate(date: Date): Promise { 84 | return this.pixelRepository.getPixelsAtDate(date); 85 | } 86 | 87 | async getFlagAfterDate(from: Date): Promise { 88 | return this.pixelRepository.getPixelsAfterDate(from); 89 | } 90 | 91 | async getOrCreateUserPixel(userId: string) { 92 | let userPixel = await this.pixelRepository.findLastByEventId({ 93 | author: userId, 94 | action: 'creation', 95 | }); 96 | if (!userPixel) { 97 | userPixel = await this.addPixel(userId); 98 | } 99 | return this.pixelRepository.getPixelById(userPixel.entityId); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/FlagSnapshotService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { GetPixelDto } from '../pixel/dto/GetPixelDto'; 4 | import { PixelRepository } from '../pixel/PixelRepository'; 5 | import { FlagSnapshotPixelService } from './pixel/FlagSnapshotPixelService'; 6 | import { FlagSnapshot } from './FlagSnapshot'; 7 | import { FlagSnapshotRepository } from './FlagSnapshotRepository'; 8 | import { FlagSnapshotDto } from "./dto/FlagSnapshotDto"; 9 | 10 | @Injectable() 11 | export class FlagSnapshotService { 12 | private lastSnapshot: FlagSnapshotDto; 13 | 14 | constructor(private snapshotRepository: FlagSnapshotRepository, private configService: ConfigService, private pixelRepository: PixelRepository, private snapshotPixelService: FlagSnapshotPixelService) {} 15 | async getLatestSnapshot(): Promise { 16 | if (!this.lastSnapshot) { 17 | const latestSnapshot = await this.snapshotRepository.findLastByDate({ complete: true }); 18 | if (latestSnapshot) { 19 | await this.setLastSnapshotValueWithLatestSnapshot(latestSnapshot); 20 | } 21 | return this.lastSnapshot; 22 | } else { 23 | const latestSnapshot = await this.snapshotRepository.findLastByDate({ complete: true, lastEventId: { $gt : this.lastSnapshot.lastEventId } }); 24 | if (latestSnapshot) { 25 | await this.setLastSnapshotValueWithLatestSnapshot(latestSnapshot); 26 | } 27 | return this.lastSnapshot; 28 | } 29 | } 30 | 31 | async setLastSnapshotValueWithLatestSnapshot(latestSnapshot: FlagSnapshot) { 32 | let latestSnapshotPixels; 33 | if (latestSnapshot.pixels) { 34 | latestSnapshotPixels = latestSnapshot.pixels; 35 | } else { 36 | latestSnapshotPixels = await this.snapshotPixelService.getSnapshotPixels(latestSnapshot._id.toHexString()); 37 | } 38 | if (latestSnapshotPixels.length > 0) { 39 | this.lastSnapshot = { 40 | ...latestSnapshot, 41 | _id: latestSnapshot._id.toHexString(), 42 | pixels: latestSnapshotPixels, 43 | }; 44 | } 45 | } 46 | 47 | async createSnapshotIfEventIdMeetThreshold(eventId: number) { 48 | if (eventId % this.configService.get('EVENTS_PER_SNAPSHOT') === 0) { 49 | return this.createSnapshot(eventId); 50 | } 51 | } 52 | 53 | async createSnapshot(snapshotLastEventId: number) { 54 | const pixelArray = await this.getPixelsForSnapshot(snapshotLastEventId); 55 | const createdSnapshot = await this.createNewEmptySnapshot(snapshotLastEventId); 56 | const pixels = await this.snapshotPixelService.saveSnapshotPixels(createdSnapshot._id.toHexString(), pixelArray); 57 | await this.snapshotRepository.updateAndReturnOne({ _id: createdSnapshot._id }, { complete: true }); 58 | return { ...createdSnapshot, pixels }; 59 | } 60 | 61 | public async createNewEmptySnapshot(lastEventId: number): Promise { 62 | return this.snapshotRepository.createAndReturn({ lastEventId: lastEventId, complete: false }); 63 | } 64 | 65 | public async getPixelsForSnapshot(lastEventId: number): Promise { 66 | const previousSnapshot = await this.snapshotRepository.findLastByDate({}); 67 | let pixelArray; 68 | if (!previousSnapshot) { 69 | pixelArray = await this.pixelRepository.getPixelsUntilEventId(lastEventId); 70 | } else { 71 | let lastSnapshotPixel; 72 | if (previousSnapshot.pixels) { 73 | lastSnapshotPixel = previousSnapshot.pixels; 74 | } else { 75 | lastSnapshotPixel = await this.snapshotPixelService.getSnapshotPixels(previousSnapshot._id.toHexString()); 76 | } 77 | const arrayPixelNotInSnapshot = await this.pixelRepository.getPixelsBetweenEventIds(previousSnapshot.lastEventId, lastEventId); 78 | pixelArray = this.mergeArray(lastSnapshotPixel, arrayPixelNotInSnapshot); 79 | } 80 | return pixelArray; 81 | } 82 | 83 | mergeArray(baseDataArray, newDataArray) { 84 | const returnedArray = baseDataArray; 85 | const indexInFlagToLocalIndexMap = {}; 86 | for (let i = 0; i < baseDataArray.length; i++) { 87 | indexInFlagToLocalIndexMap[baseDataArray[i].indexInFlag] = i; 88 | } 89 | for (const pixel of newDataArray) { 90 | const localIndex = indexInFlagToLocalIndexMap[pixel.indexInFlag]; 91 | if (localIndex == null) { 92 | returnedArray.push(pixel); 93 | indexInFlagToLocalIndexMap[pixel.indexInFlag] = returnedArray.length - 1; 94 | } else { 95 | returnedArray[indexInFlagToLocalIndexMap[pixel.indexInFlag]].hexColor = pixel.hexColor; 96 | } 97 | } 98 | return returnedArray; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /front/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "jit", 3 | purge: ["./public/**/*.html", "./src/**/*.{js,jsx,ts,tsx,vue}"], 4 | darkMode: false, // or 'media' or 'class' 5 | theme: { 6 | colors: { 7 | primary: { 8 | base: "#1672EC", 9 | dark: "#0F56B3", 10 | light: "#8AB9F6", 11 | }, 12 | grey: { 13 | base: "#839199", 14 | dark: "#142130", 15 | light: "#DCDEE4", 16 | }, 17 | white: "#FAFBFF", 18 | black: "#17161A", 19 | negative: { 20 | base: "#D52941", 21 | dark: "#9C001B", 22 | light: "#FAC8C8", 23 | }, 24 | positive: { 25 | base: "#26CF7D", 26 | dark: "#009D50", 27 | light: "#C8FADC", 28 | }, 29 | }, 30 | extend: { 31 | screens: { 32 | sm: "640px", 33 | // => @media (min-width: 640px) { ... } 34 | 35 | md: "768px", 36 | // => @media (min-width: 768px) { ... } 37 | 38 | lg: "1024px", 39 | // => @media (min-width: 1024px) { ... } 40 | 41 | xl: "1280px", 42 | // => @media (min-width: 1280px) { ... } 43 | 44 | "2xl": "1536px", 45 | // => @media (min-width: 1536px) { ... } 46 | }, 47 | fontFamily: { 48 | montserrat: ["Montserrat"], 49 | roboto: ["Roboto"], 50 | }, 51 | typography: { 52 | montserrat: { 53 | css: { 54 | fontFamily: "Montserrat", 55 | h1: { 56 | fontWeight: "bold", 57 | fontSize: 96, 58 | }, 59 | h2: { 60 | fontWeight: "bold", 61 | fontSize: 60, 62 | }, 63 | h3: { 64 | fontWeight: "bold", 65 | fontSize: 48, 66 | }, 67 | h4: { 68 | fontWeight: "bold", 69 | fontSize: 34, 70 | }, 71 | h5: { 72 | fontWeight: "bold", 73 | fontSize: 24, 74 | }, 75 | h6: { 76 | fontWeight: "bold", 77 | fontSize: 20, 78 | }, 79 | ".body-1": { 80 | fontSize: 16, 81 | lineHeight: "24px", 82 | letterSpacing: "0.01em", 83 | }, 84 | ".body-2": { 85 | fontSize: 14, 86 | lineHeight: "20px", 87 | letterSpacing: "0.005em", 88 | }, 89 | ".subtitle": { 90 | fontWeight: 500, //medium 91 | fontSize: 14, 92 | letterSpacing: "0.1px", 93 | lineHeight: "24px", 94 | }, 95 | ".button": { 96 | fontWeight: 500, //medium 97 | fontSize: 14, 98 | letterSpacing: "0.5px", 99 | textTransform: "uppercase", 100 | lineHeight: "17px", 101 | }, 102 | }, 103 | }, 104 | roboto: { 105 | css: { 106 | fontFamily: "Roboto", 107 | h1: { 108 | fontWeight: "bold", 109 | fontSize: 96, 110 | }, 111 | h2: { 112 | fontWeight: "bold", 113 | fontSize: 60, 114 | }, 115 | h3: { 116 | fontWeight: "bold", 117 | fontSize: 48, 118 | }, 119 | h4: { 120 | fontWeight: "bold", 121 | fontSize: 34, 122 | }, 123 | h5: { 124 | fontWeight: "bold", 125 | fontSize: 24, 126 | }, 127 | h6: { 128 | fontWeight: "bold", 129 | fontSize: 20, 130 | }, 131 | ".body-1": { 132 | fontSize: 16, 133 | lineHeight: "19px", 134 | letterSpacing: "0.01em", 135 | }, 136 | ".body-2": { 137 | fontSize: 14, 138 | lineHeight: "20px", 139 | letterSpacing: "0.005em", 140 | }, 141 | ".subtitle": { 142 | fontWeight: 500, //medium 143 | fontSize: 14, 144 | letterSpacing: "0.1px", 145 | lineHeight: "24px", 146 | }, 147 | ".button": { 148 | fontWeight: 500, //medium 149 | fontSize: 14, 150 | letterSpacing: "0.5px", 151 | textTransform: "uppercase", 152 | lineHeight: "16px", 153 | }, 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | variants: { extends: {} }, 160 | plugins: [require("@tailwindcss/typography")], 161 | }; 162 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/pixel/PixelRepository.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseRepository } from 'library/database/repository/DatabaseRepository'; 2 | import { GetPixelDto } from './dto/GetPixelDto'; 3 | import { Pixel } from './Pixel'; 4 | import { Inject, Injectable } from '@nestjs/common'; 5 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 6 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 7 | 8 | @Injectable() 9 | export class PixelRepository extends DatabaseRepository> { 10 | constructor(@Inject('DATABASE_CLIENT') dbClient: DatabaseClientService) { 11 | super(dbClient, 'pixel-events'); 12 | } 13 | 14 | async getPixels() { 15 | return this.getCollection().aggregate(this.getPixelAggregation()).toArray(); 16 | } 17 | 18 | async createAndReturn(data: DatabaseEvent): Promise> { 19 | if (data.action === 'creation') { 20 | data.data.indexInFlag = (await this.dbClient.getDb().collection('counter').findOneAndUpdate({ name: 'pixelCounter' }, { $inc: { counter: 1 } }, { 21 | upsert: true, 22 | returnDocument: 'after', 23 | })).value.counter; 24 | } 25 | data.eventId = (await this.dbClient.getDb().collection('counter').findOneAndUpdate({ name: 'pixelEventCounter' }, { $inc: { counter: 1 } }, { 26 | upsert: true, 27 | returnDocument: 'after', 28 | })).value.counter 29 | return super.createAndReturn(data); 30 | } 31 | 32 | async getPixelById(pixelId: string) { 33 | const aggregation = this.getPixelAggregation(); 34 | aggregation.unshift({ 35 | $match: { 36 | entityId: pixelId, 37 | }, 38 | }); 39 | const result = await this.getCollection().aggregate(aggregation).toArray(); 40 | return result[result.length - 1]; 41 | } 42 | 43 | async getUserPixel(userId: string) { 44 | const aggregation = this.getPixelAggregation(); 45 | aggregation.unshift({ 46 | $match: { 47 | author: userId, 48 | }, 49 | }); 50 | const result = await this.getCollection().aggregate(aggregation).toArray(); 51 | return result[result.length - 1]; 52 | } 53 | 54 | async getPixelsAtDate(date: Date) { 55 | const aggregation = this.getPixelAggregation(); 56 | aggregation.unshift({ 57 | $match: { 58 | createdAt: { $lte: date }, 59 | }, 60 | }); 61 | return this.getCollection().aggregate(aggregation).toArray(); 62 | } 63 | 64 | async getPixelsBetweenEventIds(from: number, to: number): Promise { 65 | const aggregation = this.getPixelAggregation(); 66 | aggregation.unshift({ 67 | $match: { eventId: { $lte: to, $gt : from }, 68 | }, 69 | }); 70 | return this.getCollection().aggregate(aggregation).toArray(); 71 | } 72 | 73 | async getPixelsAfterEventId(eventId: number) { 74 | const aggregation = this.getPixelAggregation(); 75 | aggregation.unshift({ 76 | $match: { 77 | eventId: { $gt: eventId }, 78 | }, 79 | }); 80 | return this.getCollection().aggregate(aggregation).toArray(); 81 | } 82 | 83 | async getPixelsUntilEventId(eventId: number) { 84 | const aggregation = this.getPixelAggregation(); 85 | aggregation.unshift({ 86 | $match: { 87 | eventId: { $lte: eventId }, 88 | }, 89 | }); 90 | return this.getCollection().aggregate(aggregation).toArray(); 91 | } 92 | 93 | async getPixelsAfterDate(from: Date) { 94 | const aggregation = this.getPixelAggregation(); 95 | aggregation.unshift({ 96 | $match: { 97 | createdAt: { $gte: from }, 98 | }, 99 | }); 100 | return this.getCollection().aggregate(aggregation).toArray(); 101 | } 102 | 103 | private getPixelAggregation(): Array { 104 | return [ 105 | { 106 | $match: { 107 | ignored: { 108 | $ne: true, 109 | }, 110 | }, 111 | }, 112 | { 113 | $sort: { eventId: 1 }, 114 | }, 115 | { 116 | $group: { 117 | _id: '$entityId', 118 | pixelDetails: { 119 | $mergeObjects: '$data', 120 | }, 121 | author: { 122 | $first: '$author', 123 | }, 124 | }, 125 | }, 126 | { 127 | $replaceRoot: { 128 | newRoot: { 129 | $mergeObjects: [ 130 | { 131 | entityId: '$_id', 132 | author: '$author', 133 | }, 134 | '$pixelDetails', 135 | ], 136 | }, 137 | }, 138 | }, 139 | { 140 | $project: { 141 | entityId: 1, 142 | hexColor: 1, 143 | author: 1, 144 | indexInFlag: 1, 145 | }, 146 | }, 147 | { 148 | $sort: { 149 | indexInFlag: 1, 150 | }, 151 | }, 152 | ]; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /back/library/database/repository/spec/DatabaseRepository.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseClientService } from '../../client/DatabaseClientService'; 2 | import { DatabaseRepository } from "../DatabaseRepository"; 3 | import { Db } from "mongodb"; 4 | 5 | let testCollectionName = 'testCollection'; 6 | 7 | describe('Database Repository', () => { 8 | let databaseClientService: DatabaseClientService; 9 | let databaseRepository: DatabaseRepository; 10 | let db: Db; 11 | 12 | beforeAll(async () => { 13 | databaseClientService = new DatabaseClientService({ 14 | uri: process.env.DATABASE_URI, 15 | dbName: 'testDb' 16 | }); 17 | databaseRepository = new DatabaseRepository(databaseClientService, testCollectionName); 18 | await databaseClientService.onModuleInit(); 19 | db = databaseClientService.getDb(); 20 | }); 21 | beforeEach(async () => { 22 | await db.collection(testCollectionName).deleteMany({}); 23 | }) 24 | afterAll(async () => { 25 | await databaseClientService.client.close(); 26 | }); 27 | describe('createAndReturn', () => { 28 | let res; 29 | let dbObject; 30 | beforeAll(async () => { 31 | res = await databaseRepository.createAndReturn({fakeData: true, randomId: 'bestId'}); 32 | dbObject = await db.collection(testCollectionName).findOne({randomId: 'bestId'}); 33 | }); 34 | it ('create object in db', async () => { 35 | expect(dbObject).toBeDefined(); 36 | expect(dbObject.randomId).toEqual('bestId'); 37 | }); 38 | it ('set createdAt field to creation date', () => { 39 | expect(dbObject.createdAt).toBeInstanceOf(Date); 40 | }); 41 | it ('returns created object', () => { 42 | expect(res).toEqual(dbObject); 43 | }) 44 | }); 45 | describe('findOne', () => { 46 | it('find an object based on filters', async () => { 47 | await db.collection(testCollectionName).insertOne({criteria1 : 'coolCriteria'}); 48 | let obj = await databaseRepository.findOne({criteria1: 'coolCriteria'}); 49 | expect(obj.criteria1).toEqual('coolCriteria'); 50 | }); 51 | }); 52 | describe('updateAndReturnOne', () => { 53 | it('update an object based on filters', async () => { 54 | await db.collection(testCollectionName).insertOne({attributeToModify : 'initialValue'}); 55 | let obj = await databaseRepository.updateAndReturnOne({attributeToModify: 'initialValue'}, {attributeToModify: 'newValue'}); 56 | expect(obj.attributeToModify).toEqual('newValue'); 57 | }); 58 | }); 59 | 60 | describe('updateAndReturnMany', () => { 61 | it('update many objects based on filters', async () => { 62 | await db.collection(testCollectionName).insertMany([{attributeToModify : 'initialValue', commonAttribute: 'author', untouchedAttribute: 'untouchedValue'}, {attributeToModify : 'initialValue2', commonAttribute: 'author', untouchedAttribute: 'untouchedValue'}, {attributeToModify : 'initialValue', commonAttribute: 'author2'}]); 63 | const modifiedNumber = await databaseRepository.updateAndReturnMany({commonAttribute: 'author'}, {attributeToModify: 'newValue'}); 64 | expect(modifiedNumber).toEqual(2); 65 | const objs = await db.collection(testCollectionName).find({commonAttribute: 'author'}).toArray(); 66 | objs.map(obj => { 67 | expect(obj.commonAttribute).toEqual('author'); 68 | expect(obj.untouchedAttribute).toEqual('untouchedValue'); 69 | expect(obj.attributeToModify).toEqual('newValue'); 70 | }) 71 | }); 72 | }); 73 | 74 | describe('find', () => { 75 | it('find many object based on filters', async () => { 76 | await db.collection(testCollectionName).insertMany([{firstObject : 'initialValue'}, {secondObject: 'whocare'}]); 77 | const objList = await databaseRepository.find({}); 78 | expect(objList.length).toEqual(2); 79 | }); 80 | it('find many object based on filters and sort', async () => { 81 | await db.collection(testCollectionName).insertMany([{firstObject : 'initialValue', index: 2}, {secondObject: 'whocare', index: 1}]); 82 | const objList = await databaseRepository.find({}, {index: 1}); 83 | expect(objList.length).toEqual(2); 84 | expect(objList[0].index).toEqual(1); 85 | expect(objList[1].index).toEqual(2); 86 | }); 87 | it('find many and return given fields', async () => { 88 | await db.collection(testCollectionName).insertMany([{value : 'initialValue', index: 2, uselessField: true}, {value: 'whocare', index: 1, uselessField: true }]); 89 | const objList = await databaseRepository.find({}, {index: 1}, { uselessField: 0 }); 90 | expect(objList.length).toEqual(2); 91 | expect(objList[0].uselessField).toBe(undefined); 92 | expect(objList[0].value).toEqual('whocare'); 93 | expect(objList[0].index).toEqual(1); 94 | expect(objList[1].uselessField).toBe(undefined); 95 | }); 96 | }); 97 | describe('createMany', () => { 98 | it('create many object', async () => { 99 | await databaseRepository.createMany([{firstObject : 'initialValue'}, {secondObject: 'whocare'}]); 100 | const objList = await db.collection(testCollectionName).find({}).toArray(); 101 | expect(objList.length).toEqual(2); 102 | }); 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /back/flag-service/src/user/guards/spec/FouloscopieAuthGuard.spec.ts: -------------------------------------------------------------------------------- 1 | import { FouloscopieAuthGuard } from "../FouloscopieAuthGuard"; 2 | import { ExecutionContext } from "@nestjs/common"; 3 | import { InvalidDirectusTokenError } from "../../errors/InvalidDirectusTokenError"; 4 | import * as DirectusModule from "@directus/sdk"; 5 | import { AuthToken, Directus, PartialItem, QueryOne, TypeOf, UserItem } from "@directus/sdk"; 6 | import { Reflector } from "@nestjs/core"; 7 | import { HttpArgumentsHost } from "@nestjs/common/interfaces"; 8 | import { Public } from "../../decorators/Public"; 9 | import { MissingDirectusTokenError } from "../../errors/MissingDirectusTokenError"; 10 | 11 | jest.mock('@directus/sdk') 12 | 13 | const VALID_DIRECTUS_TOKEN = 'valid token'; 14 | const INVALID_DIRECTUS_TOKEN = 'invalid token'; 15 | const USER_ID_SAMPLE = 'a user id'; 16 | 17 | describe('FouloscopieAuthGuard', () => { 18 | const reflector = new Reflector(); 19 | const fouloscopieAuthGuard = new FouloscopieAuthGuard(reflector); 20 | 21 | class Test { 22 | static defaultRoute() { 23 | // 24 | } 25 | 26 | @Public() 27 | static publicRoute() { 28 | // 29 | } 30 | } 31 | 32 | function mockContext( 33 | handler: Function, 34 | token?: string, 35 | ): ExecutionContext { 36 | const request: any = { headers: {} }; 37 | if (token) request.headers.authorization = token; 38 | return { 39 | getHandler(): Function { 40 | return handler; 41 | }, 42 | switchToHttp(): HttpArgumentsHost { 43 | return { 44 | getRequest() { 45 | return request; 46 | }, 47 | } as HttpArgumentsHost; 48 | }, 49 | } as ExecutionContext; 50 | } 51 | 52 | function testDirectusStaticAuth(handler: Function, token: string | undefined, allows: boolean, shouldCallDirectusApi: boolean, shouldPopulateRequestFields: boolean, isEmailValid = true) { 53 | function doesNotPopulateFields(context: ExecutionContext) { 54 | it('does not add userId field to request', async () => { 55 | const userId = context.switchToHttp().getRequest().userId; 56 | expect(userId).not.toBeDefined(); 57 | }); 58 | } 59 | 60 | function populatesFields(context: ExecutionContext) { 61 | it('adds userId field to request', async () => { 62 | const userId = context.switchToHttp().getRequest().userId; 63 | expect(userId).toBeDefined(); 64 | expect(userId).toBe(USER_ID_SAMPLE); 65 | }); 66 | } 67 | 68 | const mockedContext = mockContext(handler, token); 69 | 70 | const directusConstructorSpy = jest.spyOn(DirectusModule, 'Directus'); 71 | beforeAll(() => { 72 | directusConstructorSpy.mockImplementation(() => ({ 73 | auth: { 74 | async static(token: AuthToken) { 75 | return token === VALID_DIRECTUS_TOKEN; 76 | }, 77 | }, 78 | users: { 79 | me: { 80 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 81 | async read(query?: QueryOne>>): Promise>>> { 82 | return { id: USER_ID_SAMPLE, email_valid: isEmailValid }; 83 | }, 84 | }, 85 | }, 86 | } as Directus)); 87 | }); 88 | 89 | afterAll(() => { 90 | jest.clearAllMocks(); 91 | }); 92 | 93 | if (allows) { 94 | test('returns true', async () => { 95 | await expect(fouloscopieAuthGuard.canActivate(mockedContext)) 96 | .resolves.toBe(true); 97 | }); 98 | } else if (token === undefined) { 99 | test('throws a MissingDirectusTokenError', async () => { 100 | await expect(fouloscopieAuthGuard.canActivate(mockedContext)) 101 | .rejects.toThrow(MissingDirectusTokenError); 102 | }); 103 | } else { 104 | test('throws an InvalidDirectusTokenError', async () => { 105 | await expect(fouloscopieAuthGuard.canActivate(mockedContext)) 106 | .rejects.toThrow(InvalidDirectusTokenError); 107 | }); 108 | } 109 | 110 | if (shouldCallDirectusApi) { 111 | test('calls Directus api with the right url', () => { 112 | expect(directusConstructorSpy).toBeCalledTimes(1); 113 | expect(directusConstructorSpy).toBeCalledWith(process.env.DIRECTUS_URL); 114 | }); 115 | } else { 116 | test('doesn\'t call Directus api', () => { 117 | expect(directusConstructorSpy).not.toBeCalled(); 118 | }); 119 | } 120 | 121 | if (shouldPopulateRequestFields) { 122 | populatesFields(mockedContext); 123 | } else { 124 | doesNotPopulateFields(mockedContext); 125 | } 126 | } 127 | 128 | describe('Default route (protected)', () => { 129 | describe('Allows access for valid Directus static token', () => { 130 | testDirectusStaticAuth(Test.defaultRoute, VALID_DIRECTUS_TOKEN, true, true, true); 131 | }); 132 | describe('Denies access for invalid Directus static token', () => { 133 | testDirectusStaticAuth(Test.defaultRoute, INVALID_DIRECTUS_TOKEN, false, true, false); 134 | }); 135 | describe('Denies access for unverified email', () => { 136 | testDirectusStaticAuth(Test.defaultRoute, INVALID_DIRECTUS_TOKEN, false, true, false, false); 137 | }); 138 | describe('Denies access without Directus static token', () => { 139 | testDirectusStaticAuth(Test.defaultRoute, undefined, false, false, false); 140 | }); 141 | }); 142 | 143 | describe('Public route (unprotected)', () => { 144 | describe('Allows access for valid Directus static token', () => { 145 | testDirectusStaticAuth(Test.publicRoute, VALID_DIRECTUS_TOKEN, true, false, false); 146 | }); 147 | describe('Allows access for invalid Directus static token', () => { 148 | testDirectusStaticAuth(Test.publicRoute, INVALID_DIRECTUS_TOKEN, true, false, false); 149 | }); 150 | describe('Allows access without a Directus static token', () => { 151 | testDirectusStaticAuth(Test.publicRoute, undefined, true, false, false); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /back/flag-service/src/user/guards/spec/AuthGuard.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '../AuthGuard'; 2 | import { Public } from "../../decorators/Public"; 3 | import { Reflector } from "@nestjs/core"; 4 | import { ExecutionContext, ForbiddenException } from "@nestjs/common"; 5 | import { HttpArgumentsHost } from "@nestjs/common/interfaces"; 6 | import { JwtService } from "../../jwt/JwtService"; 7 | import { config } from 'dotenv'; 8 | import { UserDataJwtPayload } from "../../UserService"; 9 | import { JwtPayload } from "jsonwebtoken"; 10 | 11 | config(); 12 | 13 | describe('AuthGuard', () => { 14 | const reflector = new Reflector(); 15 | const jwtService = new JwtService(); 16 | const authGuard = new AuthGuard(reflector, jwtService); 17 | 18 | class Test { 19 | static defaultRoute() { 20 | // 21 | } 22 | 23 | @Public() 24 | static publicRoute() { 25 | // 26 | } 27 | } 28 | 29 | async function mockContext( 30 | handler: Function, 31 | authenticated: boolean, 32 | ): Promise { 33 | const payload: JwtPayload | UserDataJwtPayload = { 34 | sub: '999', // user id 35 | userData: { 36 | _id: '999', 37 | email: 'user@example.com', 38 | nickname: 'jane', 39 | createdAt: new Date().toString(), 40 | }, 41 | }; 42 | const request: any = { headers: {} }; 43 | if (authenticated) request.headers.authorization = await jwtService.sign(payload, '15 days'); 44 | return { 45 | getHandler(): Function { 46 | return handler; 47 | }, 48 | switchToHttp(): HttpArgumentsHost { 49 | return { 50 | getRequest() { 51 | return request; 52 | }, 53 | } as HttpArgumentsHost; 54 | }, 55 | } as ExecutionContext; 56 | } 57 | 58 | function testJwtServiceUsage(handler: Function, shouldUse: boolean) { 59 | describe('verifies jwt using JwtService.verify()', () => { 60 | let verifySpy: any; 61 | beforeAll(async () => { 62 | verifySpy = jest.spyOn(jwtService, 'verify'); 63 | if (shouldUse) { 64 | verifySpy = verifySpy.mockReturnValue(Promise.reject(new Error('Fake error'))); 65 | } else { 66 | try { 67 | await authGuard.canActivate(await mockContext(handler, true)); 68 | } catch (_) { 69 | } 70 | } 71 | }); 72 | 73 | afterAll(() => { 74 | verifySpy.mockRestore(); 75 | }); 76 | 77 | if (shouldUse) { 78 | it('throws Fake error', async () => { 79 | await expect(authGuard.canActivate(await mockContext(handler, true))) 80 | .rejects.toThrow('Fake error'); 81 | }); 82 | it('calls JwtService.verify()', async () => { 83 | expect(verifySpy).toBeCalledTimes(1); 84 | }); 85 | } else { 86 | it('doesn\'t call JwtService.verify()', async () => { 87 | expect(verifySpy).not.toBeCalled(); 88 | }); 89 | } 90 | }); 91 | } 92 | 93 | function testRequestFieldsArePopulated(handler: Function, shouldPopulateFieldsWhenAuthenticated: boolean) { 94 | function doesNotPopulateFields(context: () => ExecutionContext) { 95 | it('does not add jwtPayload field to request', async () => { 96 | const jwtPayload = context().switchToHttp().getRequest().jwtPayload; 97 | expect(jwtPayload).not.toBeDefined(); 98 | }); 99 | it('does not add userId field to request', async () => { 100 | const userId = context().switchToHttp().getRequest().userId; 101 | expect(userId).not.toBeDefined(); 102 | }); 103 | } 104 | 105 | function populatesFields(context: () => ExecutionContext) { 106 | it('adds jwtPayload field to request', async () => { 107 | const jwtPayload = context().switchToHttp().getRequest().jwtPayload; 108 | expect(jwtPayload).toBeDefined(); 109 | expect(jwtPayload).toHaveProperty('jti'); 110 | }); 111 | it('adds userId field to request', async () => { 112 | const userId = context().switchToHttp().getRequest().userId; 113 | expect(userId).toBeDefined(); 114 | }); 115 | } 116 | 117 | describe('request fields population', () => { 118 | describe('not authenticated', () => { 119 | let context: ExecutionContext; 120 | 121 | beforeAll(async () => { 122 | context = await mockContext(handler, false); 123 | 124 | try { 125 | await authGuard.canActivate(context); 126 | } catch (_) { 127 | } 128 | }); 129 | 130 | doesNotPopulateFields(() => context); 131 | }); 132 | describe('authenticated', () => { 133 | let context: ExecutionContext; 134 | 135 | beforeAll(async () => { 136 | context = await mockContext(handler, true); 137 | 138 | try { 139 | await authGuard.canActivate(context); 140 | } catch (_) { 141 | } 142 | }); 143 | 144 | if (shouldPopulateFieldsWhenAuthenticated) { 145 | populatesFields(() => context); 146 | } else { 147 | doesNotPopulateFields(() => context); 148 | } 149 | }); 150 | }); 151 | } 152 | 153 | describe('default (auth only)', () => { 154 | it('throws ForbiddenException when not authenticated', async () => { 155 | await expect(authGuard.canActivate(await mockContext(Test.defaultRoute, false))) 156 | .rejects.toThrow(ForbiddenException); 157 | }); 158 | it('allows access when authenticated', async () => { 159 | await expect(authGuard.canActivate(await mockContext(Test.defaultRoute, true))) 160 | .resolves.toBe(true); 161 | }); 162 | testJwtServiceUsage(Test.defaultRoute, true); 163 | testRequestFieldsArePopulated(Test.defaultRoute, true); 164 | }); 165 | 166 | describe('public', () => { 167 | it('allows access when not authenticated', async () => { 168 | await expect(authGuard.canActivate(await mockContext(Test.publicRoute, false))) 169 | .resolves.toBe(true); 170 | }); 171 | it('allows access when authenticated', async () => { 172 | await expect(authGuard.canActivate(await mockContext(Test.publicRoute, true))) 173 | .resolves.toBe(true); 174 | }); 175 | testJwtServiceUsage(Test.publicRoute, true); 176 | testRequestFieldsArePopulated(Test.publicRoute, true); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/pixel/spec/PixelRepository.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { set } from 'date-fns'; 4 | import { DatabaseModule } from 'library/database/DatabaseModule'; 5 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 6 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 7 | import { v4 } from 'uuid'; 8 | import { Pixel } from '../Pixel'; 9 | import { PixelRepository } from '../PixelRepository'; 10 | 11 | let dbClientService: DatabaseClientService; 12 | let pixelRepository: PixelRepository; 13 | let app : TestingModule; 14 | 15 | async function clean() { 16 | await dbClientService 17 | .getDb() 18 | .collection(pixelRepository.getCollectionName()) 19 | .deleteMany({}); 20 | await dbClientService 21 | .getDb() 22 | .collection('counter') 23 | .deleteMany({}); 24 | } 25 | 26 | async function init() { 27 | app = await Test.createTestingModule({ 28 | imports: [ 29 | ConfigModule.forRoot({ isGlobal: true }), 30 | DatabaseModule.register({ 31 | uri: process.env.DATABASE_URI, 32 | dbName: 'testDb', 33 | }), 34 | ], 35 | controllers: [], 36 | providers: [PixelRepository], 37 | }).compile(); 38 | dbClientService = app.get('DATABASE_CLIENT'); 39 | pixelRepository = app.get(PixelRepository); 40 | await dbClientService.onModuleInit(); 41 | } 42 | 43 | describe('PixelRepository', () => { 44 | 45 | beforeAll(async () => { 46 | await init(); 47 | await clean(); 48 | }); 49 | 50 | afterAll(async () => { 51 | await app.close(); 52 | await dbClientService.client.close(); 53 | }); 54 | 55 | 56 | describe(PixelRepository.prototype.createAndReturn, () => { 57 | describe('creation event', () => { 58 | let createdEvent: DatabaseEvent; 59 | beforeAll(async () => { 60 | const event = new DatabaseEvent() 61 | event.action = 'creation'; 62 | event.author = v4(); 63 | event.entityId = v4(); 64 | event.data = new Pixel(event.author, `${"#000000".replace(/0/g,function(){return (~~(Math.random()*16)).toString(16)})}`, event.entityId); 65 | createdEvent = await pixelRepository.createAndReturn(event); 66 | }); 67 | it ('increment pixelCounter by 1', async () => { 68 | const pixelCounter = await dbClientService.getDb().collection('counter').findOne({ name : 'pixelCounter' }); 69 | expect(pixelCounter.counter).toEqual(1); 70 | }); 71 | it ('set data.indexInFlag to pixelCounterValue', () => { 72 | expect(createdEvent.data.indexInFlag).toEqual(1); 73 | }); 74 | it ('set eventId', async () => { 75 | expect(createdEvent.eventId).toEqual(1); 76 | }); 77 | it ('increment eventCounter', async () => { 78 | const eventCounter = await dbClientService.getDb().collection('counter').findOne({ name : 'pixelEventCounter' }); 79 | expect(eventCounter.counter).toEqual(1); 80 | }); 81 | }); 82 | 83 | describe('update event', () => { 84 | let createdEvent: DatabaseEvent; 85 | beforeAll(async () => { 86 | const event = new DatabaseEvent() 87 | event.action = 'update'; 88 | event.author = v4(); 89 | event.entityId = v4(); 90 | event.data = new Pixel(event.author, `${"#000000".replace(/0/g,function(){return (~~(Math.random()*16)).toString(16)})}`, event.entityId); 91 | createdEvent = await pixelRepository.createAndReturn(event); 92 | }); 93 | 94 | it ('set eventId', async () => { 95 | expect(createdEvent.eventId).toEqual(2); 96 | }); 97 | it ('does not increment pixelCounter', async () => { 98 | const pixelCounter = await dbClientService.getDb().collection('counter').findOne({ name : 'pixelCounter' }); 99 | expect(pixelCounter.counter).toEqual(1); 100 | }); 101 | it ('increment eventCounter', async () => { 102 | const pixelCounter = await dbClientService.getDb().collection('counter').findOne({ name : 'pixelEventCounter' }); 103 | expect(pixelCounter.counter).toEqual(2); 104 | }); 105 | }); 106 | }); 107 | 108 | describe(PixelRepository.prototype.getPixels, () => { 109 | beforeAll(async () => { 110 | await clean(); 111 | await dbClientService.getDb().collection(pixelRepository.getCollectionName()).insertMany([ 112 | { 113 | action: 'creation', 114 | author: 'ownerid', 115 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 116 | data: { 117 | ownerId: 'ownerid', 118 | hexColor: '#DDDDDD', 119 | pixId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 120 | indexInFlag: 1, 121 | }, 122 | eventId: 1, 123 | createdAt: set(new Date(), { 124 | year: 2021, 125 | month: 7, 126 | date: 9, 127 | hours: 23, 128 | minutes: 0, 129 | seconds: 0, 130 | }), 131 | }, 132 | { 133 | action: 'update', 134 | author: 'ownerid', 135 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 136 | data: { 137 | ownerId: 'ownerid', 138 | hexColor: '#FFFFFF', 139 | pixId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 140 | indexInFlag: 1, 141 | }, 142 | eventId: 2, 143 | createdAt: set(new Date(), { 144 | year: 2021, 145 | month: 7, 146 | date: 9, 147 | hours: 23, 148 | minutes: 10, 149 | seconds: 0, 150 | }), 151 | }, 152 | { 153 | action: 'update', 154 | author: 'ownerid', 155 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 156 | data: { 157 | ownerId: 'ownerid', 158 | hexColor: '#AAAAAA', 159 | pixId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 160 | indexInFlag: 1, 161 | }, 162 | eventId: 4, 163 | ignored: true, 164 | createdAt: set(new Date(), { 165 | year: 2021, 166 | month: 7, 167 | date: 9, 168 | hours: 23, 169 | minutes: 15, 170 | seconds: 0, 171 | }), 172 | }, 173 | { 174 | action: 'creation', 175 | author: 'otherownerid', 176 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015ff', 177 | data: { 178 | ownerId: 'otherownerid', 179 | hexColor: '#BBBBBB', 180 | pixId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015ff', 181 | indexInFlag: 2, 182 | }, 183 | eventId: 3, 184 | createdAt: set(new Date(), { 185 | year: 2021, 186 | month: 7, 187 | date: 9, 188 | hours: 23, 189 | minutes: 10, 190 | seconds: 0, 191 | }), 192 | }, 193 | ]) 194 | }); 195 | it('get all pixels state ignoring event with ignored attribute to true', async () => { 196 | const pixels = await pixelRepository.getPixels(); 197 | expect(pixels).toEqual([ 198 | { 199 | indexInFlag: 1, 200 | hexColor: '#FFFFFF', 201 | author: 'ownerid', 202 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015fe', 203 | }, 204 | { 205 | indexInFlag: 2, 206 | hexColor: '#BBBBBB', 207 | author: 'otherownerid', 208 | entityId: 'c35a2bf6-18a6-4fd5-933b-f81faf1015ff', 209 | }, 210 | ]); 211 | }); 212 | }); 213 | 214 | }); 215 | -------------------------------------------------------------------------------- /back/flag-service/test/flag.spec-e2e.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication } from '@nestjs/common'; 2 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 3 | import request from 'supertest'; 4 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 5 | import * as DirectusModule from "@directus/sdk"; 6 | import { AuthToken, Directus, PartialItem, QueryOne, TypeOf, UserItem } from "@directus/sdk"; 7 | import { Pixel } from '../src/flag/pixel/Pixel'; 8 | import { bootstrap } from "../src/bootstrap"; 9 | import { AuthBackend } from "../src/user/AuthBackend"; 10 | import { registerAndLogin } from "./util/registerAndLogin"; 11 | import { v4 } from "uuid"; 12 | 13 | jest.mock('@directus/sdk') 14 | 15 | const VALID_DIRECTUS_TOKEN = 'valid token'; 16 | const USER_ID_SAMPLE = 'a user id'; 17 | 18 | describe('Flag (e2e)', () => { 19 | let savedEnvAuthBackend: string; 20 | 21 | let app: INestApplication; 22 | let createdPixelEvent: DatabaseEvent; 23 | let authToken: string; 24 | let userId: string; 25 | 26 | beforeAll(() => { 27 | savedEnvAuthBackend = process.env.AUTH_BACKEND; 28 | }); 29 | 30 | afterAll(() => { 31 | process.env.AUTH_BACKEND = savedEnvAuthBackend; 32 | }); 33 | 34 | for (const authBackend of Object.values(AuthBackend)) { 35 | describe(`Auth backend: ${authBackend}`, () => { 36 | beforeAll(async () => { 37 | process.env.AUTH_BACKEND = authBackend; 38 | app = await bootstrap(0); 39 | 40 | const dbService = app.get('DATABASE_CLIENT'); 41 | const db = dbService.getDb(); 42 | await db.collection('users').deleteMany({}); 43 | await db.collection('pixel-events').deleteMany({}); 44 | await db.collection('counter').deleteMany({}); 45 | await db.collection('flag-snapshot').deleteMany({}); 46 | 47 | if (authBackend === AuthBackend.FOULOSCOPIE) { 48 | authToken = VALID_DIRECTUS_TOKEN; 49 | userId = USER_ID_SAMPLE; 50 | jest.spyOn(DirectusModule, 'Directus').mockImplementation(() => ({ 51 | auth: { 52 | async static(token: AuthToken) { 53 | return token === VALID_DIRECTUS_TOKEN; 54 | }, 55 | }, 56 | users: { 57 | me: { 58 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 59 | async read(query?: QueryOne>>): Promise>>> { 60 | return { id: USER_ID_SAMPLE, email_valid: true }; 61 | }, 62 | }, 63 | }, 64 | } as Directus)); 65 | } else if (authBackend === AuthBackend.INTERNAL) { 66 | const res = await registerAndLogin(app, v4() + '@example.com', 'password123', v4()); 67 | authToken = res.jwt; 68 | userId = res.user._id; 69 | } 70 | }); 71 | 72 | afterAll(async () => { 73 | await app.close(); 74 | }); 75 | 76 | describe('/pixel', () => { 77 | describe('PUT/POST flow', () => { 78 | it('PUT PixelDoesNotExist error', async () => { 79 | const res = await request(app.getHttpServer()) 80 | .put('/pixel') 81 | .set('authorization', authToken) 82 | .send({ 83 | pixelId: 'fakePixelId', 84 | hexColor: '#DDDDDD', 85 | }); 86 | expect(res.status).toEqual(400); 87 | expect(res.body.message).toEqual('Pixel does not exist.'); 88 | }); 89 | 90 | it('POST success', async () => { 91 | const res = await request(app.getHttpServer()) 92 | .post('/pixel') 93 | .set('authorization', authToken) 94 | .send({ 95 | hexColor: '#FFADAD', 96 | }); 97 | expect(res.status).toEqual(201); 98 | createdPixelEvent = res.body; 99 | }); 100 | 101 | for(const badColor of [ 102 | 'badcolor', 103 | '#FFF', 104 | '#ZZZZZZ', 105 | '#FFFFFFF', 106 | ]) { 107 | it(`PUT fails with bad color "${badColor}"`, async () => { 108 | const res = await request(app.getHttpServer()) 109 | .put('/pixel') 110 | .set('authorization', authToken) 111 | .send({ 112 | pixelId: createdPixelEvent.entityId, 113 | hexColor: badColor, 114 | }); 115 | expect(res.status).toEqual(400); 116 | }); 117 | } 118 | 119 | it(`PUT fails with empty id`, async () => { 120 | const res = await request(app.getHttpServer()) 121 | .put('/pixel') 122 | .set('authorization', authToken) 123 | .send({ 124 | hexColor: "#FFFFFF", 125 | }); 126 | expect(res.status).toEqual(400); 127 | }); 128 | 129 | it(`PUT fails with null for pixelId`, async () => { 130 | const res = await request(app.getHttpServer()) 131 | .put('/pixel') 132 | .set('authorization', authToken) 133 | .send({ 134 | pixelId: null, 135 | hexColor: "#FFFFFF", 136 | }); 137 | expect(res.status).toEqual(400); 138 | }); 139 | 140 | it('PUT success', async () => { 141 | const res = await request(app.getHttpServer()) 142 | .put('/pixel') 143 | .set('authorization', authToken) 144 | .send({ 145 | pixelId: createdPixelEvent.entityId, 146 | hexColor: '#DDDDDD', 147 | }); 148 | expect(res.status).toEqual(200); 149 | }); 150 | }); 151 | 152 | describe('GET', () => { 153 | if (authBackend === AuthBackend.FOULOSCOPIE) { 154 | it('Fails on empty request (BadRequest)', async () => { 155 | const res = await request(app.getHttpServer()) 156 | .get('/pixel'); 157 | const { error, message } = res.body; 158 | 159 | expect(res.status).toBe(HttpStatus.BAD_REQUEST); 160 | 161 | expect(error).toBe('Missing directus token.'); 162 | expect(message).toBe('header: Authorization'); 163 | }); 164 | } 165 | 166 | it('success', async () => { 167 | const res = await request(app.getHttpServer()) 168 | .get('/pixel') 169 | .set('authorization', authToken); 170 | const mypixel = res.body; 171 | 172 | expect(res.status).toEqual(200); 173 | 174 | expect(mypixel.author).toEqual(userId); 175 | expect(mypixel.hexColor).toEqual('#DDDDDD'); 176 | expect(mypixel.indexInFlag).toEqual(1); 177 | }); 178 | }) 179 | }); 180 | 181 | it('/flag (GET)', async () => { 182 | const res = await request(app.getHttpServer()) 183 | .get('/flag') 184 | .set('authorization', authToken); 185 | const firstPixel = res.body[0]; 186 | 187 | expect(res.status).toEqual(200); 188 | 189 | expect(firstPixel.author).toEqual(userId); 190 | expect(firstPixel.hexColor).toEqual('#DDDDDD'); 191 | }); 192 | it('/cooldown (GET)', async () => { 193 | const res = await request(app.getHttpServer()) 194 | .get('/cooldown') 195 | .set('authorization', authToken); 196 | 197 | expect(res.status).toEqual(200); 198 | expect(res.body.cooldown).toEqual(Number(process.env.CHANGE_COOLDOWN)); 199 | }); 200 | }); 201 | } 202 | }); 203 | -------------------------------------------------------------------------------- /back/flag-service/src/user/spec/UserService.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseClientService } from "library/database/client/DatabaseClientService"; 2 | import { UserDataJwtPayload, UserService } from "../UserService"; 3 | import { UserRepository } from "../UserRepository"; 4 | import { Test, TestingModule } from "@nestjs/testing"; 5 | import { DatabaseModule } from "library/database/DatabaseModule"; 6 | import { UserModule } from "../UserModule"; 7 | import { User } from "../User"; 8 | import { EmailAlreadyTakenError } from "../errors/EmailAlreadyTakenError"; 9 | import { NicknameAlreadyTakenError } from "../errors/NicknameAlreadyTakenError"; 10 | import { Collection } from "mongodb"; 11 | import argon2 from "argon2"; 12 | import { EmailNotFoundError } from "../errors/EmailNotFoundError"; 13 | import { IncorrectPasswordError } from "../errors/IncorrectPasswordError"; 14 | import { JwtService } from "../jwt/JwtService"; 15 | import { UserIdNotFoundError } from "../errors/UserIdNotFoundError"; 16 | import { AuthBackend } from "../AuthBackend"; 17 | 18 | describe('UserService', () => { 19 | let userService: UserService; 20 | let jwtService: JwtService; 21 | let dbClientService: DatabaseClientService; 22 | let userRepository: UserRepository; 23 | 24 | let userCollection: Collection; 25 | beforeAll(async () => { 26 | const app: TestingModule = await Test.createTestingModule({ 27 | imports: [ 28 | DatabaseModule.register({ 29 | uri: process.env.DATABASE_URI, 30 | dbName: 'testDb', 31 | }), 32 | UserModule.register(AuthBackend.INTERNAL), 33 | ], 34 | }).compile(); 35 | userService = app.get(UserService); 36 | jwtService = app.get(JwtService); 37 | dbClientService = app.get('DATABASE_CLIENT'); 38 | userRepository = app.get(UserRepository); 39 | await dbClientService.onModuleInit(); 40 | userCollection = dbClientService 41 | .getDb() 42 | .collection(userRepository.getCollectionName()); 43 | }); 44 | 45 | afterAll(async () => { 46 | await userCollection.deleteMany({}); 47 | await dbClientService.client.close(); 48 | }); 49 | 50 | describe('register', () => { 51 | afterAll(async () => { 52 | await userCollection.deleteMany({}); 53 | }); 54 | 55 | it('creates a new user in db', async () => { 56 | const user = await userService.register('user@example.com', 'password123', 'jane'); 57 | expect(user.password).not.toBe('password123'); // it doesn't return the password unhashed 58 | 59 | const userInDb = await userCollection.findOne({ _id: user._id }); 60 | expect(userInDb).toBeDefined(); 61 | expect(userInDb.email).toBe('user@example.com'); 62 | expect(userInDb.password).toBe(user.password); 63 | expect(userInDb.nickname).toBe('jane'); 64 | 65 | expect(await argon2.verify(userInDb.password, 'password123')).toBe(true); // it stores the password hashed in db 66 | }); 67 | it('throws an EmailAlreadyTakenError when a user already exists with this email in db', async () => { 68 | expect(await userCollection.countDocuments({ email: 'john@example.com' })).toBe(0); 69 | 70 | await userService.register('john@example.com', 'password123', 'john'); 71 | await expect(userService.register('john@example.com', 'password123', 'jack')) 72 | .rejects.toThrow(EmailAlreadyTakenError); 73 | 74 | expect(await userCollection.countDocuments({ email: 'john@example.com' })).toBe(1); 75 | }); 76 | it('throws a NicknameAlreadyTakenError when a user already exists with this nickname in db', async () => { 77 | expect(await userCollection.countDocuments({ nickname: 'anna' })).toBe(0); 78 | 79 | await userService.register('anna@example.com', 'password123', 'anna'); 80 | await expect(userService.register('anotheranna@example.com', 'password123', 'anna')) 81 | .rejects.toThrow(NicknameAlreadyTakenError); 82 | 83 | expect(await userCollection.countDocuments({ nickname: 'anna' })).toBe(1); 84 | }); 85 | }); 86 | 87 | describe('login', () => { 88 | let signSpy; 89 | let response; 90 | beforeAll(async () => { 91 | await userService.register('user@example.com', 'password123', 'jane'); 92 | signSpy = jest.spyOn(jwtService, 'sign'); 93 | response = await userService.login('user@example.com', 'password123'); 94 | }); 95 | afterAll(async () => { 96 | await userCollection.deleteMany({}); 97 | }); 98 | 99 | it('logs in the user, makes a valid jwt', async () => { 100 | expect(response).toBeDefined(); 101 | expect(response.user).toBeDefined(); 102 | expect(response.user.email).toBe('user@example.com'); 103 | expect(response.jwt).toBeDefined(); 104 | 105 | const payload = await jwtService.verify(response.jwt); 106 | expect(payload).toBeDefined(); 107 | expect(payload.sub).toBe(response.user._id.toString()); 108 | expect(payload.userData).toStrictEqual({ 109 | _id: response.user._id.toString(), 110 | email: response.user.email, 111 | nickname: response.user.nickname, 112 | createdAt: response.user.createdAt.toISOString(), 113 | }); 114 | }); 115 | it('calls JwtService.sign once', () => { 116 | expect(signSpy).toBeCalledTimes(1); 117 | }); 118 | it('throws an EmailNotFoundError when no user exist with this email in db', async () => { 119 | await expect(userService.login('doesnotexist@example.com', 'password123')) 120 | .rejects.toThrow(EmailNotFoundError); 121 | }); 122 | it('throws an IncorrectPasswordError when the password is incorrect', async () => { 123 | await expect(userService.login('user@example.com', 'wrongpassword789')) 124 | .rejects.toThrow(IncorrectPasswordError); 125 | }); 126 | }); 127 | 128 | describe('changePassword', () => { 129 | afterAll(async () => { 130 | await userCollection.deleteMany({}); 131 | }); 132 | 133 | it('changes user\'s password', async () => { 134 | let user = await userService.register('user@example.com', 'password123', 'jane'); 135 | user = await userService.changePassword(user._id, 'password123', 'newpassword789'); 136 | const userInDb = await userCollection.findOne({ _id: user._id }); 137 | expect(await argon2.verify(userInDb.password, 'password123')).toBe(false); 138 | expect(await argon2.verify(userInDb.password, 'newpassword789')).toBe(true); 139 | }); 140 | it('throws a UserIdNotFoundError when there is no user with that id', async () => { 141 | await expect(userService.changePassword('0123456789ab', 'password123', 'newpassword789')) 142 | .rejects.toThrow(UserIdNotFoundError); 143 | }); 144 | it('throws an IncorrectPasswordError when current password is incorrect', async () => { 145 | const user = await userService.register('john@example.com', 'password123', 'john'); 146 | await expect(userService.changePassword(user._id, 'incorrectpassword456', 'newpassword789')) 147 | .rejects.toThrow(IncorrectPasswordError); 148 | }); 149 | }); 150 | 151 | describe('changeNickname', () => { 152 | afterAll(async () => { 153 | await userCollection.deleteMany({}); 154 | }); 155 | 156 | it('changes user\'s nickname', async () => { 157 | let user = await userService.register('user@example.com', 'password123', 'jane'); 158 | user = await userService.changeNickname(user._id, 'password123', 'jane42'); 159 | const userInDb = await userCollection.findOne({ _id: user._id }); 160 | expect(userInDb.nickname).toBe('jane42'); 161 | }); 162 | it('throws a UserIdNotFoundError when there is no user with that id', async () => { 163 | await expect(userService.changeNickname('0123456789ab', 'password123', 'jane')) 164 | .rejects.toThrow(UserIdNotFoundError); 165 | }); 166 | it('throws an IncorrectPasswordError when password is incorrect', async () => { 167 | const user = await userService.register('john@example.com', 'password123', 'john'); 168 | await expect(userService.changeNickname(user._id, 'incorrectpassword456', 'john42')) 169 | .rejects.toThrow(IncorrectPasswordError); 170 | }); 171 | it('throws a NicknameAlreadyTakenError when another user already has that nickname', async () => { 172 | await userService.register('jack@example.com', 'password123', 'jack'); 173 | const user = await userService.register('jack2@example.com', 'password123', 'jack2'); 174 | await expect(userService.changeNickname(user._id, 'password123', 'jack')) 175 | .rejects.toThrow(NicknameAlreadyTakenError); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/spec/FlagController.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChangePixelColorDto } from '../dto/ChangePixelColorDto'; 2 | import { UserHasNoPixelError } from '../errors/UserHasNoPixelError'; 3 | import { FlagController } from '../FlagController'; 4 | import { FlagService } from '../FlagService'; 5 | import { InternalServerErrorException } from '@nestjs/common'; 6 | import { UserAlreadyOwnsAPixelError } from "../errors/UserAlreadyOwnsAPixelError"; 7 | import { UserActionIsOnCooldownError } from "../errors/UserActionIsOnCooldownError"; 8 | 9 | describe('FlagController', () => { 10 | let flagController: FlagController; 11 | let flagService: FlagService; 12 | 13 | beforeAll(async () => { 14 | flagService = new FlagService(null, null); 15 | flagController = new FlagController(flagService); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | describe('addPixel', () => { 23 | describe('success', () => { 24 | let addPixelSpy; 25 | let res; 26 | beforeAll(async () => { 27 | addPixelSpy = jest 28 | .spyOn(flagService, 'addPixel') 29 | .mockReturnValue({ fake: true } as any); 30 | res = await flagController.addPixel('ownerId', '#ffffff'); 31 | }); 32 | it('call addPixel from service', () => { 33 | expect(addPixelSpy).toBeCalledTimes(1); 34 | }); 35 | it('return addPixel return value', () => { 36 | expect(res).toEqual({ fake: true }); 37 | }); 38 | }); 39 | describe('failure', () => { 40 | let addPixelSpy; 41 | let res; 42 | beforeAll(async () => { 43 | addPixelSpy = jest 44 | .spyOn(flagService, 'addPixel') 45 | .mockImplementation(() => { 46 | throw new UserAlreadyOwnsAPixelError(); 47 | }); 48 | res = flagController.addPixel('ownerId', '#ffffff'); 49 | }); 50 | it('call addPixel from service', () => { 51 | expect(addPixelSpy).toBeCalledTimes(1); 52 | }); 53 | it('throws UserAlreadyOwnsAPixelError from service', async () => { 54 | await expect(res).rejects.toThrow(UserAlreadyOwnsAPixelError); 55 | }); 56 | }); 57 | }); 58 | describe('changePixelColor', () => { 59 | describe('success', () => { 60 | let changePixelColorSpy; 61 | let res; 62 | beforeAll(async () => { 63 | const data: ChangePixelColorDto = { 64 | hexColor: '#FFFFFF', 65 | pixelId: 'pixelId', 66 | }; 67 | changePixelColorSpy = jest 68 | .spyOn(flagService, 'changePixelColor') 69 | .mockReturnValue({ modified: true } as any); 70 | res = await flagController.changePixelColor('ownerId', data, 'myip', { header: () => 'user' } as any); 71 | }); 72 | it('call changePixelColor from service', () => { 73 | expect(changePixelColorSpy).toBeCalledTimes(1); 74 | }) 75 | it('returns changePixelColor return value', () => { 76 | expect(res).toEqual({ modified: true }); 77 | }) 78 | }); 79 | describe('failure', () => { 80 | describe('service.changePixelColor throw CooldownTimerHasNotEndedYet', () => { 81 | let changePixelColorSpy; 82 | let res; 83 | beforeAll(async () => { 84 | const data: ChangePixelColorDto = { 85 | hexColor: '#FFFFFF', 86 | pixelId: 'pixelId', 87 | }; 88 | changePixelColorSpy = jest 89 | .spyOn(flagService, 'changePixelColor') 90 | .mockImplementation(() => { 91 | throw new UserActionIsOnCooldownError(1000); 92 | }); 93 | res = flagController.changePixelColor('ownerId', data, 'myip', { header: () => 'user' } as any); 94 | }); 95 | it('call changePixelColor from service', () => { 96 | expect(changePixelColorSpy).toBeCalledTimes(1); 97 | }); 98 | it('throws CooldownTimerHasNotEndedYetError from service', async () => { 99 | await expect(res).rejects.toThrow(UserActionIsOnCooldownError); 100 | }); 101 | }); 102 | describe('service.changePixelColor throw UserHasNoPixel', () => { 103 | let changePixelColorSpy; 104 | let res; 105 | beforeAll(async () => { 106 | const data: ChangePixelColorDto = { 107 | hexColor: '#FFFFFF', 108 | pixelId: 'pixelId', 109 | }; 110 | changePixelColorSpy = jest 111 | .spyOn(flagService, 'changePixelColor') 112 | .mockImplementation(() => { 113 | throw new UserHasNoPixelError(); 114 | }); 115 | res = flagController.changePixelColor('ownerId', data, 'myip', { header: () => 'user' } as any); 116 | }); 117 | it('call changePixelColor from service', () => { 118 | expect(changePixelColorSpy).toBeCalledTimes(1); 119 | }); 120 | it('throws UserHasNoPixel from service', async () => { 121 | await expect(res).rejects.toThrow(UserHasNoPixelError); 122 | }); 123 | }); 124 | }); 125 | }); 126 | describe('getFlag', () => { 127 | describe('success', () => { 128 | let getFlagSpy; 129 | let res; 130 | beforeAll(async () => { 131 | getFlagSpy = jest 132 | .spyOn(flagService, 'getFlag') 133 | .mockReturnValue([{ modified: true }] as any); 134 | res = await flagController.getFlag(); 135 | }); 136 | it('call getFlag from service', () => { 137 | expect(getFlagSpy).toBeCalledTimes(1); 138 | }) 139 | it('returns getFlag return value', () => { 140 | expect(res).toEqual([{ modified: true }]); 141 | }) 142 | }); 143 | describe('failure', () => { 144 | let getFlagSpy; 145 | let res; 146 | beforeAll(async () => { 147 | getFlagSpy = jest 148 | .spyOn(flagService, 'getFlag') 149 | .mockImplementation(() => { 150 | throw new Error(); 151 | }); 152 | res = flagController.getFlag(); 153 | }); 154 | it('call getFlag from service', () => { 155 | expect(getFlagSpy).toBeCalledTimes(1); 156 | }); 157 | it('throws InternalServerErrorException when service throw an error', async () => { 158 | await expect(res).rejects.toThrow(InternalServerErrorException); 159 | }); 160 | }); 161 | }); 162 | describe('getFlagAtDate', () => { 163 | describe('success', () => { 164 | let getFlagAtDateSpy; 165 | let res; 166 | beforeAll(async () => { 167 | getFlagAtDateSpy = jest 168 | .spyOn(flagService, 'getFlagAtDate') 169 | .mockReturnValue([{ modified: true }] as any); 170 | res = await flagController.getFlagAtDate(new Date()); 171 | }); 172 | it('call getFlagAtDate from service', () => { 173 | expect(getFlagAtDateSpy).toBeCalledTimes(1); 174 | }) 175 | it('returns getFlagAtDate return value', () => { 176 | expect(res).toEqual([{ modified: true }]); 177 | }) 178 | }); 179 | describe('failure', () => { 180 | let getFlagAtDateSpy; 181 | let res; 182 | beforeAll(async () => { 183 | getFlagAtDateSpy = jest 184 | .spyOn(flagService, 'getFlagAtDate') 185 | .mockImplementation(() => { 186 | throw new Error(); 187 | }); 188 | res = flagController.getFlagAtDate(new Date()); 189 | }); 190 | it('call getFlag from service', () => { 191 | expect(getFlagAtDateSpy).toBeCalledTimes(1); 192 | }); 193 | it('throws InternalServerErrorException when service throw an error', async () => { 194 | await expect(res).rejects.toThrow(InternalServerErrorException); 195 | }); 196 | }); 197 | }); 198 | describe('getUserPixel', () => { 199 | describe('success', () => { 200 | let getPixelSpy; 201 | let res; 202 | beforeAll(async () => { 203 | getPixelSpy = jest 204 | .spyOn(flagService, 'getOrCreateUserPixel') 205 | .mockReturnValue({ pixel: true } as any); 206 | res = await flagController.getUserPixel('ownerId'); 207 | }); 208 | it('call getOrCreateUserPixel from service', () => { 209 | expect(getPixelSpy).toBeCalledTimes(1); 210 | }) 211 | it('returns getOrCreateUserPixel return value', () => { 212 | expect(res).toEqual({ pixel: true }); 213 | }) 214 | }); 215 | }); 216 | describe('getCoolDown', () => { 217 | describe('success', () => { 218 | it('returns process.env.CHANGE_COOLDOWN', () => { 219 | process.env.CHANGE_COOLDOWN = '15'; 220 | expect(flagController.getChangeCooldown().cooldown).toEqual(15); 221 | }) 222 | }); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /back/flag-service/src/flag/snapshot/spec/FlagSnapshotService.spec-integration.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { DatabaseModule } from 'library/database/DatabaseModule'; 4 | import { DatabaseClientService } from 'library/database/client/DatabaseClientService'; 5 | import { DatabaseEvent } from 'library/database/object/event/DatabaseEvent'; 6 | import { v4 } from 'uuid'; 7 | import { Pixel } from '../../pixel/Pixel'; 8 | import { PixelRepository } from '../../pixel/PixelRepository'; 9 | import { FlagSnapshot } from '../FlagSnapshot'; 10 | import { FlagSnapshotPixelRepository } from '../pixel/FlagSnapshotPixelRepository'; 11 | import { FlagSnapshotModule } from '../FlagSnapshotModule'; 12 | import { FlagSnapshotRepository } from '../FlagSnapshotRepository'; 13 | import { FlagSnapshotService } from '../FlagSnapshotService'; 14 | 15 | let flagSnapshotService: FlagSnapshotService; 16 | let dbClientService: DatabaseClientService; 17 | let pixelRepository: PixelRepository; 18 | let flagSnapshotRepository: FlagSnapshotRepository; 19 | let flagSnapshotPixelRepository: FlagSnapshotPixelRepository; 20 | let app : TestingModule; 21 | 22 | async function clean() { 23 | await dbClientService 24 | .getDb() 25 | .collection(pixelRepository.getCollectionName()) 26 | .deleteMany({}); 27 | await dbClientService 28 | .getDb() 29 | .collection('counter') 30 | .deleteMany({}); 31 | await dbClientService 32 | .getDb() 33 | .collection(flagSnapshotRepository.getCollectionName()) 34 | .deleteMany({}); 35 | await dbClientService 36 | .getDb() 37 | .collection(flagSnapshotPixelRepository.getCollectionName()) 38 | .deleteMany({}); 39 | } 40 | 41 | async function init() { 42 | app = await Test.createTestingModule({ 43 | imports: [ 44 | ConfigModule.forRoot({ isGlobal: true }), 45 | DatabaseModule.register({ 46 | uri: process.env.DATABASE_URI, 47 | dbName: 'testDb', 48 | }), 49 | FlagSnapshotModule, 50 | ], 51 | controllers: [], 52 | providers: [], 53 | }).compile(); 54 | flagSnapshotService = app.get(FlagSnapshotService); 55 | dbClientService = app.get('DATABASE_CLIENT'); 56 | pixelRepository = app.get(PixelRepository); 57 | flagSnapshotRepository = app.get(FlagSnapshotRepository); 58 | flagSnapshotPixelRepository = app.get(FlagSnapshotPixelRepository); 59 | await dbClientService.onModuleInit(); 60 | } 61 | 62 | async function createPixelEvent(eventId: number): Promise> { 63 | const event = new DatabaseEvent() 64 | event.action = 'creation'; 65 | event.author = v4(); 66 | event.entityId = v4(); 67 | event.eventId = eventId; 68 | event.data = new Pixel(event.author, `${"#000000".replace(/0/g,function(){return (~~(Math.random()*16)).toString(16)})}`, event.entityId, eventId); 69 | event.createdAt = new Date(); 70 | await pixelRepository.createAndReturn(event); 71 | return event; 72 | } 73 | 74 | async function createManyPixel(numberOfPixels: number): Promise { 75 | let i = 0; 76 | const createdPixels = []; 77 | while (i < numberOfPixels) { 78 | const createdEvent = await createPixelEvent(i); 79 | createdPixels.push({ 80 | entityId: createdEvent.entityId, 81 | hexColor: createdEvent.data.hexColor, 82 | lastUpdate: createdEvent.createdAt, 83 | author: createdEvent.author, 84 | createdAt: createdEvent.createdAt, 85 | indexInFlag: createdEvent.data.indexInFlag, 86 | }); 87 | i++; 88 | } 89 | return createdPixels; 90 | } 91 | 92 | describe('FlagSnapshotService', () => { 93 | 94 | beforeAll(async () => { 95 | await init(); 96 | await clean(); 97 | }); 98 | 99 | afterAll(async () => { 100 | await app.close(); 101 | await dbClientService.client.close(); 102 | }); 103 | 104 | afterEach(async () => { 105 | await app.close(); 106 | await init(); 107 | await clean(); 108 | }); 109 | 110 | describe(FlagSnapshotService.prototype.createSnapshot, () => { 111 | describe('when no previous snapshot', () => { 112 | let createdPixels = []; 113 | let snapshot: FlagSnapshot; 114 | beforeEach(async () => { 115 | createdPixels = await createManyPixel(9); 116 | await flagSnapshotService.createSnapshot(9); 117 | snapshot = await flagSnapshotRepository.findLastByDate({}); 118 | }); 119 | it ('create snapshot with aggregation', async () => { 120 | expect(snapshot.lastEventId).toEqual(9); 121 | expect(snapshot.complete).toBe(true); 122 | }); 123 | it('adds createdPixels in DB', async () => { 124 | const pixelsInDb = await flagSnapshotPixelRepository.find({ snapshotId: snapshot._id.toHexString() }); 125 | function filterPixelFields(pixels) { 126 | return pixels.map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag })); 127 | } 128 | expect(filterPixelFields(pixelsInDb)).toEqual(filterPixelFields(createdPixels)); 129 | }); 130 | }); 131 | 132 | describe('with previous legacy snapshot', () => { 133 | let createdPixels = []; 134 | beforeAll(async () => { 135 | createdPixels = await createManyPixel(35); 136 | await flagSnapshotRepository.createAndReturn({ 137 | pixels: createdPixels.slice(0, 15).map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag })) as any, 138 | lastEventId: 15, 139 | complete: true, 140 | }); 141 | }); 142 | it ('creates snapshot based on previous legacy snapshot', async () => { 143 | await flagSnapshotService.createSnapshot(35); 144 | const snapshot = await flagSnapshotService.getLatestSnapshot(); 145 | expect(snapshot.lastEventId).toEqual(35); 146 | expect(snapshot.pixels).toEqual(createdPixels.map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 147 | }); 148 | }); 149 | 150 | describe('with previous snapshot', () => { 151 | let createdPixels = []; 152 | beforeAll(async () => { 153 | createdPixels = await createManyPixel(35); 154 | await flagSnapshotService.createSnapshot(15); 155 | }); 156 | it ('creates snapshot based on previous snapshot', async () => { 157 | await flagSnapshotService.createSnapshot(35); 158 | const snapshot = await flagSnapshotService.getLatestSnapshot(); 159 | expect(snapshot.lastEventId).toEqual(35); 160 | expect(snapshot.pixels).toEqual(createdPixels.map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 161 | }); 162 | }); 163 | }); 164 | 165 | describe(FlagSnapshotService.prototype.getLatestSnapshot, () => { 166 | describe('when no previous snapshot', () => { 167 | it ('returns undefined', async () => { 168 | const snapshot = await flagSnapshotService.getLatestSnapshot(); 169 | expect(snapshot).toBe(undefined); 170 | }); 171 | }); 172 | describe('with previous snapshot', () => { 173 | let createdPixels = []; 174 | beforeEach(async () => { 175 | createdPixels = await createManyPixel(40); 176 | await flagSnapshotService.createSnapshot(15); 177 | }); 178 | it ('get snapshot when no newer', async () => { 179 | const snapshot = await flagSnapshotService.getLatestSnapshot(); 180 | expect(snapshot.lastEventId).toEqual(15); 181 | expect(snapshot.pixels).toEqual(createdPixels.slice(0, 15).map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 182 | }); 183 | it ('get newer snapshot when there is a newer', async () => { 184 | await flagSnapshotService.createSnapshot(40); 185 | const latestSnapshot = await flagSnapshotService.getLatestSnapshot(); 186 | expect(latestSnapshot.lastEventId).toEqual(40); 187 | expect(latestSnapshot.pixels).toEqual(createdPixels.map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 188 | }); 189 | 190 | it ('get second to last snapshot when last is not complete', async () => { 191 | await flagSnapshotService.createSnapshot(35); 192 | await flagSnapshotRepository.createAndReturn({ 193 | lastEventId: 40, 194 | complete: false, 195 | }); 196 | 197 | const latestSnapshot = await flagSnapshotService.getLatestSnapshot(); 198 | expect(latestSnapshot.lastEventId).toEqual(35); 199 | expect(latestSnapshot.pixels).toEqual(createdPixels.slice(0, 35).map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 200 | }); 201 | }); 202 | 203 | describe('with previous legacy snapshot', () => { 204 | let createdPixels = []; 205 | beforeEach(async () => { 206 | createdPixels = await createManyPixel(35); 207 | await flagSnapshotRepository.createAndReturn({ 208 | pixels: createdPixels.slice(0, 15).map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag })) as any, 209 | lastEventId: 15, 210 | complete: true, 211 | }); 212 | }); 213 | it ('get snapshot when no newer', async () => { 214 | const snapshot = await flagSnapshotService.getLatestSnapshot(); 215 | expect(snapshot.lastEventId).toEqual(15); 216 | expect(snapshot.pixels).toEqual(createdPixels.splice(0, 15).map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 217 | }); 218 | it ('get newer snapshot when there is a newer', async () => { 219 | await new Promise((r) => setTimeout(r, 1)); 220 | const snapshot = await flagSnapshotRepository.createAndReturn({ 221 | lastEventId: 35, 222 | complete: true, 223 | }); 224 | await Promise.all(createdPixels.map(async p => { 225 | await flagSnapshotPixelRepository.createAndReturn({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag, snapshotId: snapshot._id.toHexString() }) 226 | }) as any); 227 | 228 | const latestSnapshot = await flagSnapshotService.getLatestSnapshot(); 229 | expect(latestSnapshot.lastEventId).toEqual(35); 230 | expect(latestSnapshot.pixels).toEqual(createdPixels.map(p => ({ author: p.author, hexColor: p.hexColor, entityId: p.entityId, indexInFlag: p.indexInFlag }))); 231 | }); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /back/flag-service/test/user.spec-e2e.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../src/user/UserService"; 2 | import request from "supertest"; 3 | import { registerAndLogin } from "./util/registerAndLogin"; 4 | import { v4 } from "uuid"; 5 | import { INestApplication } from "@nestjs/common"; 6 | import { DatabaseClientService } from "library/database/client/DatabaseClientService"; 7 | import { bootstrap } from "../src/bootstrap"; 8 | import { AuthBackend } from "../src/user/AuthBackend"; 9 | 10 | 11 | describe('User', () => { 12 | let savedEnvAuthBackend: string; 13 | 14 | beforeAll(() => { 15 | savedEnvAuthBackend = process.env.AUTH_BACKEND; 16 | }); 17 | 18 | afterAll(() => { 19 | process.env.AUTH_BACKEND = savedEnvAuthBackend; 20 | }); 21 | 22 | async function startTestApp(authBackend: AuthBackend) { 23 | process.env.AUTH_BACKEND = authBackend; 24 | const app = await bootstrap(0); 25 | const dbService = app.get('DATABASE_CLIENT'); 26 | await dbService.getDb().collection('users').deleteMany({}); 27 | return app; 28 | } 29 | 30 | describe('Fouloscopie auth backend', () => { 31 | let app: INestApplication; 32 | 33 | beforeAll(async () => { 34 | app = await startTestApp(AuthBackend.FOULOSCOPIE); 35 | }); 36 | afterAll(async () => { 37 | await app.close(); 38 | }); 39 | 40 | for (const route of [ 41 | '/user/register', 42 | '/user/login', 43 | ]) { 44 | test(`POST ${route} is not loaded and returns a 404`, async () => { 45 | const res = await request(app.getHttpServer()) 46 | .post(route) 47 | .send({}); 48 | expect(res.status).toBe(404); 49 | }); 50 | } 51 | 52 | for (const route of [ 53 | '/user/change-password', 54 | '/user/change-nickname', 55 | ]) { 56 | test(`PUT ${route} is not loaded and returns a 404`, async () => { 57 | const res = await request(app.getHttpServer()) 58 | .put(route) 59 | .send({}); 60 | expect(res.status).toBe(404); 61 | }); 62 | } 63 | }); 64 | 65 | describe('Internal auth backend', () => { 66 | let app: INestApplication; 67 | let userService: UserService; 68 | 69 | beforeAll(async () => { 70 | app = await startTestApp(AuthBackend.INTERNAL); 71 | userService = app.get(UserService); 72 | }); 73 | afterAll(async () => { 74 | await app.close(); 75 | }); 76 | 77 | describe('POST /user/register', () => { 78 | it('success', async () => { 79 | const res = await request(app.getHttpServer()) 80 | .post('/user/register') 81 | .send({ 82 | email: 'user@example.com', 83 | password: 'password123', 84 | passwordConfirmation: 'password123', 85 | nickname: 'jane', 86 | }); 87 | 88 | expect(res.status).toBe(201); 89 | expect(res.body).toStrictEqual({ success: true }); 90 | }); 91 | 92 | describe('failure', () => { 93 | function testBadValues(name: string, email: string, password: string, passwordConfirmation: string, nickname: string) { 94 | describe(name, () => { 95 | let registerSpy; 96 | let res; 97 | beforeAll(async () => { 98 | registerSpy = jest.spyOn(userService, 'register'); 99 | res = await request(app.getHttpServer()) 100 | .post('/user/register') 101 | .send({ 102 | email: email, 103 | password: password, 104 | passwordConfirmation: passwordConfirmation, 105 | nickname: nickname, 106 | }); 107 | }); 108 | it('responds with a Bad Request HTTP Error', async () => { 109 | expect(res.status).toBe(400); 110 | }); 111 | it('doesn\'t call register from service', () => { 112 | expect(registerSpy).not.toBeCalled(); 113 | }); 114 | }); 115 | } 116 | 117 | describe('email', () => { 118 | testBadValues('invalid email', 'not a valid email address', 'password123', 'password123', 'jane'); 119 | }); 120 | 121 | describe('password', () => { 122 | testBadValues('too short', 'user@example.com', 'abc12', 'abc12', 'jane'); 123 | testBadValues('no number', 'user@example.com', 'password', 'password', 'jane'); 124 | testBadValues('no letter', 'user@example.com', '1239009384657493', '1239009384657493', 'jane'); 125 | }); 126 | 127 | describe('passwordConfirmation', () => { 128 | testBadValues('incorrect', 'user@example.com', 'password123', 'password123 incorrect', 'jane'); 129 | }); 130 | 131 | describe('nickname', () => { 132 | testBadValues('too short', 'user@example.com', 'password123', 'password123', 'ab'); 133 | testBadValues('has spaces', 'user@example.com', 'password123', 'password123', ' has spaces '); 134 | for (const invalidCharacter of ['!', '@', '+', '~', '$', '#', '%', '^', '&', '*']) { 135 | testBadValues(`invalid character ${invalidCharacter}`, 'user@example.com', 'password123', 'password123', `nick${invalidCharacter}name`); 136 | } 137 | }); 138 | }); 139 | }); 140 | describe('POST /user/login', () => { 141 | it('success', async () => { 142 | const res = await request(app.getHttpServer()) 143 | .post('/user/login') 144 | .send({ 145 | email: 'user@example.com', 146 | password: 'password123', 147 | }); 148 | 149 | expect(res.status).toBe(201); 150 | }); 151 | 152 | describe('failure', function () { 153 | function testBadValues(name: string, email: string, password: string) { 154 | describe(name, () => { 155 | let loginSpy; 156 | let res; 157 | beforeAll(async () => { 158 | loginSpy = jest.spyOn(userService, 'login'); 159 | res = await request(app.getHttpServer()) 160 | .post('/user/login') 161 | .send({ 162 | email: email, 163 | password: password, 164 | }); 165 | }); 166 | it('responds with a Bad Request HTTP Error', async () => { 167 | expect(res.status).toBe(400); 168 | }); 169 | it('doesn\'t call login from service', () => { 170 | expect(loginSpy).not.toBeCalled(); 171 | }); 172 | }); 173 | } 174 | 175 | describe('email', () => { 176 | testBadValues('invalid email', 'not a valid email address', 'password123'); 177 | }); 178 | 179 | describe('password', () => { 180 | testBadValues('too short', 'user@example.com', 'abc12'); 181 | testBadValues('no number', 'user@example.com', 'password'); 182 | testBadValues('no letter', 'user@example.com', '1239009384657493'); 183 | }); 184 | }); 185 | }); 186 | describe('PUT /user/change-password', () => { 187 | let jwt: string; 188 | 189 | beforeAll(async () => { 190 | const res = await registerAndLogin(app, v4() + '@example.com', 'password123', v4()); 191 | jwt = res.jwt; 192 | }); 193 | 194 | it('success', async () => { 195 | const res = await request(app.getHttpServer()) 196 | .put('/user/change-password') 197 | .set('authorization', jwt) 198 | .send({ 199 | currentPassword: 'password123', 200 | newPassword: 'newPassword999', 201 | newPasswordConfirmation: 'newPassword999', 202 | }); 203 | 204 | expect(res.status).toBe(200); 205 | }); 206 | 207 | describe('failure', () => { 208 | beforeAll(async () => { 209 | const res = await registerAndLogin(app, v4() + '@example.com', 'password123', v4()); 210 | jwt = res.jwt; 211 | }); 212 | 213 | function testBadValues(name: string, currentPassword: string, newPassword: string, newPasswordConfirmation: string) { 214 | describe(name, () => { 215 | let changePasswordSpy; 216 | let res; 217 | beforeAll(async () => { 218 | changePasswordSpy = jest.spyOn(userService, 'changePassword'); 219 | res = await request(app.getHttpServer()) 220 | .put('/user/change-password') 221 | .set('authorization', jwt) 222 | .send({ 223 | currentPassword: currentPassword, 224 | newPassword: newPassword, 225 | newPasswordConfirmation: newPasswordConfirmation, 226 | }); 227 | }); 228 | it('responds with a Bad Request HTTP Error', async () => { 229 | expect(res.status).toBe(400); 230 | }); 231 | it('doesn\'t call register from service', () => { 232 | expect(changePasswordSpy).not.toBeCalled(); 233 | }); 234 | }); 235 | } 236 | 237 | describe('currentPassword', () => { 238 | testBadValues('too short', 'abc12', 'password123', 'password123'); 239 | testBadValues('no number', 'password', 'password123', 'password123'); 240 | testBadValues('no letter', '1239009384657493', 'password123', 'password123'); 241 | }); 242 | 243 | describe('newPassword', () => { 244 | testBadValues('too short', 'password123', 'abc12', 'abc12'); 245 | testBadValues('no number', 'password123', 'password', 'password'); 246 | testBadValues('no letter', 'password123', '1239009384657493', '1239009384657493'); 247 | }); 248 | 249 | describe('newPasswordConfirmation', () => { 250 | testBadValues('incorrect', 'password123', 'password123', 'password123 incorrect'); 251 | }); 252 | }); 253 | }); 254 | describe('PUT /user/change-nickname', () => { 255 | let jwt: string; 256 | beforeAll(async () => { 257 | const res = await registerAndLogin(app, v4() + '@example.com', 'password123', v4()); 258 | jwt = res.jwt; 259 | }); 260 | 261 | it('success', async () => { 262 | const res = await request(app.getHttpServer()) 263 | .put('/user/change-nickname') 264 | .set('authorization', jwt) 265 | .send({ 266 | jwt: jwt, 267 | password: 'password123', 268 | newNickname: 'jack76', 269 | }); 270 | 271 | expect(res.status).toBe(200); 272 | }); 273 | 274 | describe('failure', function () { 275 | beforeAll(async () => { 276 | const res = await registerAndLogin(app, v4() + '@example.com', 'password123', v4()); 277 | jwt = res.jwt; 278 | }); 279 | 280 | describe('bad field values', () => { 281 | function testBadValues(name: string, password: string, newNickname: string) { 282 | describe(name, () => { 283 | let changeNicknameSpy; 284 | let res; 285 | beforeAll(async () => { 286 | changeNicknameSpy = jest.spyOn(userService, 'changeNickname'); 287 | res = await request(app.getHttpServer()) 288 | .put('/user/change-nickname') 289 | .set('authorization', jwt) 290 | .send({ 291 | password: password, 292 | newNickname: newNickname, 293 | }); 294 | }); 295 | it('responds with a Bad Request HTTP Error', async () => { 296 | expect(res.status).toBe(400); 297 | }); 298 | it('doesn\'t call changeNickname from service', () => { 299 | expect(changeNicknameSpy).not.toBeCalled(); 300 | }); 301 | }); 302 | } 303 | 304 | describe('password', () => { 305 | testBadValues('too short', 'abc12', 'nickname'); 306 | testBadValues('no number', 'password', 'nickname'); 307 | testBadValues('no letter', '1239009384657493', 'nickname'); 308 | }); 309 | 310 | describe('newNickname', () => { 311 | testBadValues('too short', 'password123', 'ab'); 312 | testBadValues('has spaces', 'password123', ' has spaces '); 313 | for (const invalidCharacter of ['!', '@', '+', '~', '$', '#', '%', '^', '&', '*']) { 314 | testBadValues(`invalid character ${invalidCharacter}`, 'password123', `nick${invalidCharacter}name`); 315 | } 316 | }); 317 | }); 318 | }); 319 | }); 320 | }); 321 | 322 | 323 | }); 324 | --------------------------------------------------------------------------------