mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 16:31:07 +00:00
3398ec49a0
ci/woodpecker/push/build-arm Pipeline was successful
- Updated Azaion.Missions.csproj to exclude test sources from service compilation, preventing build failures due to test project dependencies. - Modified docker-compose.test.yml to preload the pg_stat_statements extension for testing and adjusted JWT refresh intervals for better test execution timing. - Enhanced Dockerfile to install wget for health checks and ensure proper initialization of the container. - Introduced a test-only endpoint for JWKS refresh to facilitate end-to-end testing without relying on the default refresh intervals. - Updated DTOs in ApiDtos.cs to reflect camelCase naming conventions for consistency with service responses. - Improved test cases to handle JWKS rotation and refresh scenarios effectively, ensuring robust validation of JWT handling. This commit lays the groundwork for more reliable and efficient testing of the Azaion.Missions project.
241 lines
9.5 KiB
C#
241 lines
9.5 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Azaion.Missions.E2E.Fixtures;
|
|
using Azaion.Missions.E2E.Helpers;
|
|
using Xunit;
|
|
|
|
namespace Azaion.Missions.E2E.Tests.Security;
|
|
|
|
/// <summary>
|
|
/// NFT-SEC-01..06 + 04b — JWT authn/authz scenarios from
|
|
/// <c>_docs/02_document/tests/security-tests.md</c>.
|
|
/// Traces: AC-5.2..AC-5.6, AC-5.8, AC-5.11, AC-5.12, AC-9.1, AC-9.2.
|
|
/// </summary>
|
|
[Collection("SecurityAuthClaims")]
|
|
[Trait("Category", "Sec")]
|
|
[Trait("db_access", "seed-or-assert-only")]
|
|
public sealed class AuthClaimsTests : TestBase, IClassFixture<DbResetFixture>
|
|
{
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.4")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_01_missing_authorization_header_rejects_protected_endpoints_with_401_and_no_db_write()
|
|
{
|
|
// Arrange
|
|
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
|
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
|
|
var anyMissionId = Guid.NewGuid();
|
|
var preCount = DbAssertions.TableRowCount("vehicles");
|
|
|
|
// Act
|
|
// Assert — GET /vehicles
|
|
using (var resp = await Missions.GetAsync("/vehicles"))
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
|
|
using (var resp = await Missions.GetAsync("/missions"))
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
|
|
using (var resp = await Missions.GetAsync($"/missions/{anyMissionId}/waypoints"))
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
|
|
var postBody = new
|
|
{
|
|
Type = 0,
|
|
Model = "Bayraktar",
|
|
Name = "BR-noauth",
|
|
FuelType = 1,
|
|
BatteryCapacity = 0,
|
|
EngineConsumption = 5,
|
|
EngineConsumptionIdle = 1,
|
|
IsDefault = false
|
|
};
|
|
using (var post = new HttpRequestMessage(HttpMethod.Post, "/vehicles")
|
|
{
|
|
Content = JsonContent.Create(postBody)
|
|
})
|
|
using (var resp = await Missions.SendAsync(post))
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
|
|
// Assert — POST 401 did not write a row.
|
|
var postCount = DbAssertions.TableRowCount("vehicles");
|
|
Assert.Equal(preCount, postCount);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.5")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_02_invalid_signature_rejects_byte_flip_and_foreign_keypair_with_401()
|
|
{
|
|
// Arrange — single-byte-flip uses a mock-signed token; foreign-keypair
|
|
// uses a local ECDSA P-256 (the one in-test signing path the task
|
|
// spec permits).
|
|
var good = await Tokens.MintDefaultAsync();
|
|
var flipped = FlipFirstSignatureChar(good.Jwt);
|
|
|
|
using var foreign = new ForeignKeypair();
|
|
var foreignJwt = foreign.Mint(
|
|
TestEnvironment.JwtIssuer, TestEnvironment.JwtAudience, "FL");
|
|
|
|
// Act
|
|
// Assert — flipped signature
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", flipped);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// (Act+Assert — foreign keypair token (kid not in JWKS).)
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", foreignJwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.2,AC-5.6")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_03_clock_skew_30s_rejects_minus_60_and_accepts_minus_15()
|
|
{
|
|
// Arrange — both tokens are otherwise identical; only exp differs.
|
|
var expiredBeyondSkew = await Tokens.MintAsync(
|
|
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -60));
|
|
var expiredWithinSkew = await Tokens.MintAsync(
|
|
new SignRequest(Permissions: "FL", ExpOffsetSeconds: -15));
|
|
|
|
// Act
|
|
// Assert — outside the 30s skew window.
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredBeyondSkew.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
// Inside the 30s skew window.
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredWithinSkew.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.3,AC-5.11")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_04_wrong_iss_rejected_default_iss_accepted()
|
|
{
|
|
// Arrange
|
|
var wrongIss = await Tokens.MintAsync(
|
|
new SignRequest(Iss: "https://attacker.example.com", Permissions: "FL"));
|
|
var defaultIss = await Tokens.MintDefaultAsync();
|
|
|
|
// Act
|
|
// Assert
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongIss.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
using (var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles"))
|
|
{
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", defaultIss.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.3,AC-5.12")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_04b_wrong_aud_rejected()
|
|
{
|
|
// Arrange
|
|
var wrongAud = await Tokens.MintAsync(
|
|
new SignRequest(Aud: "wrong-audience", Permissions: "FL"));
|
|
|
|
// Act
|
|
// Assert
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", wrongAud.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-5.8,AC-9.1")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_05_missing_permissions_claim_returns_403()
|
|
{
|
|
// Arrange — Permissions=null + PermissionsArray=null omits the claim.
|
|
var noPermissions = await Tokens.MintAsync(new SignRequest());
|
|
|
|
// Act
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", noPermissions.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
|
|
// Assert — authentication succeeds, authorization fails → 403 (NOT 401).
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ADMIN")]
|
|
[InlineData("fl")]
|
|
[InlineData("FLight")]
|
|
[Trait("Traces", "AC-9.1,AC-9.2")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_06_wrong_single_permission_value_returns_403(string permissions)
|
|
{
|
|
// Arrange
|
|
var token = await Tokens.MintAsync(new SignRequest(Permissions: permissions));
|
|
|
|
// Act
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
|
|
// Assert — RequireClaim("permissions","FL") is case-sensitive exact match.
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Traces", "AC-9.1,AC-9.2")]
|
|
[Trait("max_ms", "5000")]
|
|
public async Task NFT_SEC_06_multi_value_permissions_array_accepts_when_FL_is_present()
|
|
{
|
|
// Arrange — array permissions claim; ASP.NET's JWT handler flattens
|
|
// an array claim into multiple per-value claims, so RequireClaim
|
|
// matches if ANY value equals "FL".
|
|
var token = await Tokens.MintAsync(
|
|
new SignRequest(PermissionsArray: new[] { "FL", "ADMIN" }));
|
|
|
|
// Act
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
|
using var resp = await Missions.SendAsync(req);
|
|
|
|
// Assert
|
|
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
|
|
}
|
|
|
|
private static string FlipFirstSignatureChar(string jwt)
|
|
{
|
|
var parts = jwt.Split('.');
|
|
if (parts.Length != 3)
|
|
throw new InvalidOperationException(
|
|
"expected a JWS-compact JWT with exactly 3 segments");
|
|
var sig = parts[2].ToCharArray();
|
|
// Toggle the first char between two base64url-valid letters so the
|
|
// result is still parseable but signature verification fails.
|
|
sig[0] = sig[0] == 'A' ? 'B' : 'A';
|
|
return $"{parts[0]}.{parts[1]}.{new string(sig)}";
|
|
}
|
|
}
|