Skip to content

Commit a2cfb7d

Browse files
author
maxence
committed
add test
1 parent 479b77e commit a2cfb7d

11 files changed

Lines changed: 1093 additions & 8 deletions

File tree

‎SECURITY.md‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Sécurité — TableMasterApi
2+
3+
> Document de référence Bloc 2 — C2.2.3 (sécurisation du code source) du RNCP39583.
4+
> Mapping des **mesures concrètes** dans le code de l'API face à l'**OWASP Top 10 (édition 2021)**.
5+
6+
## Synthèse
7+
8+
| Faille OWASP | Couverture | Localisation |
9+
| --- | :---: | --- |
10+
| A01 Broken Access Control | ✅ | `[Authorize]` + `ICurrentUserService` |
11+
| A02 Cryptographic Failures | ✅ | TLS (Nginx), `BCrypt`/hash mot de passe, JWT signé |
12+
| A03 Injection | ✅ | Dapper paramètres nommés, jamais de concat |
13+
| A04 Insecure Design | ✅ | Architecture en couches, threat-model documenté |
14+
| A05 Security Misconfiguration | ✅ | Secrets hors code, Docker minimal, headers Nginx |
15+
| A06 Vulnerable Components | ⚠️ | Dependabot à activer (manuel pour l'instant) |
16+
| A07 Identification & Auth Failures | ✅ | JWT court + refresh token rotation |
17+
| A08 Software & Data Integrity | ✅ | Migrations DbUp signées en source, CI vérifiée |
18+
| A09 Logging & Monitoring | ⚠️ | `ILogger` présent, Sentry à brancher |
19+
| A10 Server-Side Request Forgery | ✅ | Aucun proxy d'URL utilisateur côté API |
20+
21+
---
22+
23+
## A01 — Broken Access Control
24+
25+
**Risque** : un utilisateur peut accéder à des ressources qui ne lui appartiennent pas.
26+
27+
**Mesures :**
28+
- Tous les controllers sensibles décorés `[Authorize]`.
29+
- L'identité courante est résolue via `ICurrentUserService` (claim `sub`) — jamais via paramètre client.
30+
- Les méthodes "mes ressources" (`/Reservation/My`, etc.) filtrent côté DAL sur `current_user.Id`.
31+
- Les opérations sur le restaurant vérifient que `restaurant.UserId == currentUser.Id` avant write/update/delete.
32+
33+
**À auditer périodiquement** : chaque nouveau controller doit prouver son contrôle d'accès dans la PR.
34+
35+
## A02 — Cryptographic Failures
36+
37+
**Risque** : transport en clair, secrets stockés faiblement.
38+
39+
**Mesures :**
40+
- TLS terminé au reverse-proxy Nginx (Let's Encrypt) ; HSTS activé.
41+
- Mots de passe utilisateurs hashés (jamais stockés en clair).
42+
- JWT signés HS256 avec secret stocké en variable d'environnement (`Jwt:Key`) — jamais commité.
43+
- Clé Firebase Admin (`tablemaster-firebase.json`) hors Git, montée en volume Docker.
44+
45+
**Contre-exemple à ne pas faire** : `appsettings.json` ne doit contenir aucun secret de production.
46+
47+
## A03 — Injection
48+
49+
**Risque** : injection SQL via paramètres utilisateur.
50+
51+
**Mesures :**
52+
- 100 % des requêtes utilisent **Dapper avec paramètres nommés** :
53+
```csharp
54+
await connection.QueryAsync<UserOut>(
55+
"SELECT * FROM Users WHERE Email = @Email",
56+
new { Email = email });
57+
```
58+
- **Aucune** concaténation de string avec entrée utilisateur dans les DAL (`TableMasterApi/DAL/`).
59+
- Type handlers Dapper personnalisés (ex. `PostgresTimeSpanHandler`) maîtrisent les conversions sensibles.
60+
61+
## A04 — Insecure Design
62+
63+
**Mesures :**
64+
- Architecture en couches : Controllers (HTTP) → Services (logique) → DAL (données).
65+
- Controllers minces : aucun accès BDD ni logique métier complexe.
66+
- DAL via interfaces (`DAL/Interfaces/`) — mockables, testables, substituables.
67+
- Schéma BDD piloté par migrations versionnées (`sql-scripts/`).
68+
- Threat-model documenté dans le dossier de cadrage Bloc 1.
69+
70+
## A05 — Security Misconfiguration
71+
72+
**Mesures :**
73+
- Image Docker basée sur `mcr.microsoft.com/dotnet/aspnet` (officielle, minimale).
74+
- `appsettings.Development.json` jamais déployé en prod (CI build en `Release`).
75+
- Swagger activé uniquement en dev ou si `Features:EnableSwagger=true` — désactivé par défaut en prod.
76+
- Reverse-proxy Nginx avec headers : `X-Content-Type-Options`, `Strict-Transport-Security`, `Referrer-Policy`.
77+
- Health checks `/health` et `/ready` séparés des endpoints métier.
78+
79+
## A06 — Vulnerable and Outdated Components
80+
81+
**Mesures :**
82+
- `dotnet list package --vulnerable` lancé avant chaque release.
83+
- Versions NuGet pinnées dans le `.csproj`.
84+
85+
**À automatiser** : activer **Dependabot** ou **Renovate** sur le repo GitHub pour PR automatiques de mise à jour.
86+
87+
## A07 — Identification & Authentication Failures
88+
89+
**Mesures :**
90+
- JWT à courte durée (~15 min) + refresh token long avec **rotation à chaque usage**.
91+
- Refresh tokens persistés en BDD avec date d'expiration + `IsRevoked` (`AuthDAL`).
92+
- Logout : révocation côté serveur (DeviceToken supprimé) + suppression côté client.
93+
- `JwtService` centralise génération + validation — aucun fork dans les controllers.
94+
95+
**Améliorations à venir** : rate limiting sur `/Auth` (ASP.NET Rate Limiting middleware) pour mitiger le brute-force.
96+
97+
## A08 — Software & Data Integrity Failures
98+
99+
**Mesures :**
100+
- Migrations DbUp embarquées comme **ressources** dans l'assembly (`<EmbeddedResource>`) → impossibles à modifier sans rebuild.
101+
- Scripts SQL numérotés et **idempotents** quand possible.
102+
- CI lance `dotnet test` avant tout build d'image Docker → pas d'image publiée si tests rouges.
103+
104+
## A09 — Security Logging & Monitoring Failures
105+
106+
**État actuel :**
107+
- `ILogger<T>` injecté dans services et controllers sensibles (Auth, JWT, refresh).
108+
- Logs structurés via le provider par défaut ASP.NET.
109+
110+
**À compléter** :
111+
- Brancher **Sentry .NET SDK** dans `Program.cs` pour collecter exceptions + traces.
112+
- Ajouter un middleware de `correlationId` pour tracer une requête de bout en bout.
113+
- Activer audit log sur opérations sensibles (login, refresh, logout, delete account).
114+
115+
## A10 — Server-Side Request Forgery
116+
117+
**État** : l'API ne consomme **pas d'URL fournie par l'utilisateur** côté serveur. Les seules sorties HTTP sont :
118+
- Firebase Admin (FCM) — destination figée dans la lib.
119+
- Google Maps (si utilisé côté serveur) — endpoint figé.
120+
121+
Aucune fonctionnalité de type "fetch URL", "import depuis lien", "webhook entrant" → faible surface SSRF.
122+
123+
---
124+
125+
## Audit & révision
126+
127+
| Quand | Action |
128+
| --- | --- |
129+
| Avant chaque release majeure | Lancer `dotnet list package --vulnerable --include-transitive` |
130+
| À chaque PR | CI vérifie build + tests + scan image Docker (à brancher) |
131+
| Trimestriel | Audit OWASP ZAP en mode automatisé contre l'API de staging |
132+
| Annuel | Revue manuelle de ce document — corriger les points vieillis |
133+
134+
## Contact
135+
136+
Découverte d'une vulnérabilité → ouvrir un Security Advisory privé sur GitHub, **ne pas** ouvrir d'issue publique.

‎TableMasterApi.Tests/Controllers/DeviceTokenControllerTests.cs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,46 @@ public async Task DeleteDeviceToken_ShouldDeleteTokenForCurrentUser()
6666
result.Should().BeOfType<OkResult>();
6767
dal.Verify(x => x.DeleteDeviceTokenAsync(42, "device-token"), Times.Once);
6868
}
69+
70+
[Fact]
71+
public async Task RegisterDeviceToken_ShouldReturnBadRequest_WhenModelIsNull()
72+
{
73+
var dal = new Mock<IDeviceTokenDAL>();
74+
var controller = new DeviceTokenController(dal.Object, _jwtService);
75+
76+
var result = await controller.RegisterDeviceToken(null!);
77+
78+
result.Should().BeOfType<BadRequestObjectResult>();
79+
dal.Verify(x => x.SaveDeviceTokenAsync(It.IsAny<long>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
80+
}
81+
82+
[Theory]
83+
[InlineData("ios")]
84+
[InlineData("android")]
85+
public async Task RegisterDeviceToken_ShouldPersistExplicitPlatform(string platform)
86+
{
87+
var dal = new Mock<IDeviceTokenDAL>();
88+
var controller = new DeviceTokenController(dal.Object, _jwtService);
89+
ControllerTestHelper.SetBearerToken(controller, _jwtService, 17);
90+
91+
var result = await controller.RegisterDeviceToken(new DeviceTokenIn { DeviceToken = "tok", DevicePlatform = platform });
92+
93+
result.Should().BeOfType<OkResult>();
94+
dal.Verify(x => x.SaveDeviceTokenAsync(17, "tok", platform), Times.Once);
95+
}
96+
97+
[Theory]
98+
[InlineData(null)]
99+
[InlineData(" ")]
100+
public async Task DeleteDeviceToken_ShouldReturnBadRequest_WhenTokenIsNullOrWhitespace(string? token)
101+
{
102+
var dal = new Mock<IDeviceTokenDAL>();
103+
var controller = new DeviceTokenController(dal.Object, _jwtService);
104+
105+
var result = await controller.DeleteDeviceToken(token!);
106+
107+
result.Should().BeOfType<BadRequestObjectResult>();
108+
dal.Verify(x => x.DeleteDeviceTokenAsync(It.IsAny<long>(), It.IsAny<string>()), Times.Never);
109+
}
69110
}
70111
}

‎TableMasterApi.Tests/Controllers/MenuControllerTests.cs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,73 @@ public async Task Delete_ShouldReturnOk_WhenUserOwnsRestaurant()
9191
result.Should().BeOfType<OkResult>();
9292
}
9393

