From 52baaa99566ead19498b0dccdbc730635b5d23d7 Mon Sep 17 00:00:00 2001 From: dev4 Date: Sat, 20 Sep 2025 20:08:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Optimisation=20Dockerfile,=20documentat?= =?UTF-8?q?ion=20compl=C3=A8te=20et=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile optimisé: seulement docker-cli pour bitcoin-cli - README.md complet avec tous les endpoints et configuration - CHANGELOG.md mis à jour avec v1.1.1 - Tests unitaires pour les routes funds - Tests d'intégration pour le signer - Configuration Jest avec coverage - Scripts de test dans package.json - Fichier .env.test pour les tests --- .env.exemple | 1 - .env.test | 17 ++++ CHANGELOG.md | 29 ++++++ Dockerfile | 7 +- Dockerfile.backup | 48 ++++++++++ Dockerfile.full-tools | 61 +++++++++++++ README.md | 147 ++++++++++++++++++++++++++++--- jest.config.js | 20 +++++ package.json | 14 ++- src/routes/funds.routes.ts | 8 +- tests/integration/signer.test.ts | 62 +++++++++++++ tests/setup.ts | 17 ++++ tests/unit/funds.routes.test.ts | 136 ++++++++++++++++++++++++++++ 13 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 .env.test create mode 100644 Dockerfile.backup create mode 100644 Dockerfile.full-tools create mode 100644 jest.config.js create mode 100644 tests/integration/signer.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/unit/funds.routes.test.ts diff --git a/.env.exemple b/.env.exemple index 7949a70..fdb9358 100644 --- a/.env.exemple +++ b/.env.exemple @@ -11,7 +11,6 @@ IDNOT_API_BASE_URL=https://qual-api.notaires.fr # Configuration serveur APP_HOST=dev4.4nkweb.com -PORT=8080 # API_BASE_URL=https://demo.4nkweb.com/back API_BASE_URL=https://dev4.4nkweb.com/back # DEFAULT_STORAGE=https://demo.4nkweb.com/storage diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..5e7a4d5 --- /dev/null +++ b/.env.test @@ -0,0 +1,17 @@ +# Configuration de test +NODE_ENV=test +PORT=8081 +LOG_LEVEL=error + +# Configuration signer pour les tests +SIGNER_WS_URL=ws://localhost:9090 +SIGNER_BASE_URL=http://localhost:9090 +SIGNER_API_KEY=test-api-key + +# Configuration CORS pour les tests +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8081 + +# Configuration des services externes (mocks) +IDNOT_API_KEY=test-key +OVH_APP_KEY=test-key +STRIPE_SECRET_KEY=sk_test_mock diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa7e9a..b1930e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,3 +60,32 @@ - IdNot: fallback quand `profile_idn` absent dans le token. - Récupération des rattachements via `sub` puis sélection d’un rattachement d’étude (office) si présent. - Objectif: permettre le login même si le JWT IdNot ne fournit pas `profile_idn`. + +## v1.1.1 + +### 🚀 Nouvelles fonctionnalités +- **API de transfert automatique de fonds** : Endpoints `/api/v1/funds/transfer` et `/api/v1/funds/check` +- **Intégration Docker CLI** : Support pour l'exécution de `bitcoin-cli` via Docker +- **Endpoint racine** : Documentation API accessible via `GET /` + +### 🔧 Améliorations +- **Dockerfile optimisé** : Installation minimale avec seulement `docker-cli` +- **Configuration signer** : Connexion au signer distant (dev3.4nkweb.com) +- **Gestion d'erreurs** : Amélioration de la gestion d'erreurs TypeScript +- **Documentation** : README.md complet avec tous les endpoints + +### 🐛 Corrections +- **Healthcheck** : Correction de l'endpoint `/api/v1/health` +- **CORS** : Configuration dynamique des origines autorisées +- **TypeScript** : Résolution des erreurs de compilation + +### 🧪 Tests +- **Tests unitaires** : Tests pour les routes funds +- **Tests d'intégration** : Tests de connectivité signer +- **Tests de build** : Vérification de la compilation TypeScript + +### 📦 Déploiement +- **Image Docker** : Tag `ext` pour les builds CI +- **Configuration** : Variables d'environnement optimisées +- **Monitoring** : Healthcheck et logs structurés + diff --git a/Dockerfile b/Dockerfile index f9d39f9..0a7a327 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,9 +33,14 @@ RUN npm prune --omit=dev && npm cache clean --force FROM node:19-alpine WORKDIR /app +# Installation minimale : seulement docker-cli pour bitcoin-cli +RUN apk add --no-cache docker-cli + # Création d'un utilisateur non-root RUN adduser -D appuser --uid 10000 && \ chown -R appuser /app + +# Retour à l'utilisateur appuser USER appuser # Copie des artefacts de build et des deps prod @@ -45,4 +50,4 @@ COPY --from=builder --chown=appuser:appuser /app/dist ./dist COPY --from=builder /sdk-signer-client /sdk-signer-client EXPOSE 8080 -CMD ["npm", "start"] \ No newline at end of file +CMD ["npm", "start"] diff --git a/Dockerfile.backup b/Dockerfile.backup new file mode 100644 index 0000000..f9d39f9 --- /dev/null +++ b/Dockerfile.backup @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.4 +FROM node:19-alpine AS builder +WORKDIR /app + +# Outils nécessaires pour cloner le dépôt privé +RUN apk add --no-cache git openssh-client + +# Prépare SSH pour git.4nkweb.com +RUN mkdir -p /root/.ssh && \ + ssh-keyscan git.4nkweb.com >> /root/.ssh/known_hosts + +# Clone le SDK à côté de /app afin que ../sdk-signer-client soit disponible +RUN --mount=type=ssh git clone -b dev \ + ssh://git@git.4nkweb.com/4nk/sdk-signer-client.git /sdk-signer-client + +# Build de la dépendance SDK +WORKDIR /sdk-signer-client +RUN npm ci && npm run build + +# Installation des dépendances de l'app +WORKDIR /app +COPY package*.json ./ +RUN npm install + +# Copie et build des sources de l'app +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Réduction aux deps de production +RUN npm prune --omit=dev && npm cache clean --force + +FROM node:19-alpine +WORKDIR /app + +# Création d'un utilisateur non-root +RUN adduser -D appuser --uid 10000 && \ + chown -R appuser /app +USER appuser + +# Copie des artefacts de build et des deps prod +COPY --from=builder --chown=appuser:appuser /app/package*.json ./ +COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules +COPY --from=builder --chown=appuser:appuser /app/dist ./dist +COPY --from=builder /sdk-signer-client /sdk-signer-client + +EXPOSE 8080 +CMD ["npm", "start"] \ No newline at end of file diff --git a/Dockerfile.full-tools b/Dockerfile.full-tools new file mode 100644 index 0000000..10e0a96 --- /dev/null +++ b/Dockerfile.full-tools @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1.4 +FROM node:19-alpine AS builder +WORKDIR /app + +# Outils nécessaires pour cloner le dépôt privé +RUN apk add --no-cache git openssh-client + +# Prépare SSH pour git.4nkweb.com +RUN mkdir -p /root/.ssh && \ + ssh-keyscan git.4nkweb.com >> /root/.ssh/known_hosts + +# Clone le SDK à côté de /app afin que ../sdk-signer-client soit disponible +RUN --mount=type=ssh git clone -b dev \ + ssh://git@git.4nkweb.com/4nk/sdk-signer-client.git /sdk-signer-client + +# Build de la dépendance SDK +WORKDIR /sdk-signer-client +RUN npm ci && npm run build + +# Installation des dépendances de l'app +WORKDIR /app +COPY package*.json ./ +RUN npm install + +# Copie et build des sources de l'app +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Réduction aux deps de production +RUN npm prune --omit=dev && npm cache clean --force + +FROM node:19-alpine +WORKDIR /app + +# Installation de Docker CLI et des outils nécessaires +RUN apk add --no-cache \ + docker-cli \ + curl \ + git \ + wget \ + jq \ + busybox-extras \ + npm \ + coreutils + +# Création d'un utilisateur non-root +RUN adduser -D appuser --uid 10000 && \ + chown -R appuser /app + +# Retour à l'utilisateur appuser +USER appuser + +# Copie des artefacts de build et des deps prod +COPY --from=builder --chown=appuser:appuser /app/package*.json ./ +COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules +COPY --from=builder --chown=appuser:appuser /app/dist ./dist +COPY --from=builder /sdk-signer-client /sdk-signer-client + +EXPOSE 8080 +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 37bd15f..6b77ec7 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,147 @@ -# Mini Serveur Express +# LeCoffre Backend Mini -Un mini serveur Express avec une route `/api/ping` qui renvoie "Hello World". +Serveur Express.js backend pour l'application LeCoffre, fournissant des APIs pour l'authentification, la gestion des fonds, et l'intégration avec les services externes. -## Installation +## 🚀 Fonctionnalités + +- **API REST** : Endpoints pour l'authentification, gestion des fonds, SMS, email +- **Intégration Signer** : Connexion WebSocket au signer distant (dev3.4nkweb.com) +- **Transfert automatique de fonds** : API pour transférer des fonds du wallet mining vers le relay +- **Services externes** : IdNot, Stripe, OVH, Mailchimp +- **Healthcheck** : Monitoring de l'état du service + +## 📋 Prérequis + +- Node.js 19+ +- Docker (pour l'exécution) +- Accès au signer distant (dev3.4nkweb.com) + +## 🛠️ Installation ```bash npm install ``` -## Démarrage du serveur - -```bash -npm start -``` - -Ou en mode développement avec rechargement automatique : +## 🏃‍♂️ Démarrage +### Mode développement ```bash npm run dev ``` -## Utilisation +### Mode production +```bash +npm start +``` -Une fois le serveur démarré, la route ping est accessible à : -- http://localhost:3000/api/ping +### Build +```bash +npm run build +``` -Cette route renvoie un objet JSON avec le message "Hello World". +## 📡 Endpoints API + +### Endpoints principaux +- `GET /` - Documentation de l'API +- `GET /api/v1/health` - Healthcheck du service + +### Gestion des fonds +- `POST /api/v1/funds/transfer` - Transfert automatique de fonds +- `GET /api/v1/funds/check` - Vérification des fonds du relay + +### Authentification +- `POST /api/v1/idnot/auth` - Authentification IdNot + +### Services externes +- `POST /api/sms` - Service SMS +- `POST /api/email` - Service email +- `POST /api/stripe` - Service Stripe +- `POST /api/subscription` - Gestion des abonnements + +## 🔧 Configuration + +### Variables d'environnement principales + +```bash +# Configuration serveur +PORT=8080 +NODE_ENV=production + +# Signer distant +SIGNER_WS_URL=ws://dev3.4nkweb.com:9090 +SIGNER_BASE_URL=https://dev3.4nkweb.com +SIGNER_API_KEY=your-api-key-change-this + +# Relays +RELAY_URLS=wss://dev4.4nkweb.com/ws/,wss://dev3.4nkweb.com/ws/ +VITE_BOOTSTRAPURL=https://dev4.4nkweb.com/ws/ + +# CORS +CORS_ALLOWED_ORIGINS=https://dev4.4nkweb.com,http://local.4nkweb.com:3000 +``` + +### Configuration complète +Voir le fichier `.env.exemple` pour toutes les variables disponibles. + +## 🐳 Docker + +### Build de l'image +```bash +docker build -t lecoffre-back-mini . +``` + +### Exécution +```bash +docker run -p 8080:8080 --env-file .env lecoffre-back-mini +``` + +### Image optimisée +L'image Docker est optimisée avec seulement `docker-cli` pour l'exécution de `bitcoin-cli` via Docker. + +## 🧪 Tests + +### Tests unitaires +```bash +npm test +``` + +### Tests d'intégration +```bash +npm run test:integration +``` + +### Tests spécifiques +```bash +npm run test:funds # Tests des routes funds +npm run test:signer # Tests de connectivité signer +``` + +## 📊 Monitoring + +### Healthcheck +```bash +curl http://localhost:8080/api/v1/health +``` + +### Logs +Les logs sont structurés avec des niveaux de log appropriés. + +## 🔄 CI/CD + +Le projet utilise Gitea CI avec le tag `ext` pour déclencher les builds automatiques. + +## 📝 Changelog + +Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique des versions. + +## 🤝 Contribution + +1. Fork le projet +2. Créer une branche feature (`git checkout -b feature/AmazingFeature`) +3. Commit les changements (`git commit -m 'Add some AmazingFeature'`) +4. Push vers la branche (`git push origin feature/AmazingFeature`) +5. Ouvrir une Pull Request + +## 📄 Licence + +Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..ecb4c77 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src', '/tests'], + testMatch: [ + '**/__tests__/**/*.ts', + '**/?(*.)+(spec|test).ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/tests/setup.ts'], + testTimeout: 10000, +}; diff --git a/package.json b/package.json index 009f75f..8a8b082 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,14 @@ "dev:js": "nodemon src/server.js", "test:db": "npm run build && node test-db-init.js", "test:rattachements": "node test-rattachements-endpoint.js", - "test:quick": "node quick-test-rattachements.js" + "test:quick": "node quick-test-rattachements.js", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:unit": "jest tests/unit", + "test:integration": "jest tests/integration", + "test:funds": "jest tests/unit/funds.routes.test.ts", + "test:signer": "jest tests/integration/signer.test.ts" }, "dependencies": { "@mailchimp/mailchimp_transactional": "^1.0.59", @@ -28,11 +35,16 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", "@types/node": "^20.11.19", "@types/node-fetch": "^2.6.11", "@types/pg": "^8.11.0", + "@types/supertest": "^6.0.3", "@types/uuid": "^9.0.8", + "jest": "^30.1.3", "nodemon": "^3.0.1", + "supertest": "^7.1.4", + "ts-jest": "^29.4.4", "ts-node": "^10.9.2", "typescript": "^5.3.3" } diff --git a/src/routes/funds.routes.ts b/src/routes/funds.routes.ts index f4a453f..74ec935 100644 --- a/src/routes/funds.routes.ts +++ b/src/routes/funds.routes.ts @@ -64,13 +64,15 @@ router.post('/transfer', async (req: Request, res: Response) => { sourceBalance: balance, targetBalance: targetBalanceValue }); + return; } catch (error) { console.error('❌ Erreur lors du transfert automatique:', error); res.status(500).json({ success: false, - error: `Erreur lors du transfert: ${error.message}` + error: `Erreur lors du transfert: ${error instanceof Error ? error.message : String(error)}` }); + return; } }); @@ -100,13 +102,15 @@ router.get('/check', async (req: Request, res: Response) => { }, needsTransfer: parseInt(outputsCount.trim()) === 0 && parseFloat(relayBalance.trim()) < 0.001 }); + return; } catch (error) { console.error('❌ Erreur lors de la vérification des fonds:', error); res.status(500).json({ success: false, - error: `Erreur lors de la vérification: ${error.message}` + error: `Erreur lors de la vérification: ${error instanceof Error ? error.message : String(error)}` }); + return; } }); diff --git a/tests/integration/signer.test.ts b/tests/integration/signer.test.ts new file mode 100644 index 0000000..901d5d1 --- /dev/null +++ b/tests/integration/signer.test.ts @@ -0,0 +1,62 @@ +import request from 'supertest'; +import express from 'express'; +import { routes } from '../../src/routes'; +import { SignerService } from '../../src/services/signer'; + +describe('Signer Integration Tests', () => { + let app: express.Application; + + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use('/', routes); + }); + + describe('Health Check', () => { + it('devrait retourner le statut du service', async () => { + const response = await request(app) + .get('/api/v1/health'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('healthy'); + }); + }); + + describe('Root Endpoint', () => { + it('devrait retourner la documentation de l\'API', async () => { + const response = await request(app) + .get('/'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('LeCoffre Backend API'); + expect(response.body.version).toBe('1.0.0'); + expect(response.body.endpoints).toBeDefined(); + }); + }); + + describe('Signer Service', () => { + it('devrait avoir une configuration valide', () => { + const healthCheck = SignerService.getHealthCheck(); + + expect(healthCheck).toBeDefined(); + expect(healthCheck.state).toBeDefined(); + expect(healthCheck.reconnectAttempts).toBeDefined(); + }); + + it('devrait gérer la déconnexion gracieusement', async () => { + // Test de la gestion de la déconnexion + const healthCheck = SignerService.getHealthCheck(); + expect(healthCheck.state).toBeDefined(); + }); + }); + + describe('CORS Configuration', () => { + it('devrait accepter les requêtes depuis les origines autorisées', async () => { + const response = await request(app) + .get('/') + .set('Origin', 'https://dev4.4nkweb.com'); + + expect(response.status).toBe(200); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..5de60d6 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,17 @@ +// Configuration globale des tests +import dotenv from 'dotenv'; + +// Chargement des variables d'environnement pour les tests +dotenv.config({ path: '.env.test' }); + +// Configuration des timeouts +jest.setTimeout(10000); + +// Mock des services externes par défaut +jest.mock('child_process', () => ({ + exec: jest.fn(), +})); + +// Configuration des logs pour les tests +process.env.NODE_ENV = 'test'; +process.env.LOG_LEVEL = 'error'; diff --git a/tests/unit/funds.routes.test.ts b/tests/unit/funds.routes.test.ts new file mode 100644 index 0000000..5d28509 --- /dev/null +++ b/tests/unit/funds.routes.test.ts @@ -0,0 +1,136 @@ +import request from 'supertest'; +import express from 'express'; +import fundsRoutes from '../../src/routes/funds.routes'; +import { exec } from 'child_process'; + +// Mock de child_process +jest.mock('child_process'); +const mockExec = exec as jest.MockedFunction; + +describe('Funds Routes', () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/v1/funds', fundsRoutes); + + // Reset des mocks + jest.clearAllMocks(); + }); + + describe('POST /api/v1/funds/transfer', () => { + it('devrait transférer des fonds avec succès', async () => { + // Mock des commandes bitcoin-cli + mockExec.mockImplementation((command, callback) => { + if (command.includes('getblockchaininfo')) { + callback(null, { stdout: '{"chain":"signet","blocks":1000}', stderr: '' }); + } else if (command.includes('loadwallet')) { + callback(null, { stdout: '{"name":"default","warning":""}', stderr: '' }); + } else if (command.includes('getbalance')) { + callback(null, { stdout: '1.5', stderr: '' }); + } else if (command.includes('getnewaddress')) { + callback(null, { stdout: 'bc1qtest123', stderr: '' }); + } else if (command.includes('sendtoaddress')) { + callback(null, { stdout: 'txid123456789', stderr: '' }); + } else if (command.includes('generatetoaddress')) { + callback(null, { stdout: '["blockhash123"]', stderr: '' }); + } else if (command.includes('restart')) { + callback(null, { stdout: '', stderr: '' }); + } + return {} as any; + }); + + const response = await request(app) + .post('/api/v1/funds/transfer') + .send({ + amount: 0.01, + source: 'mining_mnemonic', + target: 'default' + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.message).toContain('Transfert de 0.01 BTC réussi'); + }); + + it('devrait retourner une erreur si le solde est insuffisant', async () => { + mockExec.mockImplementation((command, callback) => { + if (command.includes('getbalance')) { + callback(null, { stdout: '0.001', stderr: '' }); + } else { + callback(null, { stdout: '', stderr: '' }); + } + return {} as any; + }); + + const response = await request(app) + .post('/api/v1/funds/transfer') + .send({ + amount: 0.01, + source: 'mining_mnemonic', + target: 'default' + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Solde insuffisant'); + }); + + it('devrait gérer les erreurs de commande bitcoin-cli', async () => { + mockExec.mockImplementation((command, callback) => { + callback(new Error('Bitcoin Core not running'), { stdout: '', stderr: 'Error' }); + return {} as any; + }); + + const response = await request(app) + .post('/api/v1/funds/transfer') + .send({ + amount: 0.01, + source: 'mining_mnemonic', + target: 'default' + }); + + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Erreur lors du transfert'); + }); + }); + + describe('GET /api/v1/funds/check', () => { + it('devrait vérifier les fonds avec succès', async () => { + mockExec.mockImplementation((command, callback) => { + if (command.includes('cat /home/bitcoin/.4nk/default')) { + callback(null, { stdout: '{"outputs":[{"value":1000000}]}', stderr: '' }); + } else if (command.includes('getbalance')) { + callback(null, { stdout: '0.01', stderr: '' }); + } else { + callback(null, { stdout: '', stderr: '' }); + } + return {} as any; + }); + + const response = await request(app) + .get('/api/v1/funds/check'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.relay.outputsCount).toBe(1); + expect(response.body.relay.balance).toBe(0.01); + }); + + it('devrait gérer les erreurs de vérification', async () => { + mockExec.mockImplementation((command, callback) => { + callback(new Error('File not found'), { stdout: '', stderr: 'Error' }); + return {} as any; + }); + + const response = await request(app) + .get('/api/v1/funds/check'); + + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Erreur lors de la vérification'); + }); + }); +});