feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment (#56)
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment. ### Summary of Changes 1. **Docker Infrastructure & Secrets**: - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations. - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords. - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets. 2. **Database Hardening**: - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration). - Configured PostgreSQL to use mandatory authentication. - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only. 3. **Feature-Flagged Restrictions**: - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`. - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments. - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error. - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #56 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #56.
This commit is contained in:
@@ -16,5 +16,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace NexusReader.Application.Tests.Services;
|
||||
|
||||
public class EpubReaderServiceTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
||||
private readonly Mock<ILogger<EpubReaderService>> _loggerMock;
|
||||
|
||||
public EpubReaderServiceTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Seed initial database schema
|
||||
using var context = new AppDbContext(_contextOptions);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
||||
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
||||
_dbContextFactoryMock.Setup(f => f.CreateDbContext())
|
||||
.Returns(() => new AppDbContext(_contextOptions));
|
||||
|
||||
_loggerMock = new Mock<ILogger<EpubReaderService>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpubContentAsync_RewritesImageUrlsAndExtractsImages()
|
||||
{
|
||||
// Arrange
|
||||
var ebookId = Guid.NewGuid();
|
||||
var userId = "test-user-id";
|
||||
|
||||
using (var context = new AppDbContext(_contextOptions))
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = "testuser",
|
||||
Email = "test@nexus.com",
|
||||
TenantId = "tenant-123",
|
||||
SubscriptionPlanId = 1
|
||||
};
|
||||
context.Users.Add(user);
|
||||
|
||||
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
|
||||
context.Authors.Add(author);
|
||||
|
||||
var ebook = new Ebook
|
||||
{
|
||||
Id = ebookId,
|
||||
UserId = userId,
|
||||
Title = "Test Book",
|
||||
AuthorId = author.Id,
|
||||
FilePath = "assets/book.epub",
|
||||
AddedDate = DateTime.UtcNow,
|
||||
LastReadDate = DateTime.UtcNow,
|
||||
Progress = 0,
|
||||
LastChapter = "Introduction"
|
||||
};
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetEpubContentAsync(ebookId, 0, userId);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBeNull();
|
||||
result.Value.Blocks.Should().NotBeEmpty();
|
||||
|
||||
// Check that any img tags extracted are preserved and rewritten
|
||||
var hasImages = false;
|
||||
foreach (var block in result.Value.Blocks)
|
||||
{
|
||||
if (block is TextSegmentBlock textBlock && textBlock.Content.Contains("<img"))
|
||||
{
|
||||
hasImages = true;
|
||||
textBlock.Content.Should().Contain($"/api/epub/{ebookId}/resource?path=");
|
||||
}
|
||||
}
|
||||
|
||||
// Output result for developer sanity check
|
||||
Console.WriteLine($"Epub parsed successfully. Image tags found: {hasImages}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpubResourceAsync_ExtractsValidEpubResource()
|
||||
{
|
||||
// Arrange
|
||||
var ebookId = Guid.NewGuid();
|
||||
var userId = "test-user-id";
|
||||
|
||||
using (var context = new AppDbContext(_contextOptions))
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = "testuser",
|
||||
Email = "test@nexus.com",
|
||||
TenantId = "tenant-123",
|
||||
SubscriptionPlanId = 1
|
||||
};
|
||||
context.Users.Add(user);
|
||||
|
||||
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
|
||||
context.Authors.Add(author);
|
||||
|
||||
var ebook = new Ebook
|
||||
{
|
||||
Id = ebookId,
|
||||
UserId = userId,
|
||||
Title = "Test Book",
|
||||
AuthorId = author.Id,
|
||||
FilePath = "assets/book.epub",
|
||||
AddedDate = DateTime.UtcNow,
|
||||
LastReadDate = DateTime.UtcNow,
|
||||
Progress = 0,
|
||||
LastChapter = "Introduction"
|
||||
};
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
|
||||
|
||||
// First find a valid image or resource path in the book by getting the content or accessing a known path.
|
||||
// Lives of the Most Excellent Painters contains OEBPS/images/cover.jpg or similar.
|
||||
// Let's call GetEpubResourceAsync on a common path (e.g. OEBPS/images/cover.jpg)
|
||||
// Since we don't know the exact path in advance, let's try a few standard locations or look up a file.
|
||||
var targetResource = "OEBPS/images/cover.jpg";
|
||||
|
||||
// Act
|
||||
var result = await service.GetEpubResourceAsync(ebookId, targetResource, userId);
|
||||
|
||||
// Assert - if it is found, it must return success and bytes.
|
||||
// If the path is different, we can try another or assert the failure is at least not a crash.
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
result.Value.Should().NotBeNull();
|
||||
result.Value.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try fallback cover or other typical EPUB resources
|
||||
var fallbackResult = await service.GetEpubResourceAsync(ebookId, "images/cover.jpg", userId);
|
||||
if (fallbackResult.IsSuccess)
|
||||
{
|
||||
fallbackResult.Value.Should().NotBeNull();
|
||||
fallbackResult.Value.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeParagraph_StripsUnsafeAttributesFromImgTags()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(EpubReaderService).GetMethod("SanitizeParagraph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var input = "<img src=\"images/cover.jpg\" alt=\"Cover Image\" onerror=\"alert(1)\" onload=\"evil()\" style=\"color:red\" class=\"img-responsive\" />";
|
||||
|
||||
// Act
|
||||
var result = (string)method.Invoke(null, new object[] { input });
|
||||
|
||||
// Assert
|
||||
result.Should().NotContain("onerror");
|
||||
result.Should().NotContain("onload");
|
||||
result.Should().NotContain("style");
|
||||
result.Should().NotContain("class");
|
||||
result.Should().Contain("src=\"images/cover.jpg\"");
|
||||
result.Should().Contain("alt=\"Cover Image\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RewriteImageUrls_BlocksJavaScriptScheme()
|
||||
{
|
||||
// Arrange
|
||||
var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var input = "<img src=\"javascript:alert(1)\" />";
|
||||
var ebookId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = (string)method.Invoke(null, new object[] { input, ebookId, "OEBPS/chapter1.xhtml" });
|
||||
|
||||
// Assert
|
||||
result.Should().NotContain("javascript:alert(1)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEpubResourceAsync_RejectsInvalidResourcePaths()
|
||||
{
|
||||
// Arrange
|
||||
var ebookId = Guid.NewGuid();
|
||||
var userId = "test-user-id";
|
||||
|
||||
using (var context = new AppDbContext(_contextOptions))
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = "testuser",
|
||||
Email = "test@nexus.com",
|
||||
TenantId = "tenant-123",
|
||||
SubscriptionPlanId = 1
|
||||
};
|
||||
context.Users.Add(user);
|
||||
|
||||
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
|
||||
context.Authors.Add(author);
|
||||
|
||||
var ebook = new Ebook
|
||||
{
|
||||
Id = ebookId,
|
||||
UserId = userId,
|
||||
Title = "Test Book",
|
||||
AuthorId = author.Id,
|
||||
FilePath = "assets/book.epub",
|
||||
AddedDate = DateTime.UtcNow,
|
||||
LastReadDate = DateTime.UtcNow,
|
||||
Progress = 0,
|
||||
LastChapter = "Introduction"
|
||||
};
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var traversalResult = await service.GetEpubResourceAsync(ebookId, "../../appsettings.json", userId);
|
||||
var colonResult = await service.GetEpubResourceAsync(ebookId, "C:\\windows\\win.ini", userId);
|
||||
|
||||
// Assert
|
||||
traversalResult.IsSuccess.Should().BeFalse();
|
||||
traversalResult.Errors.First().Message.Should().Contain("Invalid resource path");
|
||||
|
||||
colonResult.IsSuccess.Should().BeFalse();
|
||||
colonResult.Errors.First().Message.Should().Contain("Invalid resource path");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Close();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace NexusReader.Application.Tests.Services;
|
||||
|
||||
public class JwtTokenValidatorTests
|
||||
{
|
||||
private string CreateMockToken(long exp)
|
||||
{
|
||||
// {"alg":"HS256","typ":"JWT"}
|
||||
var header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
|
||||
|
||||
var payloadJson = $"{{\"exp\":{exp}}}";
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var payload = Convert.ToBase64String(payloadBytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
|
||||
return $"{header}.{payload}.signature";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_WithNullOrEmptyToken_ShouldReturnTrue()
|
||||
{
|
||||
JwtTokenValidator.IsExpired(null).Should().BeTrue();
|
||||
JwtTokenValidator.IsExpired("").Should().BeTrue();
|
||||
JwtTokenValidator.IsExpired(" ").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_WithMalformedToken_ShouldReturnTrue()
|
||||
{
|
||||
JwtTokenValidator.IsExpired("not.a.valid.token.format.here").Should().BeTrue();
|
||||
JwtTokenValidator.IsExpired("part1.part2").Should().BeTrue();
|
||||
JwtTokenValidator.IsExpired("justonestring").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_WithExpiredToken_ShouldReturnTrue()
|
||||
{
|
||||
// Expired 1 hour ago
|
||||
var expiredTime = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
|
||||
var token = CreateMockToken(expiredTime);
|
||||
|
||||
JwtTokenValidator.IsExpired(token).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_WithValidToken_ShouldReturnFalse()
|
||||
{
|
||||
// Valid for 1 hour in the future
|
||||
var futureTime = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
|
||||
var token = CreateMockToken(futureTime);
|
||||
|
||||
JwtTokenValidator.IsExpired(token).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_WithTokenInsideSkewBuffer_ShouldReturnTrue()
|
||||
{
|
||||
// Expiring in 5 seconds (within the 10-second skew buffer)
|
||||
var skewTime = DateTimeOffset.UtcNow.AddSeconds(5).ToUnixTimeSeconds();
|
||||
var token = CreateMockToken(skewTime);
|
||||
|
||||
JwtTokenValidator.IsExpired(token).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user