94+
[Fact]
95+
public async Task GetByRestaurant_ShouldReturnOkWithEmptyList_WhenNoMenus()
96+
{
97+
var menuDal = new Mock<IMenuDAL>();
98+
var controller = CreateController(menuDal);
99+
100+
menuDal.Setup(x => x.GetByRestaurantAsync(10)).ReturnsAsync(Array.Empty<MenuOut>());
101+
102+
var result = await controller.GetByRestaurant(10);
103+
104+
var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
105+
ok.Value.Should().BeAssignableTo<IEnumerable<MenuOut>>().Which.Should().BeEmpty();
106+
}
107+
108+
[Fact]
109+
public async Task Delete_ShouldReturnNotFound_WhenMenuDoesNotExist()
110+
{
111+
var menuDal = new Mock<IMenuDAL>();
112+
var restaurantDal = new Mock<IRestaurantDAL>();
113+
var controller = CreateController(menuDal, restaurantDal);
114+
ControllerTestHelper.SetBearerToken(controller, _jwtService, 42);
115+
116+
menuDal.Setup(x => x.GetByIdAsync(3)).ReturnsAsync((MenuOut?)null);
117+
118+
var result = await controller.Delete(3);
119+
120+
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
121+
notFound.Value.Should().Be("Le menu n'existe pas");
122+
restaurantDal.Verify(x => x.GetRestaurantById(It.IsAny<long>()), Times.Never);
123+
menuDal.Verify(x => x.DeleteAsync(It.IsAny<long>()), Times.Never);
124+
}
125+
126+
[Fact]
127+
public async Task Delete_ShouldReturnNotFound_WhenRestaurantDoesNotExist()
128+
{
129+
var menuDal = new Mock<IMenuDAL>();
130+
var restaurantDal = new Mock<IRestaurantDAL>();
131+
var controller = CreateController(menuDal, restaurantDal);
132+
ControllerTestHelper.SetBearerToken(controller, _jwtService, 42);
133+
134+
menuDal.Setup(x => x.GetByIdAsync(3)).ReturnsAsync(TestData.MenuOut());
135+
restaurantDal.Setup(x => x.GetRestaurantById(10)).ReturnsAsync((RestaurantOut?)null);
136+
137+
var result = await controller.Delete(3);
138+
139+
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
140+
notFound.Value.Should().Be("Le restaurant n'existe pas");
141+
menuDal.Verify(x => x.DeleteAsync(It.IsAny<long>()), Times.Never);
142+
}
143+
144+
[Fact]
145+
public async Task Delete_ShouldReturnNotFound_WhenDalReportsNoRowAffected()
146+
{
147+
var menuDal = new Mock<IMenuDAL>();
148+
var restaurantDal = new Mock<IRestaurantDAL>();
149+
var controller = CreateController(menuDal, restaurantDal);
150+
ControllerTestHelper.SetBearerToken(controller, _jwtService, 42);
151+
152+
menuDal.Setup(x => x.GetByIdAsync(3)).ReturnsAsync(TestData.MenuOut());
153+
restaurantDal.Setup(x => x.GetRestaurantById(10)).ReturnsAsync(TestData.RestaurantOut(userId: 42));
154+
menuDal.Setup(x => x.DeleteAsync(3)).ReturnsAsync(false);
155+
156+
var result = await controller.Delete(3);
157+
158+
result.Should().BeOfType<NotFoundResult>();
159+
}
160+
94161
private MenuController CreateController(
95162
Mock<IMenuDAL>? menuDal = null,
96163
Mock<IRestaurantDAL>? restaurantDal = null)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Reflection;
2+
using DbUp;
3+
using Npgsql;
4+
using TableMasterApi.DAL.Handlers;
5+
using TableMasterApi.Model;
6+
using Testcontainers.PostgreSql;
7+
using Xunit;
8+
9+
namespace TableMasterApi.Tests.Integration
10+
{
11+
/// <summary>
12+
/// Fixture partagée qui démarre un conteneur PostgreSQL Testcontainers,
13+
/// applique les migrations DbUp embarquées dans l'assembly de l'API,
14+
/// puis fournit une <see cref="ConfigPerso"/> branchée sur ce conteneur.
15+
///
16+
/// Si Docker n'est pas disponible localement, <see cref="IsAvailable"/>
17+
/// reste à <c>false</c> et les tests d'intégration sont skippés.
18+
/// </summary>
19+
public sealed class PostgresFixture : IAsyncLifetime
20+
{
21+
private PostgreSqlContainer? _container;
22+
private static readonly object _dapperHandlerLock = new();
23+
private static bool _dapperHandlerRegistered;
24+
25+
public bool IsAvailable { get; private set; }
26+
public string? UnavailableReason { get; private set; }
27+
public string ConnectionString { get; private set; } = string.Empty;
28+
public ConfigPerso Config { get; private set; } = new();
29+
30+
public async Task InitializeAsync()
31+
{
32+
try
33+
{
34+
_container = new PostgreSqlBuilder()
35+
.WithImage("postgres:16-alpine")
36+
.WithDatabase("tablemaster_tests")
37+
.WithUsername("tests")
38+
.WithPassword("tests")
39+
.Build();
40+
await _container.StartAsync();
41+
}
42+
catch (Exception ex)
43+
{
44+
IsAvailable = false;
45+
UnavailableReason = $"Docker indisponible: {ex.GetType().Name} - {ex.Message}";
46+
return;
47+
}
48+
49+
ConnectionString = _container.GetConnectionString();
50+
Config = new ConfigPerso
51+
{
52+
ConnectionString = ConnectionString,
53+
SecretKey = "this_is_a_very_secret_key_for_testing_purposes_that_is_long_enough_for_jwt",
54+
Issuer = "TableMasterTestIssuer",
55+
Audience = "TableMasterTestAudience"
56+
};
57+
58+
RegisterDapperHandlerOnce();
59+
60+
var upgrader = DeployChanges.To
61+
.PostgresqlDatabase(ConnectionString)
62+
.WithScriptsEmbeddedInAssembly(typeof(TableMasterApi.Controllers.AuthController).Assembly)
63+
.LogToConsole()
64+
.Build();
65+
66+
var result = upgrader.PerformUpgrade();
67+
if (!result.Successful)
68+
{
69+
throw new InvalidOperationException(
70+
"Echec de l'execution des migrations DbUp sur le conteneur de tests.",
71+
result.Error);
72+
}
73+
74+
IsAvailable = true;
75+
}
76+
77+
public async Task DisposeAsync()
78+
{
79+
if (_container is null) return;
80+
try
81+
{
82+
await _container.DisposeAsync();
83+
}
84+
catch
85+
{
86+
// Best effort cleanup.
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Vide les tables manipulees par les tests entre deux executions.
92+
/// Reset egalement les sequences d'identite.
93+
/// </summary>
94+
public async Task ResetAsync()
95+
{
96+
if (!IsAvailable) return;
97+
98+
await using var connection = new NpgsqlConnection(ConnectionString);
99+
await connection.OpenAsync();
100+
101+
const string truncate = @"
102+
TRUNCATE TABLE
103+
""UserRefreshTokens"",
104+
""Reservation"",
105+
""Review"",
106+
""Menu"",
107+
""DailyActivity"",
108+
""ClosedDayException"",
109+
""TableEntity"",
110+
""RestaurantRoom"",
111+
""Restaurant"",
112+
""UserDeviceTokens"",
113+
""User""
114+
RESTART IDENTITY CASCADE;";
115+
116+
await using var cmd = new NpgsqlCommand(truncate, connection);
117+
await cmd.ExecuteNonQueryAsync();
118+
}
119+
120+
private static void RegisterDapperHandlerOnce()
121+
{
122+
lock (_dapperHandlerLock)
123+
{
124+
if (_dapperHandlerRegistered) return;
125+
Dapper.SqlMapper.AddTypeHandler(new PostgresTimeSpanHandler());
126+
_dapperHandlerRegistered = true;
127+
}
128+
}
129+
}
130+
131+
[CollectionDefinition(Name)]
132+
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
133+
{
134+
public const string Name = "Postgres integration";
135+
}
136+
}

0 commit comments

Comments
 (0)