Compare commits
94 Commits
5665fcd98f
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8eaad3f81
|
||
|
|
5f176def8c
|
||
|
|
9ef6bbfd96
|
||
|
|
61b25f7b9e
|
||
|
|
d0286d51ff
|
||
|
|
2291cc8afb
|
||
|
|
bad2caef08
|
||
|
|
ac4568a0f0
|
||
|
|
a11a332eaa
|
||
|
|
02c00e8aae
|
||
|
|
2886e50a0c
|
||
|
|
59a5cc941e
|
||
|
|
78db4b1c34
|
||
|
|
b177bee75c
|
||
|
|
0cd6509273
|
||
|
|
05a56ff87d
|
||
|
|
3fa11474c1
|
||
|
|
4c12c5c5cb
|
||
|
|
48dbdbfdcc
|
||
|
|
002a6b912a
|
||
|
|
733ffbff31
|
||
|
|
4700526dd2
|
||
|
|
2450977e61
|
||
|
|
afc18b555a
|
||
|
|
9699127739
|
||
|
|
938d8bde7b
|
||
|
|
65c7096f46
|
||
|
|
57c00ad4d1
|
||
|
|
39618f7708
|
||
|
|
e84e4a5a9d
|
||
|
|
e74973a9d0
|
||
|
|
9233c1bf89
|
||
|
|
88c7f45a2c
|
||
|
|
9af72156f5
|
||
|
|
597a4d615e
|
||
|
|
2df45af305
|
||
|
|
863a4bf528
|
||
|
|
9a1cdb05a4
|
||
|
|
28caf92f9a
|
||
|
|
8b2728dc5a
|
||
|
|
3bbbbc307f
|
||
|
|
f080919563
|
||
|
|
edc1ab2438
|
||
|
|
01b66d6f2f
|
||
|
|
9a70dd02bb
|
||
|
|
e285a4e634
|
||
|
|
f247a01ac7
|
||
|
|
bb640cd8f9
|
||
|
|
c1118e9f25
|
||
|
|
eae1f84b92
|
||
|
|
8d27532dc0
|
||
|
|
f79507730e
|
||
|
|
7048c2731e
|
||
|
|
d74fd15036
|
||
|
|
86a697c392
|
||
|
|
38adbb6e77
|
||
| 594a387712 | |||
|
|
4ca15b578d
|
||
| 2912231769 | |||
|
|
db17994bb5
|
||
|
|
f57e028178
|
||
|
|
e84aa8a8db
|
||
|
|
c6b23de481
|
||
|
|
0611ef715c
|
||
|
|
0a1391674f
|
||
|
|
2fedaca502
|
||
|
|
a6837ff7fb
|
||
|
|
74b61004e7
|
||
|
|
760343da76
|
||
|
|
14f8b8b63d
|
||
|
|
50a186da1d
|
||
|
|
3908989b39
|
||
|
|
02d70f27ea
|
||
|
|
65f8860cc0
|
||
|
|
0e9edd4bfc
|
||
|
|
6ce58d1639
|
||
|
|
47d6fcb6a0
|
||
|
|
d7c2a965a0
|
||
|
|
fb7ddde42e
|
||
|
|
026aebaee3
|
||
|
|
a30113e8e2
|
||
| f10c444957 | |||
|
|
975e29dea1
|
||
|
|
a4ce48a91c
|
||
|
|
ff6fc1c6b3
|
||
|
|
5671ba60a6
|
||
|
|
5f2672021e
|
||
| 17c2cea366 | |||
| cb6d87eafd | |||
| 570576435c | |||
| c19d86a0cb | |||
| 6756cf6bc7 | |||
| de537e5947 | |||
| 9097a3e9b5 |
@@ -1,22 +0,0 @@
|
||||
name: Backend Tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run Backend Tests
|
||||
run: pnpm -F @memegoat/backend test
|
||||
111
.gitea/workflows/ci.yml
Normal file
111
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
# Pipeline CI/CD pour Gitea Actions (Forgejo)
|
||||
# Compatible avec GitHub Actions pour la portabilité
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Valider ${{ matrix.component }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
component: [backend, frontend, documentation]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Installer pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Configurer Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Obtenir le chemin du store pnpm
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||
|
||||
- name: Configurer le cache pnpm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Installer les dépendances
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Lint ${{ matrix.component }}
|
||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
||||
|
||||
- name: Tester ${{ matrix.component }}
|
||||
if: matrix.component == 'backend' || matrix.component == 'frontend'
|
||||
run: |
|
||||
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
|
||||
pnpm -F @memegoat/${{ matrix.component }} test
|
||||
else
|
||||
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
|
||||
fi
|
||||
|
||||
- name: Build ${{ matrix.component }}
|
||||
run: pnpm -F @memegoat/${{ matrix.component }} build
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
|
||||
deploy:
|
||||
name: Déploiement en Production
|
||||
needs: validate
|
||||
# Déclenchement uniquement sur push sur main ou tag de version
|
||||
# Gitea supporte le contexte 'github' pour la compatibilité
|
||||
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Vérifier l'environnement Docker
|
||||
run: |
|
||||
docker version
|
||||
docker compose version
|
||||
|
||||
- name: Déployer avec Docker Compose
|
||||
run: |
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
env:
|
||||
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
||||
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
REDIS_HOST: ${{ secrets.REDIS_HOST }}
|
||||
REDIS_PORT: ${{ secrets.REDIS_PORT }}
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
||||
S3_PORT: ${{ secrets.S3_PORT }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
|
||||
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
|
||||
MAIL_HOST: ${{ secrets.MAIL_HOST }}
|
||||
MAIL_PASS: ${{ secrets.MAIL_PASS }}
|
||||
MAIL_USER: ${{ secrets.MAIL_USER }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
@@ -1,87 +0,0 @@
|
||||
name: Deploy to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint - Backend
|
||||
run: pnpm run lint:back
|
||||
|
||||
- name: Build - Backend
|
||||
run: pnpm run build:back
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
|
||||
- name: Lint - Frontend
|
||||
run: pnpm run lint:front
|
||||
|
||||
- name: Build - Frontend
|
||||
run: pnpm run build:front
|
||||
|
||||
- name: Lint - Documentation
|
||||
run: pnpm run lint:docs
|
||||
|
||||
- name: Build - Documentation
|
||||
run: pnpm run build:docs
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
env:
|
||||
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
||||
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
||||
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
REDIS_HOST: ${{ secrets.REDIS_HOST }}
|
||||
REDIS_PORT: ${{ secrets.REDIS_PORT }}
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
||||
S3_PORT: ${{ secrets.S3_PORT }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
|
||||
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
|
||||
MAIL_HOST: ${{ secrets.MAIL_HOST }}
|
||||
MAIL_PASS: ${{ secrets.MAIL_PASS }}
|
||||
MAIL_USER: ${{ secrets.MAIL_USER }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- 'documentation/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Lint Frontend
|
||||
if: success() || failure()
|
||||
run: pnpm -F @memegoat/frontend lint
|
||||
- name: Lint Backend
|
||||
if: success() || failure()
|
||||
run: pnpm -F @memegoat/backend lint
|
||||
- name: Lint Documentation
|
||||
if: success() || failure()
|
||||
run: pnpm -F @bypass/documentation lint
|
||||
50
ROADMAP.md
Normal file
50
ROADMAP.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 🐐 Memegoat - Roadmap & Critères de Production
|
||||
|
||||
Ce document définit les objectifs, les critères techniques et les fonctionnalités à atteindre pour que le projet Memegoat soit considéré comme prêt pour la production et conforme aux normes européennes (RGPD) et françaises.
|
||||
|
||||
## 1. 🏗️ Architecture & Infrastructure
|
||||
- [x] Backend NestJS (TypeScript)
|
||||
- [x] Base de données PostgreSQL avec Drizzle ORM
|
||||
- [x] Stockage d'objets compatible S3 (MinIO)
|
||||
- [x] Service d'Emailing (Nodemailer / SMTPS)
|
||||
- [x] Documentation Technique & Référence API (`docs.memegoat.fr`)
|
||||
- [x] Health Checks (`/health`)
|
||||
- [x] Gestion des variables d'environnement (Validation avec Zod)
|
||||
- [ ] CI/CD (Build, Lint, Test, Deploy)
|
||||
|
||||
## 2. 🔐 Sécurité & Authentification
|
||||
- [x] Hachage des mots de passe (Argon2id)
|
||||
- [x] Gestion des sessions robuste (JWT avec Refresh Token et Rotation)
|
||||
- [x] RBAC (Role Based Access Control) fonctionnel
|
||||
- [x] Système de Clés API (Hachées en base)
|
||||
- [x] Double Authentification (2FA / TOTP)
|
||||
- [x] Limitation de débit (Rate Limiting / Throttler)
|
||||
- [x] Validation stricte des entrées (DTOs + ValidationPipe)
|
||||
- [x] Protection contre les vulnérabilités OWASP (Helmet, CORS)
|
||||
|
||||
## 3. ⚖️ Conformité RGPD (EU & France)
|
||||
- [x] Chiffrement natif des données personnelles (PII) via PGP (pgcrypto)
|
||||
- [x] Hachage aveugle (Blind Indexing) pour l'email (recherche/unicité)
|
||||
- [x] Journalisation d'audit complète (Audit Logs) pour les actions sensibles
|
||||
- [x] Gestion du consentement (Versionnage CGU/Politique de Confidentialité)
|
||||
- [x] Droit à l'effacement : Flux de suppression (Soft Delete -> Purge définitive)
|
||||
- [x] Droit à la portabilité : Export des données utilisateur (JSON)
|
||||
- [x] Purge automatique des données obsolètes (Signalements, Sessions expirées)
|
||||
- [x] Anonymisation des adresses IP (Hachage) dans les logs
|
||||
|
||||
## 4. 🖼️ Fonctionnalités Coeur (Media & Galerie)
|
||||
- [x] Exploration (Trends, Recent, Favoris)
|
||||
- [x] Recherche par Tags, Catégories, Auteur, Texte
|
||||
- [x] Gestion des Favoris
|
||||
- [x] Upload sécurisé via S3 (URLs présignées)
|
||||
- [x] Scan Antivirus (ClamAV) et traitement des médias (WebP, WebM, AVIF, AV1)
|
||||
- [x] Limitation de la taille et des formats de fichiers entrants (Configurable)
|
||||
- [x] Système de Signalement (Reports) et workflow de modération
|
||||
- [ ] SEO : Metatags dynamiques et slugs sémantiques
|
||||
|
||||
## 5. ✅ Qualité & Robustesse
|
||||
- [ ] Couverture de tests unitaires (Jest) > 80%
|
||||
- [ ] Tests d'intégration et E2E
|
||||
- [x] Gestion centralisée des erreurs (Filters NestJS)
|
||||
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
|
||||
- [x] Performance : Cache (Redis) pour les tendances et recherches fréquentes
|
||||
1
backend/.migrations/0005_perpetual_silverclaw.sql
Normal file
1
backend/.migrations/0005_perpetual_silverclaw.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(100);
|
||||
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "bio" varchar(255);
|
||||
1640
backend/.migrations/meta/0005_snapshot.json
Normal file
1640
backend/.migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,20 @@
|
||||
"when": 1768417827439,
|
||||
"tag": "0004_cheerful_dakota_north",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1768420201679,
|
||||
"tag": "0005_perpetual_silverclaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1768423315172,
|
||||
"tag": "0006_friendly_adam_warlock",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:22-slim AS base
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
@@ -9,10 +10,17 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY documentation/package.json ./documentation/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Utilisation du cache pour pnpm et installation figée
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
RUN pnpm run --filter @memegoat/backend build
|
||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||
RUN cp -r backend/dist /app/dist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -107,7 +107,7 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(jose|@noble)/)"
|
||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
|
||||
62
backend/src/admin/admin.controller.spec.ts
Normal file
62
backend/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
describe("AdminController", () => {
|
||||
let controller: AdminController;
|
||||
let service: AdminService;
|
||||
|
||||
const mockAdminService = {
|
||||
getStats: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
providers: [{ provide: AdminService, useValue: mockAdminService }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
service = module.get<AdminService>(AdminService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getStats", () => {
|
||||
it("should call service.getStats", async () => {
|
||||
await controller.getStats();
|
||||
expect(service.getStats).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
17
backend/src/admin/admin.controller.ts
Normal file
17
backend/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get("stats")
|
||||
getStats() {
|
||||
return this.adminService.getStats();
|
||||
}
|
||||
}
|
||||
14
backend/src/admin/admin.module.ts
Normal file
14
backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CategoriesModule } from "../categories/categories.module";
|
||||
import { ContentsModule } from "../contents/contents.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
58
backend/src/admin/admin.service.spec.ts
Normal file
58
backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CategoriesRepository } from "../categories/repositories/categories.repository";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { UsersRepository } from "../users/repositories/users.repository";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
describe("AdminService", () => {
|
||||
let service: AdminService;
|
||||
let _usersRepository: UsersRepository;
|
||||
let _contentsRepository: ContentsRepository;
|
||||
let _categoriesRepository: CategoriesRepository;
|
||||
|
||||
const mockUsersRepository = {
|
||||
countAll: jest.fn(),
|
||||
};
|
||||
|
||||
const mockContentsRepository = {
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCategoriesRepository = {
|
||||
countAll: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdminService,
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||
{ provide: CategoriesRepository, useValue: mockCategoriesRepository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminService>(AdminService);
|
||||
_usersRepository = module.get<UsersRepository>(UsersRepository);
|
||||
_contentsRepository = module.get<ContentsRepository>(ContentsRepository);
|
||||
_categoriesRepository =
|
||||
module.get<CategoriesRepository>(CategoriesRepository);
|
||||
});
|
||||
|
||||
it("should return stats", async () => {
|
||||
mockUsersRepository.countAll.mockResolvedValue(10);
|
||||
mockContentsRepository.count.mockResolvedValue(20);
|
||||
mockCategoriesRepository.countAll.mockResolvedValue(5);
|
||||
|
||||
const result = await service.getStats();
|
||||
|
||||
expect(result).toEqual({
|
||||
users: 10,
|
||||
contents: 20,
|
||||
categories: 5,
|
||||
});
|
||||
expect(mockUsersRepository.countAll).toHaveBeenCalled();
|
||||
expect(mockContentsRepository.count).toHaveBeenCalledWith({});
|
||||
expect(mockCategoriesRepository.countAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
27
backend/src/admin/admin.service.ts
Normal file
27
backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CategoriesRepository } from "../categories/repositories/categories.repository";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { UsersRepository } from "../users/repositories/users.repository";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly contentsRepository: ContentsRepository,
|
||||
private readonly categoriesRepository: CategoriesRepository,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
const [userCount, contentCount, categoryCount] = await Promise.all([
|
||||
this.usersRepository.countAll(),
|
||||
this.contentsRepository.count({}),
|
||||
this.categoriesRepository.countAll(),
|
||||
]);
|
||||
|
||||
return {
|
||||
users: userCount,
|
||||
contents: contentCount,
|
||||
categories: categoryCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ApiKeysController } from "./api-keys.controller";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
|
||||
describe("ApiKeysController", () => {
|
||||
let controller: ApiKeysController;
|
||||
let service: ApiKeysService;
|
||||
|
||||
const mockApiKeysService = {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ApiKeysController],
|
||||
providers: [{ provide: ApiKeysService, useValue: mockApiKeysService }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ApiKeysController>(ApiKeysController);
|
||||
service = module.get<ApiKeysService>(ApiKeysService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should call service.create", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { name: "Key Name", expiresAt: "2026-01-20T12:00:00Z" };
|
||||
await controller.create(req, dto);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"Key Name",
|
||||
new Date(dto.expiresAt),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call service.create without expiresAt", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { name: "Key Name" };
|
||||
await controller.create(req, dto);
|
||||
expect(service.create).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"Key Name",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should call service.findAll", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.findAll(req);
|
||||
expect(service.findAll).toHaveBeenCalledWith("user-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revoke", () => {
|
||||
it("should call service.revoke", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.revoke(req, "key-id");
|
||||
expect(service.revoke).toHaveBeenCalledWith("user-uuid", "key-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
import { CreateApiKeyDto } from "./dto/create-api-key.dto";
|
||||
|
||||
@Controller("api-keys")
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -20,13 +21,12 @@ export class ApiKeysController {
|
||||
@Post()
|
||||
create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body("name") name: string,
|
||||
@Body("expiresAt") expiresAt?: string,
|
||||
@Body() createApiKeyDto: CreateApiKeyDto,
|
||||
) {
|
||||
return this.apiKeysService.create(
|
||||
req.user.sub,
|
||||
name,
|
||||
expiresAt ? new Date(expiresAt) : undefined,
|
||||
createApiKeyDto.name,
|
||||
createApiKeyDto.expiresAt ? new Date(createApiKeyDto.expiresAt) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { ApiKeysController } from "./api-keys.controller";
|
||||
import { ApiKeysService } from "./api-keys.service";
|
||||
import { ApiKeysRepository } from "./repositories/api-keys.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService, ApiKeysRepository],
|
||||
exports: [ApiKeysService, ApiKeysRepository],
|
||||
|
||||
18
backend/src/api-keys/dto/create-api-key.dto.ts
Normal file
18
backend/src/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { ApiKeysRepository } from "./api-keys.repository";
|
||||
|
||||
describe("ApiKeysRepository", () => {
|
||||
let repository: ApiKeysRepository;
|
||||
let _databaseService: DatabaseService;
|
||||
|
||||
const mockDb = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapWithThen = (obj: unknown) => {
|
||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||
Object.defineProperty(obj, "then", {
|
||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||
const result = (this as any).execute();
|
||||
return Promise.resolve(result).then(onFulfilled);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
wrapWithThen(mockDb);
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeysRepository,
|
||||
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
|
||||
_databaseService = module.get<DatabaseService>(DatabaseService);
|
||||
});
|
||||
|
||||
it("should create an api key", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.create({
|
||||
userId: "u1",
|
||||
name: "n",
|
||||
prefix: "p",
|
||||
keyHash: "h",
|
||||
});
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should find all keys for user", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findAll("u1");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should revoke a key", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||
{ id: "1", isActive: false },
|
||||
]);
|
||||
const result = await repository.revoke("u1", "k1");
|
||||
expect(result[0].isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should find active by hash", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findActiveByKeyHash("h");
|
||||
expect(result.id).toBe("1");
|
||||
});
|
||||
|
||||
it("should update last used", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.updateLastUsed("1");
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { redisStore } from "cache-manager-redis-yet";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||
import { validateEnv } from "./config/env.schema";
|
||||
import { ContentsModule } from "./contents/contents.module";
|
||||
import { CryptoModule } from "./crypto/crypto.module";
|
||||
@@ -41,6 +44,7 @@ import { UsersModule } from "./users/users.module";
|
||||
SessionsModule,
|
||||
ReportsModule,
|
||||
ApiKeysModule,
|
||||
AdminModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -71,4 +75,10 @@ import { UsersModule } from "./users/users.module";
|
||||
controllers: [AppController, HealthController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
|
||||
.forRoutes("*");
|
||||
}
|
||||
}
|
||||
|
||||
185
backend/src/auth/auth.controller.spec.ts
Normal file
185
backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
jest.mock("iron-session", () => ({
|
||||
getIronSession: jest.fn().mockResolvedValue({
|
||||
save: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AuthController", () => {
|
||||
let controller: AuthController;
|
||||
let authService: AuthService;
|
||||
let _configService: ConfigService;
|
||||
|
||||
const mockAuthService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
verifyTwoFactorLogin: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest
|
||||
.fn()
|
||||
.mockReturnValue("complex_password_at_least_32_characters_long"),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
_configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("should call authService.register", async () => {
|
||||
const dto = {
|
||||
email: "test@example.com",
|
||||
password: "password",
|
||||
username: "test",
|
||||
};
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to avoid defining full DTO in test
|
||||
await controller.register(dto as any);
|
||||
expect(authService.register).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("should call authService.login and setup session if success", async () => {
|
||||
const dto = { email: "test@example.com", password: "password" };
|
||||
const req = { ip: "127.0.0.1" } as any;
|
||||
const res = { json: jest.fn() } as any;
|
||||
const loginResult = {
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
userId: "1",
|
||||
message: "ok",
|
||||
};
|
||||
mockAuthService.login.mockResolvedValue(loginResult);
|
||||
|
||||
await controller.login(dto as any, "ua", req, res);
|
||||
|
||||
expect(authService.login).toHaveBeenCalledWith(dto, "ua", "127.0.0.1");
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "ok", userId: "1" });
|
||||
});
|
||||
|
||||
it("should return result if no access_token", async () => {
|
||||
const dto = { email: "test@example.com", password: "password" };
|
||||
const req = { ip: "127.0.0.1" } as any;
|
||||
const res = { json: jest.fn() } as any;
|
||||
const loginResult = { message: "2fa_required", userId: "1" };
|
||||
mockAuthService.login.mockResolvedValue(loginResult);
|
||||
|
||||
await controller.login(dto as any, "ua", req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(loginResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTwoFactor", () => {
|
||||
it("should call authService.verifyTwoFactorLogin and setup session", async () => {
|
||||
const dto = { userId: "1", token: "123456" };
|
||||
const req = { ip: "127.0.0.1" } as any;
|
||||
const res = { json: jest.fn() } as any;
|
||||
const verifyResult = {
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
message: "ok",
|
||||
};
|
||||
mockAuthService.verifyTwoFactorLogin.mockResolvedValue(verifyResult);
|
||||
|
||||
await controller.verifyTwoFactor(dto, "ua", req, res);
|
||||
|
||||
expect(authService.verifyTwoFactorLogin).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"123456",
|
||||
"ua",
|
||||
"127.0.0.1",
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should refresh token if session has refresh token", async () => {
|
||||
const { getIronSession } = require("iron-session");
|
||||
const session = { refreshToken: "rt", save: jest.fn() };
|
||||
getIronSession.mockResolvedValue(session);
|
||||
const req = {} as any;
|
||||
const res = { json: jest.fn() } as any;
|
||||
mockAuthService.refresh.mockResolvedValue({
|
||||
access_token: "at2",
|
||||
refresh_token: "rt2",
|
||||
});
|
||||
|
||||
await controller.refresh(req, res);
|
||||
|
||||
expect(authService.refresh).toHaveBeenCalledWith("rt");
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Token refreshed" });
|
||||
});
|
||||
|
||||
it("should return 401 if no refresh token", async () => {
|
||||
const { getIronSession } = require("iron-session");
|
||||
const session = { save: jest.fn() };
|
||||
getIronSession.mockResolvedValue(session);
|
||||
const req = {} as any;
|
||||
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any;
|
||||
|
||||
await controller.refresh(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("should destroy session", async () => {
|
||||
const { getIronSession } = require("iron-session");
|
||||
const session = { destroy: jest.fn() };
|
||||
getIronSession.mockResolvedValue(session);
|
||||
const req = {} as any;
|
||||
const res = { json: jest.fn() } as any;
|
||||
|
||||
await controller.logout(req, res);
|
||||
|
||||
expect(session.destroy).toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "User logged out" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,32 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { SessionsModule } from "../sessions/sessions.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthGuard } from "./guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||
import { RolesGuard } from "./guards/roles.guard";
|
||||
import { RbacService } from "./rbac.service";
|
||||
import { RbacRepository } from "./repositories/rbac.repository";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => UsersModule),
|
||||
CryptoModule,
|
||||
SessionsModule,
|
||||
DatabaseModule,
|
||||
],
|
||||
imports: [forwardRef(() => UsersModule), SessionsModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, RbacService, RbacRepository],
|
||||
exports: [AuthService, RbacService, RbacRepository],
|
||||
providers: [
|
||||
AuthService,
|
||||
RbacService,
|
||||
RbacRepository,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
RbacService,
|
||||
RbacRepository,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
|
||||
@@ -110,6 +110,7 @@ export class AuthService {
|
||||
const user = await this.usersService.findByEmailHash(emailHash);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed: user not found for email hash`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -119,10 +120,12 @@ export class AuthService {
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.isTwoFactorEnabled) {
|
||||
this.logger.log(`2FA required for user ${user.uuid}`);
|
||||
return {
|
||||
message: "2FA required",
|
||||
requires2FA: true,
|
||||
@@ -141,6 +144,7 @@ export class AuthService {
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${user.uuid} logged in successfully`);
|
||||
return {
|
||||
message: "User logged in successfully",
|
||||
access_token: accessToken,
|
||||
@@ -165,6 +169,9 @@ export class AuthService {
|
||||
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
this.logger.warn(
|
||||
`2FA verification failed for user ${userId}: invalid token`,
|
||||
);
|
||||
throw new UnauthorizedException("Invalid 2FA token");
|
||||
}
|
||||
|
||||
@@ -179,6 +186,7 @@ export class AuthService {
|
||||
ip,
|
||||
);
|
||||
|
||||
this.logger.log(`User ${userId} logged in successfully via 2FA`);
|
||||
return {
|
||||
message: "User logged in successfully (2FA)",
|
||||
access_token: accessToken,
|
||||
|
||||
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
import { AuthGuard } from "./auth.guard";
|
||||
|
||||
jest.mock("jose", () => ({}));
|
||||
jest.mock("iron-session", () => ({
|
||||
getIronSession: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("AuthGuard", () => {
|
||||
let guard: AuthGuard;
|
||||
let _jwtService: JwtService;
|
||||
let _configService: ConfigService;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyJwt: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn().mockReturnValue("session-password"),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthGuard,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<AuthGuard>(AuthGuard);
|
||||
_jwtService = module.get<JwtService>(JwtService);
|
||||
_configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should return true for valid token", async () => {
|
||||
const request = { user: null };
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({
|
||||
accessToken: "valid-token",
|
||||
});
|
||||
mockJwtService.verifyJwt.mockResolvedValue({ sub: "user1" });
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
expect(request.user).toEqual({ sub: "user1" });
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException if no token", async () => {
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({}),
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException if token invalid", async () => {
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({}),
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
|
||||
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
import { OptionalAuthGuard } from "./optional-auth.guard";
|
||||
|
||||
jest.mock("jose", () => ({}));
|
||||
jest.mock("iron-session", () => ({
|
||||
getIronSession: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("OptionalAuthGuard", () => {
|
||||
let guard: OptionalAuthGuard;
|
||||
let _jwtService: JwtService;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyJwt: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn().mockReturnValue("session-password"),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OptionalAuthGuard,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<OptionalAuthGuard>(OptionalAuthGuard);
|
||||
_jwtService = module.get<JwtService>(JwtService);
|
||||
});
|
||||
|
||||
it("should return true and set user for valid token", async () => {
|
||||
const request = { user: null };
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "valid" });
|
||||
mockJwtService.verifyJwt.mockResolvedValue({ sub: "u1" });
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
expect(request.user).toEqual({ sub: "u1" });
|
||||
});
|
||||
|
||||
it("should return true if no token", async () => {
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({}),
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true even if token invalid", async () => {
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: null }),
|
||||
getResponse: () => ({}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
|
||||
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
expect(context.switchToHttp().getRequest().user).toBeNull();
|
||||
});
|
||||
});
|
||||
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { JwtService } from "../../crypto/services/jwt.service";
|
||||
import { getSessionOptions, SessionData } from "../session.config";
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
const session = await getIronSession<SessionData>(
|
||||
request,
|
||||
response,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
const token = session.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyJwt(token);
|
||||
request.user = payload;
|
||||
} catch {
|
||||
// Ignore invalid tokens for optional auth
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RbacService } from "../rbac.service";
|
||||
import { RolesGuard } from "./roles.guard";
|
||||
|
||||
describe("RolesGuard", () => {
|
||||
let guard: RolesGuard;
|
||||
let _reflector: Reflector;
|
||||
let _rbacService: RbacService;
|
||||
|
||||
const mockReflector = {
|
||||
getAllAndOverride: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRbacService = {
|
||||
getUserRoles: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RolesGuard,
|
||||
{ provide: Reflector, useValue: mockReflector },
|
||||
{ provide: RbacService, useValue: mockRbacService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<RolesGuard>(RolesGuard);
|
||||
_reflector = module.get<Reflector>(Reflector);
|
||||
_rbacService = module.get<RbacService>(RbacService);
|
||||
});
|
||||
|
||||
it("should return true if no roles required", async () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(null);
|
||||
const context = {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
} as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||
const context = {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: null }),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if user has required role", async () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||
const context = {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: { sub: "u1" } }),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
mockRbacService.getUserRoles.mockResolvedValue(["admin", "user"]);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if user doesn't have required role", async () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||
const context = {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: { sub: "u1" } }),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
|
||||
mockRbacService.getUserRoles.mockResolvedValue(["user"]);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
105
backend/src/categories/categories.controller.spec.ts
Normal file
105
backend/src/categories/categories.controller.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
|
||||
describe("CategoriesController", () => {
|
||||
let controller: CategoriesController;
|
||||
let service: CategoriesService;
|
||||
|
||||
const mockCategoriesService = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CategoriesController],
|
||||
providers: [
|
||||
{ provide: CategoriesService, useValue: mockCategoriesService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<CategoriesController>(CategoriesController);
|
||||
service = module.get<CategoriesService>(CategoriesService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should call service.findAll", async () => {
|
||||
await controller.findAll();
|
||||
expect(service.findAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should call service.findOne", async () => {
|
||||
await controller.findOne("1");
|
||||
expect(service.findOne).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should call service.create", async () => {
|
||||
const dto = { name: "Cat", slug: "cat" };
|
||||
await controller.create(dto);
|
||||
expect(service.create).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should call service.update", async () => {
|
||||
const dto = { name: "New Name" };
|
||||
await controller.update("1", dto);
|
||||
expect(service.update).toHaveBeenCalledWith("1", dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.remove", async () => {
|
||||
await controller.remove("1");
|
||||
expect(service.remove).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
import { CategoriesRepository } from "./repositories/categories.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService, CategoriesRepository],
|
||||
exports: [CategoriesService, CategoriesRepository],
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { CategoriesRepository } from "./categories.repository";
|
||||
|
||||
describe("CategoriesRepository", () => {
|
||||
let repository: CategoriesRepository;
|
||||
|
||||
const mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapWithThen = (obj: unknown) => {
|
||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||
Object.defineProperty(obj, "then", {
|
||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||
const result = (this as any).execute();
|
||||
return Promise.resolve(result).then(onFulfilled);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
wrapWithThen(mockDb);
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CategoriesRepository,
|
||||
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<CategoriesRepository>(CategoriesRepository);
|
||||
});
|
||||
|
||||
it("should find all", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findAll();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should count all", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ count: 5 }]);
|
||||
const result = await repository.countAll();
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it("should find one", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findOne("1");
|
||||
expect(result.id).toBe("1");
|
||||
});
|
||||
|
||||
it("should create", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.create({ name: "C", slug: "s" });
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.update("1", { name: "N", updatedAt: new Date() });
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.remove("1");
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { categories } from "../../database/schemas";
|
||||
import type { CreateCategoryDto } from "../dto/create-category.dto";
|
||||
@@ -16,6 +16,13 @@ export class CategoriesRepository {
|
||||
.orderBy(categories.name);
|
||||
}
|
||||
|
||||
async countAll() {
|
||||
const result = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(categories);
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
interface RequestWithUser extends Request {
|
||||
user?: {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const request = ctx.getRequest<RequestWithUser>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
? exception.getResponse()
|
||||
: "Internal server error";
|
||||
|
||||
const userId = request.user?.sub || request.user?.id;
|
||||
const userPart = userId ? `[User: ${userId}] ` : "";
|
||||
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
Sentry.captureException(exception);
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
||||
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
|
||||
exception instanceof Error ? exception.stack : "",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface IMediaService {
|
||||
processImage(
|
||||
buffer: Buffer,
|
||||
format?: "webp" | "avif",
|
||||
resize?: { width?: number; height?: number },
|
||||
): Promise<MediaProcessingResult>;
|
||||
processVideo(
|
||||
buffer: Buffer,
|
||||
|
||||
@@ -33,4 +33,6 @@ export interface IStorageService {
|
||||
sourceBucketName?: string,
|
||||
destinationBucketName?: string,
|
||||
): Promise<string>;
|
||||
|
||||
getPublicUrl(storageKey: string): string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("CrawlerDetection");
|
||||
|
||||
private readonly SUSPICIOUS_PATTERNS = [
|
||||
/\.env/,
|
||||
/wp-admin/,
|
||||
/wp-login/,
|
||||
/\.git/,
|
||||
/\.php$/,
|
||||
/xmlrpc/,
|
||||
/config/,
|
||||
/setup/,
|
||||
/wp-config/,
|
||||
/_next/,
|
||||
/install/,
|
||||
/admin/,
|
||||
/phpmyadmin/,
|
||||
/sql/,
|
||||
/backup/,
|
||||
/db\./,
|
||||
/backup\./,
|
||||
/cgi-bin/,
|
||||
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
|
||||
];
|
||||
|
||||
private readonly BOT_USER_AGENTS = [
|
||||
/bot/i,
|
||||
/crawler/i,
|
||||
/spider/i,
|
||||
/python/i,
|
||||
/curl/i,
|
||||
/wget/i,
|
||||
/nmap/i,
|
||||
/nikto/i,
|
||||
/zgrab/i,
|
||||
/masscan/i,
|
||||
];
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const { method, url, ip } = req;
|
||||
const userAgent = req.get("user-agent") || "unknown";
|
||||
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode === 404) {
|
||||
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
||||
pattern.test(url),
|
||||
);
|
||||
const isBotUserAgent = this.BOT_USER_AGENTS.some((pattern) =>
|
||||
pattern.test(userAgent),
|
||||
);
|
||||
|
||||
if (isSuspiciousPath || isBotUserAgent) {
|
||||
this.logger.warn(
|
||||
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
||||
);
|
||||
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal file
37
backend/src/common/middlewares/http-logger.middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class HTTPLoggerMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("HTTP");
|
||||
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const { method, originalUrl, ip } = request;
|
||||
const userAgent = request.get("user-agent") || "";
|
||||
const startTime = Date.now();
|
||||
|
||||
response.on("finish", () => {
|
||||
const { statusCode } = response;
|
||||
const contentLength = response.get("content-length");
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const hashedIp = createHash("sha256")
|
||||
.update(ip as string)
|
||||
.digest("hex");
|
||||
const message = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${hashedIp} +${duration}ms`;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
return this.logger.error(message);
|
||||
}
|
||||
|
||||
if (statusCode >= 400) {
|
||||
return this.logger.warn(message);
|
||||
}
|
||||
|
||||
return this.logger.log(message);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export const envSchema = z.object({
|
||||
MAIL_FROM: z.string().email(),
|
||||
|
||||
DOMAIN_NAME: z.string(),
|
||||
API_URL: z.string().url().optional(),
|
||||
|
||||
// Sentry
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
|
||||
230
backend/src/contents/contents.controller.spec.ts
Normal file
230
backend/src/contents/contents.controller.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ContentsController } from "./contents.controller";
|
||||
import { ContentsService } from "./contents.service";
|
||||
|
||||
describe("ContentsController", () => {
|
||||
let controller: ContentsController;
|
||||
let service: ContentsService;
|
||||
|
||||
const mockContentsService = {
|
||||
create: jest.fn(),
|
||||
getUploadUrl: jest.fn(),
|
||||
uploadAndProcess: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
incrementViews: jest.fn(),
|
||||
incrementUsage: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
removeAdmin: jest.fn(),
|
||||
generateBotHtml: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ContentsController],
|
||||
providers: [
|
||||
{ provide: ContentsService, useValue: mockContentsService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(OptionalAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ContentsController>(ContentsController);
|
||||
service = module.get<ContentsService>(ContentsService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should call service.create", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { title: "Title", type: "image" as any };
|
||||
await controller.create(req, dto as any);
|
||||
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUploadUrl", () => {
|
||||
it("should call service.getUploadUrl", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.getUploadUrl(req, "test.jpg");
|
||||
expect(service.getUploadUrl).toHaveBeenCalledWith("user-uuid", "test.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload", () => {
|
||||
it("should call service.uploadAndProcess", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const file = {} as Express.Multer.File;
|
||||
const dto = { title: "Title" };
|
||||
await controller.upload(req, file, dto as any);
|
||||
expect(service.uploadAndProcess).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
file,
|
||||
dto,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explore", () => {
|
||||
it("should call service.findAll", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.explore(
|
||||
req,
|
||||
10,
|
||||
0,
|
||||
"trend",
|
||||
"tag",
|
||||
"cat",
|
||||
"auth",
|
||||
"query",
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(service.findAll).toHaveBeenCalledWith({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sortBy: "trend",
|
||||
tag: "tag",
|
||||
category: "cat",
|
||||
author: "auth",
|
||||
query: "query",
|
||||
favoritesOnly: false,
|
||||
userId: "user-uuid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends", () => {
|
||||
it("should call service.findAll with trend sort", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.trends(req, 10, 0);
|
||||
expect(service.findAll).toHaveBeenCalledWith({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sortBy: "trend",
|
||||
userId: "user-uuid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recent", () => {
|
||||
it("should call service.findAll with recent sort", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.recent(req, 10, 0);
|
||||
expect(service.findAll).toHaveBeenCalledWith({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sortBy: "recent",
|
||||
userId: "user-uuid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return json for normal user", async () => {
|
||||
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
|
||||
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||
const content = { id: "1" };
|
||||
mockContentsService.findOne.mockResolvedValue(content);
|
||||
|
||||
await controller.findOne("1", req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(content);
|
||||
});
|
||||
|
||||
it("should return html for bot", async () => {
|
||||
const req = {
|
||||
user: { sub: "user-uuid" },
|
||||
headers: { "user-agent": "Googlebot" },
|
||||
} as any;
|
||||
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||
const content = { id: "1" };
|
||||
mockContentsService.findOne.mockResolvedValue(content);
|
||||
mockContentsService.generateBotHtml.mockReturnValue("<html></html>");
|
||||
|
||||
await controller.findOne("1", req, res);
|
||||
|
||||
expect(res.send).toHaveBeenCalledWith("<html></html>");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if not found", async () => {
|
||||
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
|
||||
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||
mockContentsService.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(controller.findOne("1", req, res)).rejects.toThrow(
|
||||
"Contenu non trouvé",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementViews", () => {
|
||||
it("should call service.incrementViews", async () => {
|
||||
await controller.incrementViews("1");
|
||||
expect(service.incrementViews).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
it("should call service.incrementUsage", async () => {
|
||||
await controller.incrementUsage("1");
|
||||
expect(service.incrementUsage).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.remove", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.remove("1", req);
|
||||
expect(service.remove).toHaveBeenCalledWith("1", "user-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAdmin", () => {
|
||||
it("should call service.removeAdmin", async () => {
|
||||
await controller.removeAdmin("1");
|
||||
expect(service.removeAdmin).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Response } from "express";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
@@ -65,10 +68,12 @@ export class ContentsController {
|
||||
}
|
||||
|
||||
@Get("explore")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(60)
|
||||
@Header("Cache-Control", "public, max-age=60")
|
||||
explore(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
@Query("sort") sort?: "trend" | "recent",
|
||||
@@ -78,7 +83,7 @@ export class ContentsController {
|
||||
@Query("query") query?: string,
|
||||
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
|
||||
favoritesOnly?: boolean,
|
||||
@Query("userId") userId?: string,
|
||||
@Query("userId") userIdQuery?: string,
|
||||
) {
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
@@ -89,42 +94,57 @@ export class ContentsController {
|
||||
author,
|
||||
query,
|
||||
favoritesOnly,
|
||||
userId,
|
||||
userId: userIdQuery || req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("trends")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(300)
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
trends(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
offset,
|
||||
sortBy: "trend",
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("recent")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(60)
|
||||
@Header("Cache-Control", "public, max-age=60")
|
||||
recent(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
|
||||
return this.contentsService.findAll({
|
||||
limit,
|
||||
offset,
|
||||
sortBy: "recent",
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":idOrSlug")
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(3600)
|
||||
@Header("Cache-Control", "public, max-age=3600")
|
||||
async findOne(
|
||||
@Param("idOrSlug") idOrSlug: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const content = await this.contentsService.findOne(idOrSlug);
|
||||
const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
|
||||
if (!content) {
|
||||
throw new NotFoundException("Contenu non trouvé");
|
||||
}
|
||||
@@ -158,4 +178,11 @@ export class ContentsController {
|
||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.contentsService.remove(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(":id/admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
removeAdmin(@Param("id") id: string) {
|
||||
return this.contentsService.removeAdmin(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { MediaModule } from "../media/media.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { ContentsController } from "./contents.controller";
|
||||
@@ -9,7 +7,7 @@ import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
|
||||
imports: [S3Module, AuthModule, MediaModule],
|
||||
controllers: [ContentsController],
|
||||
providers: [ContentsService, ContentsRepository],
|
||||
exports: [ContentsRepository],
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("ContentsService", () => {
|
||||
incrementViews: jest.fn(),
|
||||
incrementUsage: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
softDeleteAdmin: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findBySlug: jest.fn(),
|
||||
};
|
||||
@@ -30,6 +31,7 @@ describe("ContentsService", () => {
|
||||
const mockS3Service = {
|
||||
getUploadUrl: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
@@ -146,4 +148,81 @@ describe("ContentsService", () => {
|
||||
expect(result[0].views).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
it("should increment usage", async () => {
|
||||
mockContentsRepository.incrementUsage.mockResolvedValue([
|
||||
{ id: "1", usageCount: 1 },
|
||||
]);
|
||||
await service.incrementUsage("1");
|
||||
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should soft delete content", async () => {
|
||||
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
|
||||
await service.remove("1", "u1");
|
||||
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAdmin", () => {
|
||||
it("should soft delete content without checking owner", async () => {
|
||||
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
|
||||
await service.removeAdmin("1");
|
||||
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return content by id", async () => {
|
||||
mockContentsRepository.findOne.mockResolvedValue({
|
||||
id: "1",
|
||||
storageKey: "k",
|
||||
author: { avatarUrl: "a" },
|
||||
});
|
||||
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||
const result = await service.findOne("1");
|
||||
expect(result.id).toBe("1");
|
||||
expect(result.url).toBe("url");
|
||||
});
|
||||
|
||||
it("should return content by slug", async () => {
|
||||
mockContentsRepository.findOne.mockResolvedValue({
|
||||
id: "1",
|
||||
slug: "s",
|
||||
storageKey: "k",
|
||||
});
|
||||
const result = await service.findOne("s");
|
||||
expect(result.slug).toBe("s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateBotHtml", () => {
|
||||
it("should generate html with og tags", () => {
|
||||
const content = { title: "Title", storageKey: "k" };
|
||||
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||
const html = service.generateBotHtml(content as any);
|
||||
expect(html).toContain("<title>Title</title>");
|
||||
expect(html).toContain('content="Title"');
|
||||
expect(html).toContain('content="url"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUniqueSlug", () => {
|
||||
it("should return original slug if unique", async () => {
|
||||
mockContentsRepository.findBySlug.mockResolvedValue(null);
|
||||
const slug = (service as any).ensureUniqueSlug("My Title");
|
||||
await expect(slug).resolves.toBe("my-title");
|
||||
});
|
||||
|
||||
it("should append counter if not unique", async () => {
|
||||
mockContentsRepository.findBySlug
|
||||
.mockResolvedValueOnce({ id: "1" })
|
||||
.mockResolvedValueOnce(null);
|
||||
const slug = await (service as any).ensureUniqueSlug("My Title");
|
||||
expect(slug).toBe("my-title-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ export class ContentsService {
|
||||
// 3. Upload vers S3
|
||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Création en base de données
|
||||
return await this.create(userId, {
|
||||
@@ -126,7 +127,18 @@ export class ContentsService {
|
||||
this.contentsRepository.count(options),
|
||||
]);
|
||||
|
||||
return { data, totalCount };
|
||||
const processedData = data.map((content) => ({
|
||||
...content,
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: processedData, totalCount };
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateContentDto) {
|
||||
@@ -162,12 +174,34 @@ export class ContentsService {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string) {
|
||||
return this.contentsRepository.findOne(idOrSlug);
|
||||
async removeAdmin(id: string) {
|
||||
this.logger.log(`Removing content ${id} by admin`);
|
||||
const deleted = await this.contentsRepository.softDeleteAdmin(id);
|
||||
|
||||
if (deleted) {
|
||||
await this.clearContentsCache();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string, userId?: string) {
|
||||
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
||||
if (!content) return null;
|
||||
|
||||
return {
|
||||
...content,
|
||||
url: this.s3Service.getPublicUrl(content.storageKey),
|
||||
author: {
|
||||
...content.author,
|
||||
avatarUrl: content.author?.avatarUrl
|
||||
? this.s3Service.getPublicUrl(content.author.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateBotHtml(content: { title: string; storageKey: string }): string {
|
||||
const imageUrl = this.getFileUrl(content.storageKey);
|
||||
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -188,19 +222,6 @@ export class ContentsService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
getFileUrl(storageKey: string): string {
|
||||
const endpoint = this.configService.get("S3_ENDPOINT");
|
||||
const port = this.configService.get("S3_PORT");
|
||||
const protocol =
|
||||
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
|
||||
const bucket = this.configService.get("S3_BUCKET_NAME");
|
||||
|
||||
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
|
||||
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
|
||||
}
|
||||
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
|
||||
}
|
||||
|
||||
private generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export enum ContentType {
|
||||
@@ -19,14 +20,17 @@ export class CreateContentDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(512)
|
||||
storageKey!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
mimeType!: string;
|
||||
|
||||
@IsInt()
|
||||
@@ -39,5 +43,6 @@ export class CreateContentDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
import { ContentType } from "./create-content.dto";
|
||||
|
||||
@@ -13,6 +15,7 @@ export class UploadContentDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -20,6 +23,8 @@ export class UploadContentDto {
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -135,11 +135,20 @@ export class ContentsRepository {
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount:
|
||||
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
isLiked: userId
|
||||
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
||||
: sql<boolean>`false`,
|
||||
createdAt: contents.createdAt,
|
||||
updatedAt: contents.updatedAt,
|
||||
author: {
|
||||
id: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
category: {
|
||||
id: categories.id,
|
||||
@@ -215,7 +224,7 @@ export class ContentsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string) {
|
||||
async findOne(idOrSlug: string, userId?: string) {
|
||||
const [result] = await this.databaseService.db
|
||||
.select({
|
||||
id: contents.id,
|
||||
@@ -227,11 +236,31 @@ export class ContentsRepository {
|
||||
fileSize: contents.fileSize,
|
||||
views: contents.views,
|
||||
usageCount: contents.usageCount,
|
||||
favoritesCount:
|
||||
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
isLiked: userId
|
||||
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
||||
: sql<boolean>`false`,
|
||||
createdAt: contents.createdAt,
|
||||
updatedAt: contents.updatedAt,
|
||||
userId: contents.userId,
|
||||
author: {
|
||||
id: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
category: {
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
slug: categories.slug,
|
||||
},
|
||||
})
|
||||
.from(contents)
|
||||
.leftJoin(users, eq(contents.userId, users.uuid))
|
||||
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(contents.deletedAt),
|
||||
@@ -240,7 +269,20 @@ export class ContentsRepository {
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result;
|
||||
if (!result) return null;
|
||||
|
||||
const tagsForContent = await this.databaseService.db
|
||||
.select({
|
||||
name: tags.name,
|
||||
})
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(eq(contentsToTags.contentId, result.id));
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: tagsForContent.map((t) => t.name),
|
||||
};
|
||||
}
|
||||
|
||||
async count(options: {
|
||||
@@ -353,6 +395,15 @@ export class ContentsRepository {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async softDeleteAdmin(id: string) {
|
||||
const [deleted] = await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(contents.id, id))
|
||||
.returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
const [result] = await this.databaseService.db
|
||||
.select()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { EncryptionService } from "./services/encryption.service";
|
||||
import { HashingService } from "./services/hashing.service";
|
||||
import { JwtService } from "./services/jwt.service";
|
||||
import { PostQuantumService } from "./services/post-quantum.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
CryptoService,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { DatabaseService } from "./database.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [DatabaseService],
|
||||
|
||||
67
backend/src/database/database.service.spec.ts
Normal file
67
backend/src/database/database.service.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "./database.service";
|
||||
|
||||
jest.mock("pg", () => {
|
||||
const mPool = {
|
||||
connect: jest.fn(),
|
||||
query: jest.fn(),
|
||||
end: jest.fn(),
|
||||
on: jest.fn(),
|
||||
};
|
||||
return { Pool: jest.fn(() => mPool) };
|
||||
});
|
||||
|
||||
jest.mock("drizzle-orm/node-postgres", () => ({
|
||||
drizzle: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe("DatabaseService", () => {
|
||||
let service: DatabaseService;
|
||||
let _configService: ConfigService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key) => {
|
||||
const config = {
|
||||
POSTGRES_PASSWORD: "p",
|
||||
POSTGRES_USER: "u",
|
||||
POSTGRES_HOST: "h",
|
||||
POSTGRES_PORT: "5432",
|
||||
POSTGRES_DB: "db",
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DatabaseService>(DatabaseService);
|
||||
_configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("onModuleInit", () => {
|
||||
it("should skip migrations in development", async () => {
|
||||
await service.onModuleInit();
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith("NODE_ENV");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onModuleDestroy", () => {
|
||||
it("should close pool", async () => {
|
||||
const pool = (service as any).pool;
|
||||
await service.onModuleDestroy();
|
||||
expect(pool.end).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,9 @@ export const users = pgTable(
|
||||
displayName: varchar("display_name", { length: 32 }),
|
||||
|
||||
username: varchar("username", { length: 32 }).notNull().unique(),
|
||||
passwordHash: varchar("password_hash", { length: 95 }).notNull(),
|
||||
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
|
||||
avatarUrl: varchar("avatar_url", { length: 512 }),
|
||||
bio: varchar("bio", { length: 255 }),
|
||||
|
||||
// Sécurité
|
||||
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
||||
|
||||
82
backend/src/favorites/favorites.controller.spec.ts
Normal file
82
backend/src/favorites/favorites.controller.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { FavoritesController } from "./favorites.controller";
|
||||
import { FavoritesService } from "./favorites.service";
|
||||
|
||||
describe("FavoritesController", () => {
|
||||
let controller: FavoritesController;
|
||||
let service: FavoritesService;
|
||||
|
||||
const mockFavoritesService = {
|
||||
addFavorite: jest.fn(),
|
||||
removeFavorite: jest.fn(),
|
||||
getUserFavorites: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [FavoritesController],
|
||||
providers: [{ provide: FavoritesService, useValue: mockFavoritesService }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<FavoritesController>(FavoritesController);
|
||||
service = module.get<FavoritesService>(FavoritesService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
it("should call service.addFavorite", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.add(req, "content-1");
|
||||
expect(service.addFavorite).toHaveBeenCalledWith("user-uuid", "content-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.removeFavorite", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.remove(req, "content-1");
|
||||
expect(service.removeFavorite).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"content-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("should call service.getUserFavorites", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.list(req, 10, 0);
|
||||
expect(service.getUserFavorites).toHaveBeenCalledWith("user-uuid", 10, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { FavoritesController } from "./favorites.controller";
|
||||
import { FavoritesService } from "./favorites.service";
|
||||
import { FavoritesRepository } from "./repositories/favorites.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [FavoritesController],
|
||||
providers: [FavoritesService, FavoritesRepository],
|
||||
exports: [FavoritesService, FavoritesRepository],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { FavoritesRepository } from "./favorites.repository";
|
||||
|
||||
describe("FavoritesRepository", () => {
|
||||
let repository: FavoritesRepository;
|
||||
|
||||
const mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
offset: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapWithThen = (obj: unknown) => {
|
||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
|
||||
Object.defineProperty(obj, "then", {
|
||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||
const result = (this as any).execute();
|
||||
return Promise.resolve(result).then(onFulfilled);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
wrapWithThen(mockDb);
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FavoritesRepository,
|
||||
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<FavoritesRepository>(FavoritesRepository);
|
||||
});
|
||||
|
||||
it("should find content by id", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findContentById("1");
|
||||
expect(result.id).toBe("1");
|
||||
});
|
||||
|
||||
it("should add favorite", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||
{ userId: "u", contentId: "c" },
|
||||
]);
|
||||
await repository.add("u", "c");
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove favorite", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.remove("u", "c");
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should find by user id", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ content: { id: "c1" } }]);
|
||||
const result = await repository.findByUserId("u1", 10, 0);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("c1");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "./database/database.service";
|
||||
import { HealthController } from "./health.controller";
|
||||
@@ -9,6 +10,10 @@ describe("HealthController", () => {
|
||||
execute: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
@@ -19,24 +24,42 @@ describe("HealthController", () => {
|
||||
db: mockDb,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: mockCacheManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<HealthController>(HealthController);
|
||||
});
|
||||
|
||||
it("should return ok if database is connected", async () => {
|
||||
it("should return ok if database and redis are connected", async () => {
|
||||
mockDb.execute.mockResolvedValue([]);
|
||||
mockCacheManager.set.mockResolvedValue(undefined);
|
||||
const result = await controller.check();
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.database).toBe("connected");
|
||||
expect(result.redis).toBe("connected");
|
||||
});
|
||||
|
||||
it("should return error if database is disconnected", async () => {
|
||||
mockDb.execute.mockRejectedValue(new Error("DB Error"));
|
||||
mockCacheManager.set.mockResolvedValue(undefined);
|
||||
const result = await controller.check();
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.database).toBe("disconnected");
|
||||
expect(result.message).toBe("DB Error");
|
||||
expect(result.databaseError).toBe("DB Error");
|
||||
expect(result.redis).toBe("connected");
|
||||
});
|
||||
|
||||
it("should return error if redis is disconnected", async () => {
|
||||
mockDb.execute.mockResolvedValue([]);
|
||||
mockCacheManager.set.mockRejectedValue(new Error("Redis Error"));
|
||||
const result = await controller.check();
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.database).toBe("connected");
|
||||
expect(result.redis).toBe("disconnected");
|
||||
expect(result.redisError).toBe("Redis Error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Controller, Get, Inject } from "@nestjs/common";
|
||||
import { Cache } from "cache-manager";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "./database/database.service";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly databaseService: DatabaseService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async check() {
|
||||
const health: any = {
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Check database connection
|
||||
await this.databaseService.db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
status: "ok",
|
||||
database: "connected",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
health.database = "connected";
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
database: "disconnected",
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
health.status = "error";
|
||||
health.database = "disconnected";
|
||||
health.databaseError = error.message;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check Redis connection via cache-manager
|
||||
// We try to set a temporary key to verify the connection
|
||||
await this.cacheManager.set("health-check", "ok", 1000);
|
||||
health.redis = "connected";
|
||||
} catch (error) {
|
||||
health.status = "error";
|
||||
health.redis = "disconnected";
|
||||
health.redisError = error.message;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
|
||||
61
backend/src/media/media.controller.spec.ts
Normal file
61
backend/src/media/media.controller.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import type { Response } from "express";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { MediaController } from "./media.controller";
|
||||
|
||||
describe("MediaController", () => {
|
||||
let controller: MediaController;
|
||||
|
||||
const mockS3Service = {
|
||||
getFileInfo: jest.fn(),
|
||||
getFile: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [MediaController],
|
||||
providers: [{ provide: S3Service, useValue: mockS3Service }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<MediaController>(MediaController);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getFile", () => {
|
||||
it("should stream the file and set headers with path containing slashes", async () => {
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
} as unknown as Response;
|
||||
const stream = new Readable();
|
||||
stream.pipe = jest.fn();
|
||||
const key = "contents/user-id/test.webp";
|
||||
|
||||
mockS3Service.getFileInfo.mockResolvedValue({
|
||||
size: 100,
|
||||
metaData: { "content-type": "image/webp" },
|
||||
});
|
||||
mockS3Service.getFile.mockResolvedValue(stream);
|
||||
|
||||
await controller.getFile(key, res);
|
||||
|
||||
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
|
||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if file is not found", async () => {
|
||||
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
||||
const res = {} as unknown as Response;
|
||||
|
||||
await expect(controller.getFile("invalid", res)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
backend/src/media/media.controller.ts
Normal file
28
backend/src/media/media.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import type { BucketItemStat } from "minio";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
|
||||
@Controller("media")
|
||||
export class MediaController {
|
||||
constructor(private readonly s3Service: S3Service) {}
|
||||
|
||||
@Get("*key")
|
||||
async getFile(@Param("key") key: string, @Res() res: Response) {
|
||||
try {
|
||||
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
|
||||
const stream = await this.s3Service.getFile(key);
|
||||
|
||||
const contentType =
|
||||
stats.metaData?.["content-type"] || "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (_error) {
|
||||
throw new NotFoundException("Fichier non trouvé");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { MediaController } from "./media.controller";
|
||||
import { MediaService } from "./media.service";
|
||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [S3Module],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
||||
exports: [MediaService],
|
||||
})
|
||||
|
||||
@@ -96,4 +96,37 @@ describe("MediaService", () => {
|
||||
expect(result.buffer).toEqual(Buffer.from("processed-video"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanFile", () => {
|
||||
it("should return false if clamav not initialized", async () => {
|
||||
const result = await service.scanFile(Buffer.from(""), "test.txt");
|
||||
expect(result.isInfected).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle virus detection", async () => {
|
||||
// Mock private property to simulate initialized clamscan
|
||||
(service as any).isClamAvInitialized = true;
|
||||
(service as any).clamscan = {
|
||||
scanStream: jest.fn().mockResolvedValue({
|
||||
isInfected: true,
|
||||
viruses: ["Eicar-Test-Signature"],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await service.scanFile(Buffer.from(""), "test.txt");
|
||||
expect(result.isInfected).toBe(true);
|
||||
expect(result.virusName).toBe("Eicar-Test-Signature");
|
||||
});
|
||||
|
||||
it("should handle scan error", async () => {
|
||||
(service as any).isClamAvInitialized = true;
|
||||
(service as any).clamscan = {
|
||||
scanStream: jest.fn().mockRejectedValue(new Error("Scan failed")),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.scanFile(Buffer.from(""), "test.txt"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,8 +83,9 @@ export class MediaService implements IMediaService {
|
||||
async processImage(
|
||||
buffer: Buffer,
|
||||
format: "webp" | "avif" = "webp",
|
||||
resize?: { width?: number; height?: number },
|
||||
): Promise<MediaProcessingResult> {
|
||||
return this.imageProcessor.process(buffer, { format });
|
||||
return this.imageProcessor.process(buffer, { format, resize });
|
||||
}
|
||||
|
||||
async processVideo(
|
||||
|
||||
@@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy {
|
||||
|
||||
async process(
|
||||
buffer: Buffer,
|
||||
options: { format: "webp" | "avif" } = { format: "webp" },
|
||||
options: {
|
||||
format: "webp" | "avif";
|
||||
resize?: { width?: number; height?: number };
|
||||
} = { format: "webp" },
|
||||
): Promise<MediaProcessingResult> {
|
||||
try {
|
||||
const { format } = options;
|
||||
const { format, resize } = options;
|
||||
let pipeline = sharp(buffer);
|
||||
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize(resize.width, resize.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (format === "webp") {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export enum ReportReason {
|
||||
INAPPROPRIATE = "inappropriate",
|
||||
@@ -21,5 +27,6 @@ export class CreateReportDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
82
backend/src/reports/reports.controller.spec.ts
Normal file
82
backend/src/reports/reports.controller.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
describe("ReportsController", () => {
|
||||
let controller: ReportsController;
|
||||
let service: ReportsService;
|
||||
|
||||
const mockReportsService = {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ReportsController],
|
||||
providers: [{ provide: ReportsService, useValue: mockReportsService }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ReportsController>(ReportsController);
|
||||
service = module.get<ReportsService>(ReportsService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should call service.create", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { contentId: "1", reason: "spam" };
|
||||
await controller.create(req, dto as any);
|
||||
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should call service.findAll", async () => {
|
||||
await controller.findAll(10, 0);
|
||||
expect(service.findAll).toHaveBeenCalledWith(10, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStatus", () => {
|
||||
it("should call service.updateStatus", async () => {
|
||||
const dto = { status: "resolved" as any };
|
||||
await controller.updateStatus("1", dto);
|
||||
expect(service.updateStatus).toHaveBeenCalledWith("1", "resolved");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { ReportsRepository } from "./repositories/reports.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
exports: [ReportsRepository, ReportsService],
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ReportsService", () => {
|
||||
describe("create", () => {
|
||||
it("should create a report", async () => {
|
||||
const reporterId = "u1";
|
||||
const data = { contentId: "c1", reason: "spam" };
|
||||
const data = { contentId: "c1", reason: "spam" } as const;
|
||||
mockReportsRepository.create.mockResolvedValue({
|
||||
id: "r1",
|
||||
...data,
|
||||
|
||||
74
backend/src/reports/repositories/reports.repository.spec.ts
Normal file
74
backend/src/reports/repositories/reports.repository.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { ReportsRepository } from "./reports.repository";
|
||||
|
||||
describe("ReportsRepository", () => {
|
||||
let repository: ReportsRepository;
|
||||
|
||||
const mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
offset: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapWithThen = (obj: unknown) => {
|
||||
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Necessary to mock Drizzle's awaitable query builder
|
||||
Object.defineProperty(obj, "then", {
|
||||
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||
const result = (this as any).execute();
|
||||
return Promise.resolve(result).then(onFulfilled);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
wrapWithThen(mockDb);
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ReportsRepository,
|
||||
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<ReportsRepository>(ReportsRepository);
|
||||
});
|
||||
|
||||
it("should create report", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.create({ reporterId: "u", reason: "spam" });
|
||||
expect(result.id).toBe("1");
|
||||
});
|
||||
|
||||
it("should find all", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
const result = await repository.findAll(10, 0);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should update status", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||
{ id: "1", status: "resolved" },
|
||||
]);
|
||||
const result = await repository.updateStatus("1", "resolved");
|
||||
expect(result[0].status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("should purge obsolete", async () => {
|
||||
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||
await repository.purgeObsolete(new Date());
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ jest.mock("minio");
|
||||
|
||||
describe("S3Service", () => {
|
||||
let service: S3Service;
|
||||
let _configService: ConfigService;
|
||||
let configService: ConfigService;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
||||
let minioClient: any;
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("S3Service", () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<S3Service>(S3Service);
|
||||
_configService = module.get<ConfigService>(ConfigService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
@@ -185,35 +185,39 @@ describe("S3Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFile", () => {
|
||||
it("should move file within default bucket", async () => {
|
||||
const source = "source.txt";
|
||||
const dest = "dest.txt";
|
||||
await service.moveFile(source, dest);
|
||||
|
||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||
"memegoat",
|
||||
dest,
|
||||
"/memegoat/source.txt",
|
||||
expect.any(Minio.CopyConditions),
|
||||
);
|
||||
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
|
||||
describe("getPublicUrl", () => {
|
||||
it("should use API_URL if provided", () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === "API_URL") return "https://api.test.com";
|
||||
return null;
|
||||
});
|
||||
const url = service.getPublicUrl("test.webp");
|
||||
expect(url).toBe("https://api.test.com/media/test.webp");
|
||||
});
|
||||
|
||||
it("should move file between different buckets", async () => {
|
||||
const source = "source.txt";
|
||||
const dest = "dest.txt";
|
||||
const sBucket = "source-bucket";
|
||||
const dBucket = "dest-bucket";
|
||||
await service.moveFile(source, dest, sBucket, dBucket);
|
||||
|
||||
expect(minioClient.copyObject).toHaveBeenCalledWith(
|
||||
dBucket,
|
||||
dest,
|
||||
`/${sBucket}/${source}`,
|
||||
expect.any(Minio.CopyConditions),
|
||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||
(configService.get as jest.Mock).mockImplementation(
|
||||
(key: string, def: unknown) => {
|
||||
if (key === "API_URL") return null;
|
||||
if (key === "DOMAIN_NAME") return "localhost";
|
||||
if (key === "PORT") return 3000;
|
||||
return def;
|
||||
},
|
||||
);
|
||||
expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
|
||||
const url = service.getPublicUrl("test.webp");
|
||||
expect(url).toBe("http://localhost:3000/media/test.webp");
|
||||
});
|
||||
|
||||
it("should use api.DOMAIN_NAME for production", () => {
|
||||
(configService.get as jest.Mock).mockImplementation(
|
||||
(key: string, def: unknown) => {
|
||||
if (key === "API_URL") return null;
|
||||
if (key === "DOMAIN_NAME") return "memegoat.fr";
|
||||
return def;
|
||||
},
|
||||
);
|
||||
const url = service.getPublicUrl("test.webp");
|
||||
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
||||
...metaData,
|
||||
"Content-Type": mimeType,
|
||||
});
|
||||
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
|
||||
return fileName;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
|
||||
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
|
||||
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
|
||||
try {
|
||||
await this.minioClient.removeObject(bucketName, fileName);
|
||||
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error deleting file from ${bucketName}: ${error.message}`,
|
||||
@@ -155,4 +157,22 @@ export class S3Service implements OnModuleInit, IStorageService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getPublicUrl(storageKey: string): string {
|
||||
const apiUrl = this.configService.get<string>("API_URL");
|
||||
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
|
||||
const port = this.configService.get<number>("PORT", 3000);
|
||||
|
||||
let baseUrl: string;
|
||||
|
||||
if (apiUrl) {
|
||||
baseUrl = apiUrl.replace(/\/$/, "");
|
||||
} else if (domain === "localhost" || domain === "127.0.0.1") {
|
||||
baseUrl = `http://${domain}:${port}`;
|
||||
} else {
|
||||
baseUrl = `https://api.${domain}`;
|
||||
}
|
||||
|
||||
return `${baseUrl}/media/${storageKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { SessionsRepository } from "./repositories/sessions.repository";
|
||||
import { SessionsService } from "./sessions.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, CryptoModule],
|
||||
providers: [SessionsService, SessionsRepository],
|
||||
exports: [SessionsService, SessionsRepository],
|
||||
})
|
||||
|
||||
69
backend/src/tags/tags.controller.spec.ts
Normal file
69
backend/src/tags/tags.controller.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TagsController } from "./tags.controller";
|
||||
import { TagsService } from "./tags.service";
|
||||
|
||||
describe("TagsController", () => {
|
||||
let controller: TagsController;
|
||||
let service: TagsService;
|
||||
|
||||
const mockTagsService = {
|
||||
findAll: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TagsController],
|
||||
providers: [
|
||||
{ provide: TagsService, useValue: mockTagsService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<TagsController>(TagsController);
|
||||
service = module.get<TagsService>(TagsService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should call service.findAll", async () => {
|
||||
await controller.findAll(10, 0, "test", "popular");
|
||||
expect(service.findAll).toHaveBeenCalledWith({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
query: "test",
|
||||
sortBy: "popular",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { TagsRepository } from "./repositories/tags.repository";
|
||||
import { TagsController } from "./tags.controller";
|
||||
import { TagsService } from "./tags.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, CryptoModule],
|
||||
imports: [AuthModule],
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService, TagsRepository],
|
||||
exports: [TagsService, TagsRepository],
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateConsentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
termsVersion!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
privacyVersion!: string;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,13 @@ export class UpdateUserDto {
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
150
backend/src/users/repositories/users.repository.spec.ts
Normal file
150
backend/src/users/repositories/users.repository.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { UsersRepository } from "./users.repository";
|
||||
|
||||
describe("UsersRepository", () => {
|
||||
let repository: UsersRepository;
|
||||
let _databaseService: DatabaseService;
|
||||
|
||||
const mockDb = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
offset: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersRepository,
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: { db: mockDb },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<UsersRepository>(UsersRepository);
|
||||
_databaseService = module.get<DatabaseService>(DatabaseService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(repository).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should insert a user", async () => {
|
||||
const data = {
|
||||
username: "u",
|
||||
email: "e",
|
||||
passwordHash: "p",
|
||||
emailHash: "eh",
|
||||
};
|
||||
await repository.create(data);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByEmailHash", () => {
|
||||
it("should select user by email hash", async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
const result = await repository.findByEmailHash("hash");
|
||||
expect(result.uuid).toBe("u1");
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOneWithPrivateData", () => {
|
||||
it("should select user with private data", async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
const result = await repository.findOneWithPrivateData("u1");
|
||||
expect(result.uuid).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countAll", () => {
|
||||
it("should return count", async () => {
|
||||
mockDb.from.mockResolvedValueOnce([{ count: 5 }]);
|
||||
const result = await repository.countAll();
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should select users with limit and offset", async () => {
|
||||
mockDb.offset.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
const result = await repository.findAll(10, 0);
|
||||
expect(result[0].uuid).toBe("u1");
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(10);
|
||||
expect(mockDb.offset).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByUsername", () => {
|
||||
it("should find by username", async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
const result = await repository.findByUsername("u");
|
||||
expect(result.uuid).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update user", async () => {
|
||||
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
await repository.update("u1", { displayName: "New" });
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTwoFactorSecret", () => {
|
||||
it("should return secret", async () => {
|
||||
mockDb.limit.mockResolvedValueOnce([{ secret: "s" }]);
|
||||
const result = await repository.getTwoFactorSecret("u1");
|
||||
expect(result).toBe("s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserContents", () => {
|
||||
it("should return contents", async () => {
|
||||
mockDb.where.mockResolvedValueOnce([{ id: "c1" }]);
|
||||
const result = await repository.getUserContents("u1");
|
||||
expect(result[0].id).toBe("c1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteUserAndContents", () => {
|
||||
it("should run transaction", async () => {
|
||||
const mockTx = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
|
||||
};
|
||||
mockDb.transaction.mockImplementation(async (cb) => cb(mockTx));
|
||||
|
||||
const result = await repository.softDeleteUserAndContents("u1");
|
||||
expect(result[0].uuid).toBe("u1");
|
||||
expect(mockTx.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgeDeleted", () => {
|
||||
it("should delete old deleted users", async () => {
|
||||
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||
const _result = await repository.purgeDeleted(new Date());
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,8 @@ export class UsersRepository {
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
status: users.status,
|
||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||
createdAt: users.createdAt,
|
||||
@@ -66,7 +68,9 @@ export class UsersRepository {
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
status: users.status,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
@@ -81,6 +85,8 @@ export class UsersRepository {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
|
||||
192
backend/src/users/users.controller.spec.ts
Normal file
192
backend/src/users/users.controller.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
encapsulate: jest.fn(),
|
||||
decapsulate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("jose", () => ({
|
||||
SignJWT: jest.fn().mockReturnValue({
|
||||
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||
setIssuedAt: jest.fn().mockReturnThis(),
|
||||
setExpirationTime: jest.fn().mockReturnThis(),
|
||||
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||
}),
|
||||
jwtVerify: jest.fn(),
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
describe("UsersController", () => {
|
||||
let controller: UsersController;
|
||||
let usersService: UsersService;
|
||||
let authService: AuthService;
|
||||
|
||||
const mockUsersService = {
|
||||
findAll: jest.fn(),
|
||||
findPublicProfile: jest.fn(),
|
||||
findOneWithPrivateData: jest.fn(),
|
||||
exportUserData: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateAvatar: jest.fn(),
|
||||
updateConsent: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAuthService = {
|
||||
generateTwoFactorSecret: jest.fn(),
|
||||
enableTwoFactor: jest.fn(),
|
||||
disableTwoFactor: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheManager = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UsersController],
|
||||
providers: [
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<UsersController>(UsersController);
|
||||
usersService = module.get<UsersService>(UsersService);
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should call usersService.findAll", async () => {
|
||||
await controller.findAll(10, 0);
|
||||
expect(usersService.findAll).toHaveBeenCalledWith(10, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPublicProfile", () => {
|
||||
it("should call usersService.findPublicProfile", async () => {
|
||||
await controller.findPublicProfile("testuser");
|
||||
expect(usersService.findPublicProfile).toHaveBeenCalledWith("testuser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMe", () => {
|
||||
it("should call usersService.findOneWithPrivateData", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.findMe(req);
|
||||
expect(usersService.findOneWithPrivateData).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportMe", () => {
|
||||
it("should call usersService.exportUserData", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.exportMe(req);
|
||||
expect(usersService.exportUserData).toHaveBeenCalledWith("user-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMe", () => {
|
||||
it("should call usersService.update", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { displayName: "New Name" };
|
||||
await controller.updateMe(req, dto);
|
||||
expect(usersService.update).toHaveBeenCalledWith("user-uuid", dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAvatar", () => {
|
||||
it("should call usersService.updateAvatar", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const file = {} as Express.Multer.File;
|
||||
await controller.updateAvatar(req, file);
|
||||
expect(usersService.updateAvatar).toHaveBeenCalledWith("user-uuid", file);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConsent", () => {
|
||||
it("should call usersService.updateConsent", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
const dto = { termsVersion: "1.0", privacyVersion: "1.0" };
|
||||
await controller.updateConsent(req, dto);
|
||||
expect(usersService.updateConsent).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"1.0",
|
||||
"1.0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMe", () => {
|
||||
it("should call usersService.remove", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.removeMe(req);
|
||||
expect(usersService.remove).toHaveBeenCalledWith("user-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAdmin", () => {
|
||||
it("should call usersService.remove", async () => {
|
||||
await controller.removeAdmin("target-uuid");
|
||||
expect(usersService.remove).toHaveBeenCalledWith("target-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup2fa", () => {
|
||||
it("should call authService.generateTwoFactorSecret", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.setup2fa(req);
|
||||
expect(authService.generateTwoFactorSecret).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable2fa", () => {
|
||||
it("should call authService.enableTwoFactor", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.enable2fa(req, "token123");
|
||||
expect(authService.enableTwoFactor).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"token123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disable2fa", () => {
|
||||
it("should call authService.disableTwoFactor", async () => {
|
||||
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||
await controller.disable2fa(req, "token123");
|
||||
expect(authService.disableTwoFactor).toHaveBeenCalledWith(
|
||||
"user-uuid",
|
||||
"token123",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
@@ -74,6 +76,16 @@ export class UsersController {
|
||||
return this.usersService.update(req.user.sub, updateUserDto);
|
||||
}
|
||||
|
||||
@Post("me/avatar")
|
||||
@UseGuards(AuthGuard)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
updateAvatar(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.usersService.updateAvatar(req.user.sub, file);
|
||||
}
|
||||
|
||||
@Patch("me/consent")
|
||||
@UseGuards(AuthGuard)
|
||||
updateConsent(
|
||||
@@ -93,6 +105,13 @@ export class UsersController {
|
||||
return this.usersService.remove(req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(":uuid")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
removeAdmin(@Param("uuid") uuid: string) {
|
||||
return this.usersService.remove(uuid);
|
||||
}
|
||||
|
||||
// Double Authentification (2FA)
|
||||
@Post("me/2fa/setup")
|
||||
@UseGuards(AuthGuard)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { MediaModule } from "../media/media.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)],
|
||||
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersRepository],
|
||||
exports: [UsersService, UsersRepository],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RbacService } from "../auth/rbac.service";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@@ -39,6 +47,24 @@ describe("UsersService", () => {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRbacService = {
|
||||
getUserRoles: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
scanFile: jest.fn(),
|
||||
processImage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockS3Service = {
|
||||
uploadFile: jest.fn(),
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -47,6 +73,10 @@ describe("UsersService", () => {
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
{ provide: RbacService, useValue: mockRbacService },
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -98,4 +128,112 @@ describe("UsersService", () => {
|
||||
expect(result[0].displayName).toBe("New");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearUserCache", () => {
|
||||
it("should delete cache", async () => {
|
||||
await service.clearUserCache("u1");
|
||||
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByEmailHash", () => {
|
||||
it("should call repository.findByEmailHash", async () => {
|
||||
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
|
||||
const result = await service.findByEmailHash("hash");
|
||||
expect(result.uuid).toBe("u1");
|
||||
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOneWithPrivateData", () => {
|
||||
it("should return user with roles", async () => {
|
||||
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
|
||||
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
|
||||
const result = await service.findOneWithPrivateData("u1");
|
||||
expect(result.roles).toEqual(["admin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all users", async () => {
|
||||
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
|
||||
mockUsersRepository.countAll.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll(10, 0);
|
||||
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.data[0].uuid).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPublicProfile", () => {
|
||||
it("should return public profile", async () => {
|
||||
mockUsersRepository.findByUsername.mockResolvedValue({
|
||||
uuid: "u1",
|
||||
username: "u1",
|
||||
});
|
||||
const result = await service.findPublicProfile("u1");
|
||||
expect(result.username).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConsent", () => {
|
||||
it("should update consent", async () => {
|
||||
await service.updateConsent("u1", "v1", "v2");
|
||||
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||
termsVersion: "v1",
|
||||
privacyVersion: "v2",
|
||||
gdprAcceptedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTwoFactorSecret", () => {
|
||||
it("should set 2fa secret", async () => {
|
||||
await service.setTwoFactorSecret("u1", "secret");
|
||||
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||
twoFactorSecret: "secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTwoFactor", () => {
|
||||
it("should toggle 2fa", async () => {
|
||||
await service.toggleTwoFactor("u1", true);
|
||||
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||
isTwoFactorEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTwoFactorSecret", () => {
|
||||
it("should return 2fa secret", async () => {
|
||||
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
|
||||
const result = await service.getTwoFactorSecret("u1");
|
||||
expect(result).toBe("secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportUserData", () => {
|
||||
it("should return all user data", async () => {
|
||||
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
|
||||
mockUsersRepository.getUserContents.mockResolvedValue([]);
|
||||
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
|
||||
|
||||
const result = await service.exportUserData("u1");
|
||||
|
||||
expect(result.profile).toBeDefined();
|
||||
expect(result.contents).toBeDefined();
|
||||
expect(result.favorites).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should soft delete user", async () => {
|
||||
await service.remove("u1");
|
||||
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Cache } from "cache-manager";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { RbacService } from "../auth/rbac.service";
|
||||
import type { IMediaService } from "../common/interfaces/media.interface";
|
||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
|
||||
@@ -11,6 +23,10 @@ export class UsersService {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
@Inject(forwardRef(() => RbacService))
|
||||
private readonly rbacService: RbacService,
|
||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||
@Inject(S3Service) private readonly s3Service: IStorageService,
|
||||
) {}
|
||||
|
||||
private async clearUserCache(username?: string) {
|
||||
@@ -33,7 +49,21 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async findOneWithPrivateData(uuid: string) {
|
||||
return await this.usersRepository.findOneWithPrivateData(uuid);
|
||||
const [user, roles] = await Promise.all([
|
||||
this.usersRepository.findOneWithPrivateData(uuid),
|
||||
this.rbacService.getUserRoles(uuid),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
role: roles.includes("admin") ? "admin" : "user",
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(limit: number, offset: number) {
|
||||
@@ -42,11 +72,26 @@ export class UsersService {
|
||||
this.usersRepository.countAll(),
|
||||
]);
|
||||
|
||||
return { data, totalCount };
|
||||
const processedData = data.map((user) => ({
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { data: processedData, totalCount };
|
||||
}
|
||||
|
||||
async findPublicProfile(username: string) {
|
||||
return await this.usersRepository.findByUsername(username);
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(uuid: string) {
|
||||
@@ -63,6 +108,48 @@ export class UsersService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateAvatar(uuid: string, file: Express.Multer.File) {
|
||||
this.logger.log(`Updating avatar for user ${uuid}`);
|
||||
|
||||
// Validation du format et de la taille
|
||||
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
"Format d'image non supporté. Formats acceptés: png, jpeg, webp.",
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
throw new BadRequestException("Image trop volumineuse. Limite: 2 Mo.");
|
||||
}
|
||||
|
||||
// 1. Scan Antivirus
|
||||
const scanResult = await this.mediaService.scanFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
if (scanResult.isInfected) {
|
||||
throw new BadRequestException(
|
||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Traitement (WebP + Redimensionnement 512x512)
|
||||
const processed = await this.mediaService.processImage(file.buffer, "webp", {
|
||||
width: 512,
|
||||
height: 512,
|
||||
});
|
||||
|
||||
// 3. Upload vers S3
|
||||
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Mise à jour de la base de données
|
||||
const user = await this.update(uuid, { avatarUrl: key });
|
||||
return user[0];
|
||||
}
|
||||
|
||||
async updateConsent(
|
||||
uuid: string,
|
||||
termsVersion: string,
|
||||
|
||||
@@ -101,8 +101,8 @@ services:
|
||||
ENABLE_CORS: ${ENABLE_CORS:-true}
|
||||
CLAMAV_HOST: memegoat-clamav
|
||||
CLAMAV_PORT: 3310
|
||||
MAX_IMAGE_SIZE_KB: 512
|
||||
MAX_GIF_SIZE_KB: 1024
|
||||
MAX_IMAGE_SIZE_KB: 1024
|
||||
MAX_GIF_SIZE_KB: 4096
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY documentation/package.json ./documentation/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Montage du cache pnpm
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm run --filter @memegoat/documentation build
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build avec cache Next.js
|
||||
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
||||
pnpm run --filter @memegoat/documentation build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -82,6 +82,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GET /users/public/:username">
|
||||
Récupère le profil public d'un utilisateur par son nom d'utilisateur.
|
||||
**Réponse :** `id`, `username`, `displayName`, `avatarUrl`, `createdAt`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GET /users/me/export">
|
||||
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
|
||||
Contient le profil, les contenus et les favoris.
|
||||
@@ -89,7 +94,22 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
|
||||
<Accordion title="PATCH /users/me">
|
||||
Met à jour les informations du profil.
|
||||
**Corps :**
|
||||
- `displayName` (string)
|
||||
- `bio` (string)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="POST /users/me/avatar">
|
||||
Met à jour l'avatar de l'utilisateur.
|
||||
**Type :** `multipart/form-data`
|
||||
**Champ :** `file` (Image)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="PATCH /users/me/consent">
|
||||
Met à jour les consentements légaux de l'utilisateur.
|
||||
**Corps :**
|
||||
- `termsVersion` (string)
|
||||
- `privacyVersion` (string)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DELETE /users/me">
|
||||
@@ -105,9 +125,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
- `POST /users/me/2fa/disable` : Désactive avec jeton.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Administration (GET /users/admin)">
|
||||
Liste tous les utilisateurs. Réservé aux administrateurs.
|
||||
**Params :** `limit`, `offset`.
|
||||
<Accordion title="Administration (Admin uniquement)">
|
||||
- `GET /users/admin` : Liste tous les utilisateurs (avec pagination `limit`, `offset`).
|
||||
- `DELETE /users/:uuid` : Supprime définitivement un utilisateur par son UUID.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -118,12 +138,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
|
||||
|
||||
**Query Params :**
|
||||
- `limit` (number) : Défaut 10.
|
||||
- `offset` (number) : Défaut 0.
|
||||
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
|
||||
- `tag` (string)
|
||||
- `category` (slug ou id)
|
||||
- `author` (username)
|
||||
- `query` (titre)
|
||||
- `favoritesOnly` (bool)
|
||||
- `tag` (string) : Filtrer par tag.
|
||||
- `category` (slug ou id) : Filtrer par catégorie.
|
||||
- `author` (username) : Filtrer par auteur.
|
||||
- `query` (titre) : Recherche textuelle.
|
||||
- `favoritesOnly` (bool) : Ne montrer que les favoris de l'utilisateur connecté.
|
||||
- `userId` (uuid) : Filtrer les contenus d'un utilisateur spécifique.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GET /contents/:idOrSlug">
|
||||
@@ -133,8 +156,13 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="POST /contents">
|
||||
Crée une entrée de contenu (sans upload de fichier direct). Utile pour référencer des URLs externes.
|
||||
**Corps :** `title`, `description`, `url`, `type`, `categoryId`, `tags`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="POST /contents/upload">
|
||||
Upload un fichier avec traitement automatique.
|
||||
Upload un fichier avec traitement automatique par le serveur.
|
||||
**Type :** `multipart/form-data`
|
||||
|
||||
**Champs :**
|
||||
@@ -145,6 +173,11 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
- `tags`? : string[]
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="POST /contents/upload-url">
|
||||
Génère une URL présignée pour un upload direct vers S3.
|
||||
**Query Param :** `fileName` (string).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="POST /contents/:id/view | /use">
|
||||
Incrémente les statistiques de vue ou d'utilisation.
|
||||
</Accordion>
|
||||
@@ -152,6 +185,10 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
<Accordion title="DELETE /contents/:id">
|
||||
Supprime un contenu (Soft Delete). Doit être l'auteur.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DELETE /contents/:id/admin">
|
||||
Supprime définitivement un contenu. **Réservé aux administrateurs.**
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
|
||||
@@ -159,19 +196,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
<Accordions>
|
||||
<Accordion title="Catégories (/categories)">
|
||||
- `GET /categories` : Liste toutes les catégories.
|
||||
- `GET /categories/:id` : Détails d'une catégorie.
|
||||
- `POST /categories` : Création (Admin uniquement).
|
||||
- `PATCH /categories/:id` : Mise à jour (Admin uniquement).
|
||||
- `DELETE /categories/:id` : Suppression (Admin uniquement).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Favoris (/favorites)">
|
||||
- `GET /favorites` : Liste les favoris de l'utilisateur.
|
||||
Requiert l'authentification.
|
||||
- `GET /favorites` : Liste les favoris de l'utilisateur (avec pagination `limit`, `offset`).
|
||||
- `POST /favorites/:contentId` : Ajoute un favori.
|
||||
- `DELETE /favorites/:contentId` : Retire un favori.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Signalements (/reports)">
|
||||
- `POST /reports` : Signale un contenu ou un tag.
|
||||
- `GET /reports` : Liste (Modérateurs).
|
||||
- `PATCH /reports/:id/status` : Gère le workflow.
|
||||
- `GET /reports` : Liste des signalements (Pagination `limit`, `offset`). **Admin/Modérateurs**.
|
||||
- `PATCH /reports/:id/status` : Change le statut (`pending`, `resolved`, `dismissed`). **Admin/Modérateurs**.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -185,7 +226,23 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tags (/tags)">
|
||||
- `GET /tags` : Recherche de tags populaires ou récents.
|
||||
**Params :** `query`, `sort`, `limit`.
|
||||
- `GET /tags` : Recherche de tags.
|
||||
- **Params :** `query` (recherche), `sort` (`popular` | `recent`), `limit`, `offset`.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
### 🛠️ Système & Médias
|
||||
|
||||
<Accordions>
|
||||
<Accordion title="Santé (/health)">
|
||||
- `GET /health` : Vérifie l'état de l'API et de la connexion à la base de données.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Médias (/media)">
|
||||
- `GET /media/*key` : Accès direct aux fichiers stockés sur S3. Supporte la mise en cache agressive.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Administration (/admin)">
|
||||
- `GET /admin/stats` : Récupère les statistiques globales de la plateforme. **Admin uniquement**.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
@@ -20,6 +20,13 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
|
||||
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
|
||||
</Cards>
|
||||
|
||||
### Webhooks / Services Externes
|
||||
### Stockage & Médias (S3)
|
||||
|
||||
Liste des intégrations tierces.
|
||||
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
|
||||
|
||||
1. **Proxification Backend** : Pour l'accès public via `/media/*`.
|
||||
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
|
||||
|
||||
### Notifications (Mail)
|
||||
|
||||
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.
|
||||
|
||||
@@ -35,10 +35,13 @@ erDiagram
|
||||
string username
|
||||
string email
|
||||
string display_name
|
||||
string avatar_url
|
||||
string bio
|
||||
string status
|
||||
}
|
||||
CONTENT {
|
||||
string title
|
||||
string slug
|
||||
string type
|
||||
string storage_key
|
||||
}
|
||||
@@ -82,6 +85,8 @@ erDiagram
|
||||
bytea email
|
||||
varchar email_hash
|
||||
varchar display_name
|
||||
varchar avatar_url
|
||||
varchar bio
|
||||
varchar password_hash
|
||||
user_status status
|
||||
bytea two_factor_secret
|
||||
@@ -100,6 +105,7 @@ erDiagram
|
||||
uuid category_id FK
|
||||
content_type type
|
||||
varchar title
|
||||
varchar slug
|
||||
varchar storage_key
|
||||
varchar mime_type
|
||||
integer file_size
|
||||
@@ -233,6 +239,8 @@ erDiagram
|
||||
varchar email_hash "UNIQUE, INDEXED"
|
||||
varchar username "UNIQUE, NOT NULL"
|
||||
varchar password_hash "NOT NULL"
|
||||
varchar avatar_url "NULLABLE"
|
||||
varchar bio "NULLABLE"
|
||||
bytea two_factor_secret "ENCRYPTED"
|
||||
boolean is_two_factor_enabled "DEFAULT false"
|
||||
timestamp gdpr_accepted_at "NULLABLE"
|
||||
@@ -241,6 +249,7 @@ erDiagram
|
||||
contents {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
uuid user_id "REFERENCES users(uuid)"
|
||||
varchar slug "UNIQUE, NOT NULL"
|
||||
varchar storage_key "UNIQUE, NOT NULL"
|
||||
integer file_size "NOT NULL"
|
||||
timestamp deleted_at "SOFT DELETE"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/documentation",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY documentation/package.json ./documentation/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Montage du cache pnpm
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm run --filter @memegoat/frontend build
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build avec cache Next.js
|
||||
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \
|
||||
pnpm run --filter @memegoat/frontend build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/frontend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function MemeModal({
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<ContentCard content={content} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
CategoryService.getAll()
|
||||
.then(setCategories)
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Catégories ({categories.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : categories.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center h-24">
|
||||
Aucune catégorie trouvée.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
{category.name}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{category.description || "Aucune description"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
export default function AdminContentsPage() {
|
||||
const [contents, setContents] = useState<Content[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
ContentService.getExplore({ limit: 20 })
|
||||
.then((res) => {
|
||||
setContents(res.data);
|
||||
setTotalCount(res.totalCount);
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
|
||||
|
||||
try {
|
||||
await ContentService.removeAdmin(id);
|
||||
setContents(contents.filter((c) => c.id !== id));
|
||||
setTotalCount((prev) => prev - 1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Contenus ({totalCount})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Contenu</TableHead>
|
||||
<TableHead>Catégorie</TableHead>
|
||||
<TableHead>Auteur</TableHead>
|
||||
<TableHead>Stats</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-10 w-[200px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : contents.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center h-24">
|
||||
Aucun contenu trouvé.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
contents.map((content) => (
|
||||
<TableRow key={content.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
||||
{content.type === "image" ? (
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Video className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{content.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{content.type} • {content.mimeType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{content.category?.name || "Sans catégorie"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>@{content.author.username}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" /> {content.views}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" /> {content.usageCount}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(content.id)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { type AdminStats, adminService } from "@/services/admin.service";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminService
|
||||
.getStats()
|
||||
.then(setStats)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError("Impossible de charger les statistiques.");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center gap-4 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
<p className="text-xl font-semibold">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "Utilisateurs",
|
||||
value: stats?.users,
|
||||
icon: Users,
|
||||
href: "/admin/users",
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Contenus",
|
||||
value: stats?.contents,
|
||||
icon: FileText,
|
||||
href: "/admin/contents",
|
||||
color: "text-green-500",
|
||||
},
|
||||
{
|
||||
title: "Catégories",
|
||||
value: stats?.categories,
|
||||
icon: LayoutGrid,
|
||||
href: "/admin/categories",
|
||||
color: "text-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard Admin</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{statCards.map((card) => (
|
||||
<Link key={card.title} href={card.href}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
UserService.getUsersAdmin()
|
||||
.then((res) => {
|
||||
setUsers(res.data);
|
||||
setTotalCount(res.totalCount);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await UserService.removeUserAdmin(uuid);
|
||||
setUsers(users.filter((u) => u.uuid !== uuid));
|
||||
setTotalCount((prev) => prev - 1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Utilisateurs ({totalCount})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Utilisateur</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date d'inscription</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[50px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center h-24">
|
||||
Aucun utilisateur trouvé.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.uuid}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
{user.displayName || user.username}
|
||||
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.status === "active" ? "success" : "destructive"}>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{format(new Date(user.createdAt), "PPP", { locale: fr })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(user.uuid)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export default function HelpPage() {
|
||||
const faqs = [
|
||||
{
|
||||
question: "Comment puis-je publier un mème ?",
|
||||
answer:
|
||||
"Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.",
|
||||
},
|
||||
{
|
||||
question: "Quels formats de fichiers sont acceptés ?",
|
||||
answer:
|
||||
"Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.",
|
||||
},
|
||||
{
|
||||
question: "Comment fonctionnent les favoris ?",
|
||||
answer:
|
||||
"En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.",
|
||||
},
|
||||
{
|
||||
question: "Puis-je supprimer un mème que j'ai publié ?",
|
||||
answer:
|
||||
"Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.",
|
||||
},
|
||||
{
|
||||
question: "Comment fonctionne le système de recherche ?",
|
||||
answer:
|
||||
"Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto py-12 px-4">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="bg-primary/10 p-3 rounded-xl">
|
||||
<HelpCircle className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Centre d'aide</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 border rounded-2xl p-6 shadow-sm mb-12">
|
||||
<h2 className="text-xl font-semibold mb-6">Foire Aux Questions</h2>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{faqs.map((faq, index) => (
|
||||
<AccordionItem key={faq.question} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-lg font-medium">Vous ne trouvez pas de réponse ?</h2>
|
||||
<p className="text-muted-foreground">
|
||||
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
|
||||
</p>
|
||||
<p className="font-semibold text-primary">contact@memegoat.fr</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { UserNavMobile } from "@/components/user-nav-mobile";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -16,26 +17,31 @@ export default function DashboardLayout({
|
||||
modal: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex flex-row overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1" />
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
|
||||
{children}
|
||||
{modal}
|
||||
</main>
|
||||
<React.Suspense fallback={null}>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex flex-row overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1 flex justify-center">
|
||||
<span className="font-bold text-primary text-lg">MemeGoat</span>
|
||||
</div>
|
||||
<UserNavMobile />
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
|
||||
{children}
|
||||
{modal}
|
||||
</main>
|
||||
<React.Suspense fallback={null}>
|
||||
<MobileFilters />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<MobileFilters />
|
||||
<SearchSidebar />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchSidebar />
|
||||
</React.Suspense>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard } from "@/components/content-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
||||
@@ -40,6 +41,7 @@ export default async function MemePage({
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user