Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fee137aee6 | ||
|
|
cadc321d96 | ||
|
|
58cc2e4096 | ||
|
|
5d3be19ca8 | ||
|
|
c163db3029 | ||
|
|
dcaa1a77db | ||
|
|
482a4b41f0 |
@@ -5,6 +5,12 @@
|
||||
ASPNETCORE_ENVIRONMENT=Production
|
||||
ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
# Auth — JWT signing key (min. 32 chars). REQUIRED in Production; never commit a real value.
|
||||
# Auth__SigningKey=
|
||||
# Auth__Issuer=tarninahub
|
||||
# Auth__Audience=tarninahub
|
||||
# Auth__TokenLifetimeMinutes=60
|
||||
|
||||
# SurrealDB — embedded RocksDB by default.
|
||||
SurrealDb__Namespace=hubmi
|
||||
SurrealDb__Database=hubmi
|
||||
|
||||
@@ -35,3 +35,6 @@ backend/local-docs/
|
||||
# Docker — lokalne nadpisania
|
||||
docker-compose.override.yaml
|
||||
docker-compose.override.yml
|
||||
|
||||
# Artefakty narzędzi (np. historia REPL `surreal sql`)
|
||||
history.txt
|
||||
|
||||
@@ -140,3 +140,4 @@ TarninaHub/
|
||||
- Błędy: `ProblemDetails` (RFC 7807), globalny handler wyjątków.
|
||||
- Healthcheck: `GET /api/health` (bez wersji, dla infrastruktury).
|
||||
- Migracje SurrealQL są aplikowane automatycznie przy starcie (katalog `migrations/`).
|
||||
- Uwierzytelnianie: JWT Bearer (`POST /api/auth/signup`, `POST /api/auth/signin`), role `user`/`jst`/`operator`/`manager`/`admin` i polityki RBAC. Hasła (Argon2) i zgody w SurrealDB; różne poziomy dostępu (`by-consent`/`temporary`/`anonymized`) + rejestr odczytu (`audit_log`).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogService
|
||||
{
|
||||
Task RecordAsync(
|
||||
string? actorId,
|
||||
string action,
|
||||
string target,
|
||||
IReadOnlyDictionary<string, object?> context,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface IConsentService
|
||||
{
|
||||
Task<ConsentResponse> GrantAsync(
|
||||
string userId,
|
||||
GrantConsentRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ConsentResponse>> ListForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> RevokeAsync(
|
||||
string consentId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public sealed record SubmissionEvent(
|
||||
string Type,
|
||||
string SubmissionId,
|
||||
string? ActorId,
|
||||
string Status,
|
||||
DateTimeOffset At);
|
||||
|
||||
public interface ISubmissionEventPublisher
|
||||
{
|
||||
Task PublishAsync(SubmissionEvent submissionEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface ISubmissionService
|
||||
{
|
||||
Task<SubmissionResponse> CreateAsync(
|
||||
string authorId,
|
||||
CreateSubmissionRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedResponse<SubmissionResponse>> ListAsync(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SubmissionResponse?> GetAsync(
|
||||
string submissionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SubmissionResponse?> UpdateStatusAsync(
|
||||
string submissionId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using TarninaHub.Application.Models;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
TokenResult CreateToken(UserAccount user);
|
||||
}
|
||||
|
||||
public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt);
|
||||
@@ -0,0 +1,15 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Models;
|
||||
|
||||
namespace TarninaHub.Application.Abstractions;
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserAccount?> FindByPhoneAsync(string phone, CancellationToken cancellationToken);
|
||||
|
||||
Task<UserAccount?> GetByIdAsync(string userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<UserAccount?> CreateAsync(RegisterRequest request, string role, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> VerifyCredentialsAsync(string phone, string password, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TarninaHub.Application.Dtos;
|
||||
|
||||
public sealed record RegisterRequest(string Phone, string? Email, string Password);
|
||||
|
||||
public sealed record LoginRequest(string Phone, string Password);
|
||||
|
||||
public sealed record UserResponse(
|
||||
string Id,
|
||||
string Phone,
|
||||
string? Email,
|
||||
string Role,
|
||||
string? OrganizationId);
|
||||
|
||||
public sealed record TokenResponse(
|
||||
string AccessToken,
|
||||
string TokenType,
|
||||
DateTimeOffset ExpiresAt,
|
||||
UserResponse User);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TarninaHub.Application.Dtos;
|
||||
|
||||
public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt);
|
||||
|
||||
public sealed record ConsentResponse(
|
||||
string Id,
|
||||
string UserId,
|
||||
string Scope,
|
||||
DateTimeOffset GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? RevokedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TarninaHub.Application.Dtos;
|
||||
|
||||
public sealed record PagedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int Total);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TarninaHub.Application.Dtos;
|
||||
|
||||
public sealed record CreateSubmissionRequest(
|
||||
[Required, StringLength(200, MinimumLength = 3)] string Title,
|
||||
[Required, StringLength(4000, MinimumLength = 10)] string Description,
|
||||
[Required] string ChallengeId,
|
||||
[Required] string LocationId,
|
||||
IReadOnlyList<string>? Keywords);
|
||||
|
||||
public sealed record UpdateSubmissionStatusRequest(
|
||||
[Required] string Status);
|
||||
|
||||
public sealed record SubmissionResponse(
|
||||
string Id,
|
||||
string ProblemId,
|
||||
string Title,
|
||||
string Description,
|
||||
string ChallengeId,
|
||||
string LocationId,
|
||||
string AuthorId,
|
||||
string Status,
|
||||
IReadOnlyList<string> Keywords,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt);
|
||||
@@ -0,0 +1,36 @@
|
||||
using TarninaHub.Domain.Enums;
|
||||
|
||||
namespace TarninaHub.Application.Mappings;
|
||||
|
||||
public static class SubmissionStatusWire
|
||||
{
|
||||
public static string ToWire(this SubmissionStatus status) => status switch
|
||||
{
|
||||
SubmissionStatus.New => "new",
|
||||
SubmissionStatus.Triaged => "triaged",
|
||||
SubmissionStatus.Matched => "matched",
|
||||
SubmissionStatus.InProgress => "in_progress",
|
||||
SubmissionStatus.Closed => "closed",
|
||||
SubmissionStatus.Rejected => "rejected",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static SubmissionStatus? Parse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
"new" => SubmissionStatus.New,
|
||||
"triaged" => SubmissionStatus.Triaged,
|
||||
"matched" => SubmissionStatus.Matched,
|
||||
"in_progress" => SubmissionStatus.InProgress,
|
||||
"closed" => SubmissionStatus.Closed,
|
||||
"rejected" => SubmissionStatus.Rejected,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Models;
|
||||
|
||||
namespace TarninaHub.Application.Mappings;
|
||||
|
||||
public static class UserMappings
|
||||
{
|
||||
public static UserResponse ToResponse(this UserAccount user) => new(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.Role,
|
||||
user.OrganizationId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TarninaHub.Application.Models;
|
||||
|
||||
public sealed record UserAccount(
|
||||
string Id,
|
||||
string Phone,
|
||||
string? Email,
|
||||
string Role,
|
||||
string? OrganizationId);
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace TarninaHub.Application.Security;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static string? GetUserId(this ClaimsPrincipal principal) =>
|
||||
principal.FindFirst("sub")?.Value;
|
||||
|
||||
public static string? GetRole(this ClaimsPrincipal principal) =>
|
||||
principal.FindFirst("role")?.Value;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TarninaHub.Application.Security;
|
||||
|
||||
public static class RoleNames
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Jst = "jst";
|
||||
public const string Operator = "operator";
|
||||
public const string Manager = "manager";
|
||||
public const string Admin = "admin";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[User, Jst, Operator, Manager, Admin];
|
||||
|
||||
public static bool IsValid(string role) => All.Contains(role);
|
||||
}
|
||||
|
||||
public static class Policies
|
||||
{
|
||||
/// <summary>operator, manager, admin — ROPS/expert staff.</summary>
|
||||
public const string Staff = "Staff";
|
||||
|
||||
/// <summary>operator, admin.</summary>
|
||||
public const string OperatorOrAdmin = "OperatorOrAdmin";
|
||||
|
||||
/// <summary>manager, operator, admin.</summary>
|
||||
public const string ManagerOrAbove = "ManagerOrAbove";
|
||||
|
||||
/// <summary>admin only.</summary>
|
||||
public const string AdminOnly = "AdminOnly";
|
||||
|
||||
/// <summary>jst and above (jst, operator, manager, admin).</summary>
|
||||
public const string JstOrAbove = "JstOrAbove";
|
||||
}
|
||||
|
||||
public static class ConsentScopes
|
||||
{
|
||||
/// <summary>Access granted by the data subject's consent.</summary>
|
||||
public const string ByConsent = "by-consent";
|
||||
|
||||
/// <summary>Time-limited access (uses consent.expires_at).</summary>
|
||||
public const string Temporary = "temporary";
|
||||
|
||||
/// <summary>Fully anonymized data (no personal identifiers).</summary>
|
||||
public const string Anonymized = "anonymized";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [ByConsent, Temporary, Anonymized];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text;
|
||||
|
||||
namespace TarninaHub.Application.Text;
|
||||
|
||||
public static class KeywordExtractor
|
||||
{
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"i", "w", "we", "na", "z", "do", "nie", "że", "się", "jest", "to", "dla", "od", "po",
|
||||
"o", "a", "ale", "jak", "co", "oraz", "przez", "przy", "tak", "już", "czy", "ich",
|
||||
"the", "and", "for", "with", "this", "that", "are", "was",
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> Extract(string text, int max = 8)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || max <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var order = new List<string>();
|
||||
|
||||
foreach (var rawToken in Tokenize(text))
|
||||
{
|
||||
var token = rawToken.ToLowerInvariant();
|
||||
|
||||
if (token.Length < 3 || StopWords.Contains(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!counts.TryGetValue(token, out var count))
|
||||
{
|
||||
order.Add(token);
|
||||
}
|
||||
|
||||
counts[token] = count + 1;
|
||||
}
|
||||
|
||||
return order
|
||||
.OrderByDescending(token => counts[token])
|
||||
.ThenBy(token => order.IndexOf(token))
|
||||
.Take(max)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string text)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
foreach (var character in text)
|
||||
{
|
||||
if (char.IsLetter(character))
|
||||
{
|
||||
buffer.Append(character);
|
||||
}
|
||||
else if (buffer.Length > 0)
|
||||
{
|
||||
yield return buffer.ToString();
|
||||
buffer.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
yield return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Mappings;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly ITokenService _tokenService;
|
||||
|
||||
public AuthController(IUserService userService, ITokenService tokenService)
|
||||
{
|
||||
_userService = userService;
|
||||
_tokenService = tokenService;
|
||||
}
|
||||
|
||||
[HttpPost("signup")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<UserResponse>> SignUp(
|
||||
RegisterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _userService.FindByPhoneAsync(request.Phone, cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Phone number already registered",
|
||||
Status = StatusCodes.Status409Conflict,
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _userService.CreateAsync(request, RoleNames.User, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Problem(title: "User could not be created", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
return CreatedAtAction("GetById", "Users", new { userId = user.Id }, user.ToResponse());
|
||||
}
|
||||
|
||||
[HttpPost("signin")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<TokenResponse>> SignIn(
|
||||
LoginRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var valid = await _userService.VerifyCredentialsAsync(
|
||||
request.Phone,
|
||||
request.Password,
|
||||
cancellationToken);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid phone number or password",
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _userService.FindByPhoneAsync(request.Phone, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var token = _tokenService.CreateToken(user);
|
||||
|
||||
return Ok(new TokenResponse(token.AccessToken, "Bearer", token.ExpiresAt, user.ToResponse()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/consents")]
|
||||
[Authorize]
|
||||
public class ConsentsController : ControllerBase
|
||||
{
|
||||
private readonly IConsentService _consentService;
|
||||
private readonly IAuditLogService _auditLogService;
|
||||
|
||||
public ConsentsController(IConsentService consentService, IAuditLogService auditLogService)
|
||||
{
|
||||
_consentService = consentService;
|
||||
_auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ConsentResponse>> Grant(
|
||||
GrantConsentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!ConsentScopes.All.Contains(request.Scope))
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Scope),
|
||||
$"Scope must be one of: {string.Join(", ", ConsentScopes.All)}.");
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
var consent = await _consentService.GrantAsync(userId, request, cancellationToken);
|
||||
|
||||
await _auditLogService.RecordAsync(
|
||||
userId,
|
||||
"consent.grant",
|
||||
consent.Id,
|
||||
new Dictionary<string, object?> { ["scope"] = request.Scope },
|
||||
cancellationToken);
|
||||
|
||||
return StatusCode(StatusCodes.Status201Created, consent);
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
public async Task<ActionResult<IReadOnlyList<ConsentResponse>>> GetMine(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var consents = await _consentService.ListForUserAsync(userId, cancellationToken);
|
||||
|
||||
return Ok(consents);
|
||||
}
|
||||
|
||||
[HttpPost("{consentId}/revoke")]
|
||||
public async Task<IActionResult> Revoke(string consentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var revoked = await _consentService.RevokeAsync(consentId, userId, cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _auditLogService.RecordAsync(
|
||||
userId,
|
||||
"consent.revoke",
|
||||
consentId,
|
||||
new Dictionary<string, object?>(),
|
||||
cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Mappings;
|
||||
using TarninaHub.Application.Security;
|
||||
using TarninaHub.Domain.Rules;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/submissions")]
|
||||
[Authorize]
|
||||
public class SubmissionsController : ControllerBase
|
||||
{
|
||||
private readonly ISubmissionService _submissionService;
|
||||
|
||||
public SubmissionsController(ISubmissionService submissionService)
|
||||
{
|
||||
_submissionService = submissionService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SubmissionResponse>> Create(
|
||||
CreateSubmissionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var created = await _submissionService.CreateAsync(userId, request, cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResponse<SubmissionResponse>>> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
page = page < 1 ? 1 : page;
|
||||
pageSize = pageSize is < 1 or > 100 ? 20 : pageSize;
|
||||
|
||||
var result = await _submissionService.ListAsync(
|
||||
userId,
|
||||
IsStaff(),
|
||||
status,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{submissionId}")]
|
||||
public async Task<ActionResult<SubmissionResponse>> GetById(
|
||||
string submissionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var submission = await _submissionService.GetAsync(submissionId, cancellationToken);
|
||||
|
||||
if (submission is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!IsStaff() && !string.Equals(submission.AuthorId, userId, StringComparison.Ordinal))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
return Ok(submission);
|
||||
}
|
||||
|
||||
[HttpPut("{submissionId}/status")]
|
||||
[Authorize(Policy = Policies.OperatorOrAdmin)]
|
||||
public async Task<ActionResult<SubmissionResponse>> UpdateStatus(
|
||||
string submissionId,
|
||||
UpdateSubmissionStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await _submissionService.GetAsync(submissionId, cancellationToken);
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var from = SubmissionStatusWire.Parse(current.Status);
|
||||
var to = SubmissionStatusWire.Parse(request.Status);
|
||||
|
||||
if (to is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Status), "Unknown status value.");
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
if (from is null || !SubmissionWorkflow.CanTransition(from.Value, to.Value))
|
||||
{
|
||||
ModelState.AddModelError(
|
||||
nameof(request.Status),
|
||||
$"Transition from '{current.Status}' to '{request.Status}' is not allowed.");
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
var updated = await _submissionService.UpdateStatusAsync(submissionId, to.Value.ToWire(), cancellationToken);
|
||||
|
||||
return updated is null ? NotFound() : Ok(updated);
|
||||
}
|
||||
|
||||
private bool IsStaff() =>
|
||||
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Mappings;
|
||||
using TarninaHub.Application.Security;
|
||||
|
||||
namespace TarninaHub.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAuditLogService _auditLogService;
|
||||
|
||||
public UsersController(IUserService userService, IAuditLogService auditLogService)
|
||||
{
|
||||
_userService = userService;
|
||||
_auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserResponse>> GetMe(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var user = await _userService.GetByIdAsync(userId, cancellationToken);
|
||||
|
||||
return user is null ? NotFound() : Ok(user.ToResponse());
|
||||
}
|
||||
|
||||
[HttpGet("{userId}")]
|
||||
public async Task<ActionResult<UserResponse>> GetById(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserId = User.GetUserId();
|
||||
var isOwner = string.Equals(currentUserId, userId, StringComparison.Ordinal);
|
||||
|
||||
if (!isOwner && !User.IsInRole(RoleNames.Operator) && !User.IsInRole(RoleNames.Manager) && !User.IsInRole(RoleNames.Admin))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var user = await _userService.GetByIdAsync(userId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!isOwner)
|
||||
{
|
||||
await _auditLogService.RecordAsync(
|
||||
currentUserId,
|
||||
"user.read",
|
||||
userId,
|
||||
new Dictionary<string, object?> { ["reason"] = "staff-lookup" },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return Ok(user.ToResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TarninaHub.Domain.Enums;
|
||||
|
||||
namespace TarninaHub.Domain.Rules;
|
||||
|
||||
public static class SubmissionWorkflow
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<SubmissionStatus, SubmissionStatus[]> Transitions =
|
||||
new Dictionary<SubmissionStatus, SubmissionStatus[]>
|
||||
{
|
||||
[SubmissionStatus.New] = [SubmissionStatus.Triaged, SubmissionStatus.Rejected],
|
||||
[SubmissionStatus.Triaged] = [SubmissionStatus.Matched, SubmissionStatus.Rejected, SubmissionStatus.Closed],
|
||||
[SubmissionStatus.Matched] = [SubmissionStatus.InProgress, SubmissionStatus.Closed, SubmissionStatus.Rejected],
|
||||
[SubmissionStatus.InProgress] = [SubmissionStatus.Closed, SubmissionStatus.Rejected],
|
||||
[SubmissionStatus.Closed] = [],
|
||||
[SubmissionStatus.Rejected] = [],
|
||||
};
|
||||
|
||||
public static bool CanTransition(SubmissionStatus from, SubmissionStatus to) =>
|
||||
from == to || Transitions[from].Contains(to);
|
||||
|
||||
public static IReadOnlyList<SubmissionStatus> NextStates(SubmissionStatus from) =>
|
||||
Transitions[from];
|
||||
|
||||
public static bool IsTerminal(SubmissionStatus status) =>
|
||||
Transitions[status].Length == 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TarninaHub.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthSettings
|
||||
{
|
||||
public string Issuer { get; set; } = "tarninahub";
|
||||
|
||||
public string Audience { get; set; } = "tarninahub";
|
||||
|
||||
/// <summary>
|
||||
/// HMAC signing key (min. 32 bytes). Must come from configuration/secrets
|
||||
/// (env `Auth__SigningKey`) — never hardcoded for production environments.
|
||||
/// </summary>
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
|
||||
public int TokenLifetimeMinutes { get; set; } = 60;
|
||||
|
||||
public bool HasUsableSigningKey => SigningKey.Length >= 32;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Models;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Auth;
|
||||
|
||||
public sealed class TokenService : ITokenService
|
||||
{
|
||||
private readonly AuthSettings _settings;
|
||||
|
||||
public TokenService(IOptions<AuthSettings> settings)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
}
|
||||
|
||||
public TokenResult CreateToken(UserAccount user)
|
||||
{
|
||||
var issuedAt = DateTimeOffset.UtcNow;
|
||||
var expiresAt = issuedAt.AddMinutes(_settings.TokenLifetimeMinutes);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||
new("role", user.Role),
|
||||
new("phone", user.Phone),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
claims.Add(new Claim("email", user.Email));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.OrganizationId))
|
||||
{
|
||||
claims.Add(new Claim("organization", user.OrganizationId));
|
||||
}
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _settings.Issuer,
|
||||
audience: _settings.Audience,
|
||||
claims: claims,
|
||||
notBefore: issuedAt.UtcDateTime,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
|
||||
|
||||
return new TokenResult(accessToken, expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Infrastructure.Auth;
|
||||
using TarninaHub.Infrastructure.Events;
|
||||
using TarninaHub.Infrastructure.Persistence;
|
||||
using TarninaHub.Infrastructure.Settings;
|
||||
|
||||
@@ -14,6 +16,7 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
|
||||
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
|
||||
|
||||
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
|
||||
?? new SurrealDbSettings();
|
||||
@@ -46,6 +49,12 @@ public static class DependencyInjection
|
||||
|
||||
services.AddSingleton<IStorageService, StorageService>();
|
||||
services.AddSingleton<IMigrationRunner, MigrationRunner>();
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
services.AddSingleton<IUserService, UserService>();
|
||||
services.AddSingleton<IConsentService, ConsentService>();
|
||||
services.AddSingleton<IAuditLogService, AuditLogService>();
|
||||
services.AddSingleton<ISubmissionEventPublisher, LoggingSubmissionEventPublisher>();
|
||||
services.AddSingleton<ISubmissionService, SubmissionService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder publisher: logs domain events. Will be replaced/wired to
|
||||
/// matchmaking (steps 14-15) and notifications (step 17).
|
||||
/// </summary>
|
||||
public sealed class LoggingSubmissionEventPublisher : ISubmissionEventPublisher
|
||||
{
|
||||
private readonly ILogger<LoggingSubmissionEventPublisher> _logger;
|
||||
|
||||
public LoggingSubmissionEventPublisher(ILogger<LoggingSubmissionEventPublisher> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishAsync(SubmissionEvent submissionEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Domain event {EventType} for {SubmissionId} (status: {Status}, actor: {ActorId})",
|
||||
submissionEvent.Type,
|
||||
submissionEvent.SubmissionId,
|
||||
submissionEvent.Status,
|
||||
submissionEvent.ActorId ?? "system");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class AuditLogService : IAuditLogService
|
||||
{
|
||||
private readonly ISurrealDbClient _client;
|
||||
|
||||
public AuditLogService(ISurrealDbClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task RecordAsync(
|
||||
string? actorId,
|
||||
string action,
|
||||
string target,
|
||||
IReadOnlyDictionary<string, object?> context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (actorTable, actorIdPart) = actorId is null
|
||||
? ("user", (string?)null)
|
||||
: SurrealRecordId.Split(actorId);
|
||||
|
||||
var actorExpression = actorId is null
|
||||
? "NONE"
|
||||
: "type::record($actorTable, $actorId)";
|
||||
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["action"] = action,
|
||||
["target"] = target,
|
||||
["context"] = context,
|
||||
["actorTable"] = actorTable,
|
||||
["actorId"] = actorIdPart,
|
||||
};
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
$"CREATE audit_log SET actor = {actorExpression}, action = $action, " +
|
||||
"target = $target, context = $context, at = time::now();",
|
||||
parameters,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class ConsentService : IConsentService
|
||||
{
|
||||
private const string SelectConsentFields =
|
||||
"type::string(id) AS id, type::string(user) AS user, scope, granted_at, expires_at, revoked_at";
|
||||
|
||||
private readonly ISurrealDbClient _client;
|
||||
|
||||
public ConsentService(ISurrealDbClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task<ConsentResponse> GrantAsync(
|
||||
string userId,
|
||||
GrantConsentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
|
||||
var consentId = Guid.NewGuid().ToString("N");
|
||||
var expiresClause = request.ExpiresAt.HasValue ? ", expires_at = $expiresAt" : string.Empty;
|
||||
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["consentId"] = consentId,
|
||||
["userTable"] = userTable,
|
||||
["userId"] = userIdPart,
|
||||
["scope"] = request.Scope,
|
||||
};
|
||||
|
||||
if (request.ExpiresAt.HasValue)
|
||||
{
|
||||
parameters["expiresAt"] = request.ExpiresAt;
|
||||
}
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
"CREATE type::record('consent', $consentId) SET " +
|
||||
"user = type::record($userTable, $userId), scope = $scope, " +
|
||||
"granted_at = time::now()" + expiresClause + "; " +
|
||||
$"SELECT {SelectConsentFields} FROM type::record('consent', $consentId) LIMIT 1;",
|
||||
parameters,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ConsentRow>>(1) ?? [];
|
||||
|
||||
return Map(rows[0]);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsentResponse>> ListForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
$"SELECT {SelectConsentFields} FROM consent " +
|
||||
"WHERE user = type::record($userTable, $userId) ORDER BY granted_at DESC;",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["userTable"] = userTable,
|
||||
["userId"] = userIdPart,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ConsentRow>>(0) ?? [];
|
||||
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeAsync(
|
||||
string consentId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
|
||||
var (consentTable, consentIdPart) = SurrealRecordId.Split(consentId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
"UPDATE type::record($consentTable, $consentIdPart) SET revoked_at = time::now() " +
|
||||
"WHERE user = type::record($userTable, $userId) AND revoked_at = NONE; " +
|
||||
$"SELECT {SelectConsentFields} FROM type::record($consentTable, $consentIdPart) LIMIT 1;",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["consentTable"] = consentTable,
|
||||
["consentIdPart"] = consentIdPart,
|
||||
["userTable"] = userTable,
|
||||
["userId"] = userIdPart,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ConsentRow>>(1) ?? [];
|
||||
|
||||
return rows.Count > 0 && rows[0].RevokedAt is not null;
|
||||
}
|
||||
|
||||
private static ConsentResponse Map(ConsentRow row) => new(
|
||||
row.Id,
|
||||
row.User,
|
||||
row.Scope,
|
||||
row.GrantedAt,
|
||||
row.ExpiresAt,
|
||||
row.RevokedAt);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Text;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class SubmissionService : ISubmissionService
|
||||
{
|
||||
private const string SelectFields =
|
||||
"type::string(id) AS id, type::string(problem) AS problem, status, keywords, created_at, updated_at, " +
|
||||
"problem.title AS title, problem.description AS description, " +
|
||||
"type::string(problem.challenge) AS challenge, type::string(problem.location) AS location, " +
|
||||
"type::string(problem.author) AS author";
|
||||
|
||||
private readonly ISurrealDbClient _client;
|
||||
private readonly ISubmissionEventPublisher _events;
|
||||
|
||||
public SubmissionService(ISurrealDbClient client, ISubmissionEventPublisher events)
|
||||
{
|
||||
_client = client;
|
||||
_events = events;
|
||||
}
|
||||
|
||||
public async Task<SubmissionResponse> CreateAsync(
|
||||
string authorId,
|
||||
CreateSubmissionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var problemId = Guid.NewGuid().ToString("N");
|
||||
var submissionId = Guid.NewGuid().ToString("N");
|
||||
var keywords = request.Keywords is { Count: > 0 }
|
||||
? request.Keywords
|
||||
: KeywordExtractor.Extract(request.Description);
|
||||
|
||||
var (challengeTable, challengeIdPart) = SurrealRecordId.Split(request.ChallengeId);
|
||||
var (locationTable, locationIdPart) = SurrealRecordId.Split(request.LocationId);
|
||||
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
|
||||
|
||||
var problemResponse = await _client.RawQuery(
|
||||
"CREATE type::record('problem', $id) SET title = $title, description = $description, " +
|
||||
"challenge = type::record($challengeTable, $challengeId), " +
|
||||
"location = type::record($locationTable, $locationId), " +
|
||||
"author = type::record($authorTable, $authorId), visibility = 'shared';",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = problemId,
|
||||
["title"] = request.Title,
|
||||
["description"] = request.Description,
|
||||
["challengeTable"] = challengeTable,
|
||||
["challengeId"] = challengeIdPart,
|
||||
["locationTable"] = locationTable,
|
||||
["locationId"] = locationIdPart,
|
||||
["authorTable"] = authorTable,
|
||||
["authorId"] = authorIdPart,
|
||||
},
|
||||
cancellationToken);
|
||||
problemResponse.EnsureAllOks();
|
||||
|
||||
var submissionResponse = await _client.RawQuery(
|
||||
"CREATE type::record('submission', $id) SET problem = type::record('problem', $problemId), " +
|
||||
"submitted_by = type::record($authorTable, $authorId), status = 'new', keywords = $keywords;",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = submissionId,
|
||||
["problemId"] = problemId,
|
||||
["authorTable"] = authorTable,
|
||||
["authorId"] = authorIdPart,
|
||||
["keywords"] = keywords,
|
||||
},
|
||||
cancellationToken);
|
||||
submissionResponse.EnsureAllOks();
|
||||
|
||||
var created = await LoadAsync($"submission:{submissionId}", cancellationToken)
|
||||
?? throw new InvalidOperationException("Submission could not be loaded after creation.");
|
||||
|
||||
await _events.PublishAsync(
|
||||
new SubmissionEvent("submission.created", created.Id, authorId, created.Status, DateTimeOffset.UtcNow),
|
||||
cancellationToken);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SubmissionResponse>> ListAsync(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var where = BuildWhere(viewerId, isStaff, status, out var parameters);
|
||||
|
||||
var countResponse = await _client.RawQuery(
|
||||
$"SELECT count() AS count FROM submission{where} GROUP ALL;",
|
||||
parameters,
|
||||
cancellationToken);
|
||||
countResponse.EnsureAllOks();
|
||||
|
||||
var totals = countResponse.GetValue<List<CountRow>>(0) ?? [];
|
||||
var total = totals.Count > 0 ? totals[0].Count : 0;
|
||||
|
||||
parameters["limit"] = pageSize;
|
||||
parameters["offset"] = (page - 1) * pageSize;
|
||||
|
||||
var listResponse = await _client.RawQuery(
|
||||
$"SELECT {SelectFields} FROM submission{where} ORDER BY created_at DESC LIMIT $limit START $offset;",
|
||||
parameters,
|
||||
cancellationToken);
|
||||
listResponse.EnsureAllOks();
|
||||
|
||||
var rows = listResponse.GetValue<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return new PagedResponse<SubmissionResponse>(
|
||||
rows.Select(Map).ToList(),
|
||||
page,
|
||||
pageSize,
|
||||
total);
|
||||
}
|
||||
|
||||
public async Task<SubmissionResponse?> GetAsync(
|
||||
string submissionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (table, id) = SurrealRecordId.Split(submissionId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
|
||||
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<SubmissionResponse?> UpdateStatusAsync(
|
||||
string submissionId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (table, id) = SurrealRecordId.Split(submissionId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
"UPDATE type::record($table, $id) SET status = $status, updated_at = time::now(), " +
|
||||
"version = version + 1; " +
|
||||
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["table"] = table,
|
||||
["id"] = id,
|
||||
["status"] = newStatus,
|
||||
},
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(1) ?? [];
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var updated = Map(rows[0]);
|
||||
|
||||
await _events.PublishAsync(
|
||||
new SubmissionEvent("submission.status_changed", updated.Id, null, updated.Status, DateTimeOffset.UtcNow),
|
||||
cancellationToken);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async Task<SubmissionResponse?> LoadAsync(string submissionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (table, id) = SurrealRecordId.Split(submissionId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
|
||||
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<SubmissionRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
private static string BuildWhere(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
out Dictionary<string, object?> parameters)
|
||||
{
|
||||
parameters = new Dictionary<string, object?>();
|
||||
var clauses = new List<string>();
|
||||
|
||||
if (!isStaff)
|
||||
{
|
||||
var (viewerTable, viewerIdPart) = SurrealRecordId.Split(viewerId);
|
||||
clauses.Add("submitted_by = type::record($viewerTable, $viewerId)");
|
||||
parameters["viewerTable"] = viewerTable;
|
||||
parameters["viewerId"] = viewerIdPart;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
clauses.Add("status = $status");
|
||||
parameters["status"] = status;
|
||||
}
|
||||
|
||||
return clauses.Count > 0 ? " WHERE " + string.Join(" AND ", clauses) : string.Empty;
|
||||
}
|
||||
|
||||
private static SubmissionResponse Map(SubmissionRow row) => new(
|
||||
row.Id,
|
||||
row.Problem,
|
||||
row.Title,
|
||||
row.Description,
|
||||
row.Challenge,
|
||||
row.Location,
|
||||
row.Author,
|
||||
row.Status,
|
||||
row.Keywords,
|
||||
row.CreatedAt,
|
||||
row.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
internal static class SurrealRecordId
|
||||
{
|
||||
public static (string Table, string Id) Split(string recordId)
|
||||
{
|
||||
var separator = recordId.IndexOf(':', StringComparison.Ordinal);
|
||||
|
||||
return separator < 0
|
||||
? (recordId, recordId)
|
||||
: (recordId[..separator], recordId[(separator + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Dahomey.Cbor.Attributes;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
internal sealed class UserRow
|
||||
{
|
||||
[CborProperty("id")] public string Id { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("phone")] public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("email")] public string? Email { get; set; }
|
||||
|
||||
[CborProperty("role")] public string Role { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("organization")] public string? Organization { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PasswordCheckRow
|
||||
{
|
||||
[CborProperty("password_ok")] public bool PasswordOk { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ConsentRow
|
||||
{
|
||||
[CborProperty("id")] public string Id { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("user")] public string User { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("scope")] public string Scope { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; }
|
||||
|
||||
[CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SubmissionRow
|
||||
{
|
||||
[CborProperty("id")] public string Id { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("problem")] public string Problem { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("status")] public string Status { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("keywords")] public List<string> Keywords { get; set; } = [];
|
||||
|
||||
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
[CborProperty("title")] public string Title { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("description")] public string Description { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("location")] public string Location { get; set; } = string.Empty;
|
||||
|
||||
[CborProperty("author")] public string Author { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class CountRow
|
||||
{
|
||||
[CborProperty("count")] public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Application.Models;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class UserService : IUserService
|
||||
{
|
||||
private const string SelectUserFields =
|
||||
"type::string(id) AS id, phone, email, role, type::string(organization) AS organization";
|
||||
|
||||
private readonly ISurrealDbClient _client;
|
||||
|
||||
public UserService(ISurrealDbClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindByPhoneAsync(string phone, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _client.RawQuery(
|
||||
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
|
||||
new Dictionary<string, object?> { ["phone"] = phone },
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<UserRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> GetByIdAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (table, id) = SurrealRecordId.Split(userId);
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
$"SELECT {SelectUserFields} FROM type::record($table, $id) LIMIT 1;",
|
||||
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<UserRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> CreateAsync(
|
||||
RegisterRequest request,
|
||||
string role,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var emailClause = string.IsNullOrWhiteSpace(request.Email) ? string.Empty : ", email = $email";
|
||||
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["phone"] = request.Phone,
|
||||
["password"] = request.Password,
|
||||
["role"] = role,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
parameters["email"] = request.Email;
|
||||
}
|
||||
|
||||
var response = await _client.RawQuery(
|
||||
"CREATE user SET phone = $phone" + emailClause + ", " +
|
||||
"password = crypto::argon2::generate($password), role = $role; " +
|
||||
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
|
||||
parameters,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<UserRow>>(1) ?? [];
|
||||
|
||||
return rows.Count > 0 ? Map(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCredentialsAsync(
|
||||
string phone,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _client.RawQuery(
|
||||
"SELECT crypto::argon2::compare(password, $password) AS password_ok " +
|
||||
"FROM user WHERE phone = $phone LIMIT 1;",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["phone"] = phone,
|
||||
["password"] = password,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<PasswordCheckRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 && rows[0].PasswordOk;
|
||||
}
|
||||
|
||||
private static UserAccount Map(UserRow row) => new(
|
||||
row.Id,
|
||||
row.Phone,
|
||||
string.IsNullOrWhiteSpace(row.Email) ? null : row.Email,
|
||||
row.Role,
|
||||
NormalizeRecordId(row.Organization));
|
||||
|
||||
private static string? NormalizeRecordId(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
|
||||
<PackageReference Include="SurrealDb.Net" Version="1.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Security;
|
||||
using TarninaHub.Configuration;
|
||||
using TarninaHub.Errors;
|
||||
using TarninaHub.Infrastructure;
|
||||
using TarninaHub.Infrastructure.Auth;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Layered infrastructure: SurrealDB client, settings and persistence services.
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Authentication (JWT bearer) and role-based authorization policies.
|
||||
var authSettings = builder.Configuration.GetSection("Auth").Get<AuthSettings>() ?? new AuthSettings();
|
||||
|
||||
if (!authSettings.HasUsableSigningKey && !builder.Environment.IsDevelopment())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Auth signing key is missing or too short. Configure 'Auth:SigningKey' " +
|
||||
"(env 'Auth__SigningKey') with at least 32 characters.");
|
||||
}
|
||||
|
||||
var signingKey = authSettings.HasUsableSigningKey
|
||||
? authSettings.SigningKey
|
||||
: "development-only-signing-key-please-override-in-production";
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = authSettings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = authSettings.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||
RoleClaimType = "role",
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(Policies.Staff,
|
||||
policy => policy.RequireRole(RoleNames.Operator, RoleNames.Manager, RoleNames.Admin));
|
||||
options.AddPolicy(Policies.OperatorOrAdmin,
|
||||
policy => policy.RequireRole(RoleNames.Operator, RoleNames.Admin));
|
||||
options.AddPolicy(Policies.ManagerOrAbove,
|
||||
policy => policy.RequireRole(RoleNames.Manager, RoleNames.Operator, RoleNames.Admin));
|
||||
options.AddPolicy(Policies.AdminOnly,
|
||||
policy => policy.RequireRole(RoleNames.Admin));
|
||||
options.AddPolicy(Policies.JstOrAbove,
|
||||
policy => policy.RequireRole(RoleNames.Jst, RoleNames.Operator, RoleNames.Manager, RoleNames.Admin));
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Uniform error responses (RFC 7807) and a global exception handler.
|
||||
@@ -76,6 +130,9 @@ if (!app.Environment.IsDevelopment())
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"Issuer": "tarninahub",
|
||||
"Audience": "tarninahub",
|
||||
"TokenLifetimeMinutes": 60,
|
||||
"SigningKey": "dev-only-signing-key-0123456789-abcdefghijklmnopqrstuvwxyz"
|
||||
},
|
||||
"SurrealDb": {
|
||||
"Namespace": "hubmi",
|
||||
"Database": "hubmi",
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Auth": {
|
||||
"Issuer": "tarninahub",
|
||||
"Audience": "tarninahub",
|
||||
"TokenLifetimeMinutes": 60
|
||||
},
|
||||
"SurrealDb": {
|
||||
"Namespace": "hubmi",
|
||||
"Database": "hubmi",
|
||||
|
||||
@@ -13,3 +13,4 @@ DEFINE FIELD OVERWRITE content ON TABLE application TYPE object FLEXIBLE DEFAULT
|
||||
DEFINE FIELD OVERWRITE media ON TABLE innovation TYPE array<object> FLEXIBLE DEFAULT [];
|
||||
DEFINE FIELD OVERWRITE attachments ON TABLE knowledge_item TYPE array<object> FLEXIBLE DEFAULT [];
|
||||
DEFINE FIELD OVERWRITE payload ON TABLE notification TYPE object FLEXIBLE DEFAULT {};
|
||||
DEFINE FIELD OVERWRITE context ON TABLE audit_log TYPE object FLEXIBLE DEFAULT {};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using TarninaHub.Application.Mappings;
|
||||
using TarninaHub.Application.Text;
|
||||
using TarninaHub.Domain.Enums;
|
||||
|
||||
namespace TarninaHub.Tests.Application;
|
||||
|
||||
public class KeywordExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extract_RemovesStopWordsAndShortTokens()
|
||||
{
|
||||
var keywords = KeywordExtractor.Extract("To jest problem dla seniorów i seniorów w gminie");
|
||||
|
||||
Assert.Contains("seniorów", keywords);
|
||||
Assert.Contains("problem", keywords);
|
||||
Assert.DoesNotContain("jest", keywords);
|
||||
Assert.DoesNotContain("dla", keywords);
|
||||
Assert.DoesNotContain("i", keywords);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RespectsMaxAndOrdersByFrequency()
|
||||
{
|
||||
var keywords = KeywordExtractor.Extract("transport transport transport zdrowie zdrowie rodzina", max: 2);
|
||||
|
||||
Assert.Equal(2, keywords.Count);
|
||||
Assert.Equal("transport", keywords[0]);
|
||||
Assert.Equal("zdrowie", keywords[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReturnsEmptyForBlankText()
|
||||
{
|
||||
Assert.Empty(KeywordExtractor.Extract(" "));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubmissionStatus.New, "new")]
|
||||
[InlineData(SubmissionStatus.InProgress, "in_progress")]
|
||||
[InlineData(SubmissionStatus.Rejected, "rejected")]
|
||||
public void StatusWire_RoundTrips(SubmissionStatus status, string wire)
|
||||
{
|
||||
Assert.Equal(wire, status.ToWire());
|
||||
Assert.Equal(status, SubmissionStatusWire.Parse(wire));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TarninaHub.Application.Security;
|
||||
using TarninaHub.Infrastructure.Auth;
|
||||
|
||||
namespace TarninaHub.Tests.Auth;
|
||||
|
||||
public class SecurityConstantsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("user", true)]
|
||||
[InlineData("jst", true)]
|
||||
[InlineData("operator", true)]
|
||||
[InlineData("manager", true)]
|
||||
[InlineData("admin", true)]
|
||||
[InlineData("guest", false)]
|
||||
public void RoleNames_IsValid_RecognizesKnownRoles(string role, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RoleNames.IsValid(role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthSettings_HasUsableSigningKey_RequiresAtLeast32Chars()
|
||||
{
|
||||
Assert.False(new AuthSettings { SigningKey = "short" }.HasUsableSigningKey);
|
||||
Assert.True(new AuthSettings { SigningKey = new string('k', 32) }.HasUsableSigningKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsentScopes_ContainsExpectedLevels()
|
||||
{
|
||||
Assert.Contains(ConsentScopes.ByConsent, ConsentScopes.All);
|
||||
Assert.Contains(ConsentScopes.Temporary, ConsentScopes.All);
|
||||
Assert.Contains(ConsentScopes.Anonymized, ConsentScopes.All);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TarninaHub.Application.Models;
|
||||
using TarninaHub.Infrastructure.Auth;
|
||||
|
||||
namespace TarninaHub.Tests.Auth;
|
||||
|
||||
public class TokenServiceTests
|
||||
{
|
||||
private static AuthSettings CreateSettings() => new()
|
||||
{
|
||||
Issuer = "tarninahub",
|
||||
Audience = "tarninahub",
|
||||
SigningKey = new string('k', 40),
|
||||
TokenLifetimeMinutes = 30,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void CreateToken_IncludesSubjectAndRole()
|
||||
{
|
||||
var service = new TokenService(Options.Create(CreateSettings()));
|
||||
var user = new UserAccount("user:ania", "+48000000001", "ania@example.test", "user", null);
|
||||
|
||||
var result = service.CreateToken(user);
|
||||
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken);
|
||||
|
||||
Assert.Equal("user:ania", jwt.Claims.Single(claim => claim.Type == "sub").Value);
|
||||
Assert.Equal("user", jwt.Claims.Single(claim => claim.Type == "role").Value);
|
||||
Assert.Equal("tarninahub", jwt.Issuer);
|
||||
Assert.True(result.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateToken_IsValidatedByTheSameSigningKey()
|
||||
{
|
||||
var settings = CreateSettings();
|
||||
var service = new TokenService(Options.Create(settings));
|
||||
var user = new UserAccount("user:jst", "+48000000010", null, "jst", "organization:mops_krakow");
|
||||
|
||||
var result = service.CreateToken(user);
|
||||
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = settings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = settings.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey)),
|
||||
ValidateLifetime = true,
|
||||
RoleClaimType = "role",
|
||||
NameClaimType = "sub",
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
var principal = handler.ValidateToken(result.AccessToken, parameters, out _);
|
||||
|
||||
Assert.Equal("jst", principal.FindFirst("role")?.Value);
|
||||
Assert.Equal("user:jst", principal.FindFirst("sub")?.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Controllers;
|
||||
|
||||
namespace TarninaHub.Tests.Controllers;
|
||||
|
||||
public class SubmissionsControllerTests
|
||||
{
|
||||
private static SubmissionsController CreateController(ISubmissionService service, string role = "operator")
|
||||
{
|
||||
var controller = new SubmissionsController(service);
|
||||
var identity = new ClaimsIdentity(
|
||||
[new Claim("sub", "user:tester"), new Claim("role", role)],
|
||||
authenticationType: "test");
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) },
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static SubmissionResponse SampleSubmission(string status) => new(
|
||||
"submission:abc",
|
||||
"problem:xyz",
|
||||
"Title",
|
||||
"Description",
|
||||
"social_challenge:seniorzy",
|
||||
"location:krakow",
|
||||
"user:tester",
|
||||
status,
|
||||
["seniorzy"],
|
||||
DateTimeOffset.UtcNow,
|
||||
null);
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_RejectsInvalidTransition()
|
||||
{
|
||||
var service = new StubSubmissionService { Current = SampleSubmission("new") };
|
||||
var controller = CreateController(service);
|
||||
|
||||
var result = await controller.UpdateStatus(
|
||||
"submission:abc",
|
||||
new UpdateSubmissionStatusRequest("matched"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ObjectResult>(result.Result);
|
||||
Assert.Null(service.LastRequestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_AcceptsValidTransition()
|
||||
{
|
||||
var service = new StubSubmissionService
|
||||
{
|
||||
Current = SampleSubmission("new"),
|
||||
UpdateResult = SampleSubmission("triaged"),
|
||||
};
|
||||
var controller = CreateController(service);
|
||||
|
||||
var result = await controller.UpdateStatus(
|
||||
"submission:abc",
|
||||
new UpdateSubmissionStatusRequest("triaged"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
Assert.Equal("triaged", service.LastRequestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsNotFoundWhenMissing()
|
||||
{
|
||||
var controller = CreateController(new StubSubmissionService());
|
||||
|
||||
var result = await controller.GetById("submission:missing", CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
private sealed class StubSubmissionService : ISubmissionService
|
||||
{
|
||||
public SubmissionResponse? Current { get; set; }
|
||||
|
||||
public SubmissionResponse? UpdateResult { get; set; }
|
||||
|
||||
public string? LastRequestedStatus { get; private set; }
|
||||
|
||||
public Task<SubmissionResponse> CreateAsync(
|
||||
string authorId,
|
||||
CreateSubmissionRequest request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(SampleSubmission("new"));
|
||||
|
||||
public Task<PagedResponse<SubmissionResponse>> ListAsync(
|
||||
string viewerId,
|
||||
bool isStaff,
|
||||
string? status,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken) => Task.FromResult(
|
||||
new PagedResponse<SubmissionResponse>([], page, pageSize, 0));
|
||||
|
||||
public Task<SubmissionResponse?> GetAsync(string submissionId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(Current);
|
||||
|
||||
public Task<SubmissionResponse?> UpdateStatusAsync(
|
||||
string submissionId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestedStatus = newStatus;
|
||||
|
||||
return Task.FromResult(UpdateResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using TarninaHub.Domain.Enums;
|
||||
using TarninaHub.Domain.Rules;
|
||||
|
||||
namespace TarninaHub.Tests.Domain;
|
||||
|
||||
public class SubmissionWorkflowTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SubmissionStatus.New, SubmissionStatus.Triaged, true)]
|
||||
[InlineData(SubmissionStatus.New, SubmissionStatus.Matched, false)]
|
||||
[InlineData(SubmissionStatus.Triaged, SubmissionStatus.Matched, true)]
|
||||
[InlineData(SubmissionStatus.Matched, SubmissionStatus.InProgress, true)]
|
||||
[InlineData(SubmissionStatus.InProgress, SubmissionStatus.Closed, true)]
|
||||
[InlineData(SubmissionStatus.Closed, SubmissionStatus.Triaged, false)]
|
||||
[InlineData(SubmissionStatus.Rejected, SubmissionStatus.New, false)]
|
||||
public void CanTransition_EnforcesWorkflow(SubmissionStatus from, SubmissionStatus to, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SubmissionWorkflow.CanTransition(from, to));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanTransition_AllowsSameStatus()
|
||||
{
|
||||
Assert.True(SubmissionWorkflow.CanTransition(SubmissionStatus.New, SubmissionStatus.New));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubmissionStatus.Closed, true)]
|
||||
[InlineData(SubmissionStatus.Rejected, true)]
|
||||
[InlineData(SubmissionStatus.New, false)]
|
||||
public void IsTerminal_RecognizesFinalStates(SubmissionStatus status, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SubmissionWorkflow.IsTerminal(status));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user