Initial build: WishNinja self-hosted gift wishlist manager
ASP.NET Core Blazor (.NET 10) + EF Core/SQLite + Identity. Features: - Wishlists & items with local image storage (upload, clipboard paste, or URL fetched and stored locally) - Owner-hidden claims (enforced at the query layer) to preserve surprises - Admin-invite-only onboarding with email-based password resets - All state under /data; ships as a single Docker image Includes Dockerfile, docker-compose, Gitea Actions CI (test + push image), Unraid template, and xUnit tests (claim privacy, invite lifecycle, image validation). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WishNinja.Configuration;
|
||||
using WishNinja.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace WishNinja.Tests;
|
||||
|
||||
public class ImageServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly ImageService _svc;
|
||||
|
||||
public ImageServiceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "wishninja-tests-" + Guid.NewGuid().ToString("N"));
|
||||
var options = Options.Create(new WishNinjaOptions
|
||||
{
|
||||
DataPath = _tempDir,
|
||||
Uploads = new UploadOptions { MaxBytes = 1024 }, // 1 KB cap for the test
|
||||
});
|
||||
_svc = new ImageService(options, new StubHttpClientFactory());
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("image/png", true)]
|
||||
[InlineData("image/jpeg", true)]
|
||||
[InlineData("image/webp", true)]
|
||||
[InlineData("image/gif", true)]
|
||||
[InlineData("application/pdf", false)]
|
||||
[InlineData("text/html", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsAllowedContentType_enforces_allowlist(string? contentType, bool expected) =>
|
||||
Assert.Equal(expected, _svc.IsAllowedContentType(contentType));
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_rejects_disallowed_type()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not an image"));
|
||||
var result = await _svc.SaveAsync(stream, "application/pdf");
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_rejects_oversize_file_and_cleans_up()
|
||||
{
|
||||
using var stream = new MemoryStream(new byte[2048]); // exceeds 1 KB cap
|
||||
var result = await _svc.SaveAsync(stream, "image/png");
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Empty(Directory.GetFiles(_svc.UploadsDirectory)); // partial file removed
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-url")]
|
||||
[InlineData("ftp://example.com/x.png")]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
public async Task SaveFromUrlAsync_rejects_non_http_urls(string url)
|
||||
{
|
||||
var result = await _svc.SaveFromUrlAsync(url);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Empty(Directory.GetFiles(_svc.UploadsDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_writes_allowed_file_with_guid_name()
|
||||
{
|
||||
using var stream = new MemoryStream(new byte[512]);
|
||||
var result = await _svc.SaveAsync(stream, "image/png");
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.EndsWith(".png", result.FileName);
|
||||
Assert.True(File.Exists(Path.Combine(_svc.UploadsDirectory, result.FileName!)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WishNinja.Configuration;
|
||||
using WishNinja.Data;
|
||||
using WishNinja.Data.Entities;
|
||||
using WishNinja.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace WishNinja.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the token-validation half of the invite lifecycle (expiry + single-use). Account
|
||||
/// creation in AcceptInviteAsync requires the full Identity stack and is exercised manually /
|
||||
/// in integration, not here.
|
||||
/// </summary>
|
||||
public class InviteServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDb _db = new();
|
||||
private readonly InviteService _svc;
|
||||
private const string AdminId = "admin-1";
|
||||
|
||||
public InviteServiceTests()
|
||||
{
|
||||
using (var ctx = _db.CreateDbContext())
|
||||
{
|
||||
ctx.Users.Add(new ApplicationUser { Id = AdminId, UserName = "admin@x", DisplayName = "Admin" });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
_svc = new InviteService(
|
||||
_db,
|
||||
userManager: null!, // unused by GetValidInviteAsync
|
||||
emailSender: null!, // unused by GetValidInviteAsync
|
||||
options: Options.Create(new WishNinjaOptions()),
|
||||
logger: NullLogger<InviteService>.Instance);
|
||||
}
|
||||
|
||||
private static string Hash(string raw) =>
|
||||
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
|
||||
|
||||
private void SeedInvite(string rawToken, DateTimeOffset expiresAt, DateTimeOffset? acceptedAt = null)
|
||||
{
|
||||
using var ctx = _db.CreateDbContext();
|
||||
ctx.Invites.Add(new Invite
|
||||
{
|
||||
Email = "invitee@x",
|
||||
TokenHash = Hash(rawToken),
|
||||
Role = "User",
|
||||
InvitedByUserId = AdminId,
|
||||
ExpiresAt = expiresAt,
|
||||
AcceptedAt = acceptedAt,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_token_returns_invite()
|
||||
{
|
||||
SeedInvite("good-token", DateTimeOffset.UtcNow.AddHours(1));
|
||||
var invite = await _svc.GetValidInviteAsync("good-token");
|
||||
Assert.NotNull(invite);
|
||||
Assert.Equal("invitee@x", invite!.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_token_returns_null()
|
||||
{
|
||||
SeedInvite("old-token", DateTimeOffset.UtcNow.AddHours(-1));
|
||||
Assert.Null(await _svc.GetValidInviteAsync("old-token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accepted_token_returns_null()
|
||||
{
|
||||
SeedInvite("used-token", DateTimeOffset.UtcNow.AddHours(1), acceptedAt: DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
Assert.Null(await _svc.GetValidInviteAsync("used-token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_token_returns_null()
|
||||
{
|
||||
SeedInvite("real-token", DateTimeOffset.UtcNow.AddHours(1));
|
||||
Assert.Null(await _svc.GetValidInviteAsync("wrong-token"));
|
||||
Assert.Null(await _svc.GetValidInviteAsync(""));
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IDbContextFactory{TContext}"/> backed by a single shared in-memory SQLite
|
||||
/// connection, so data written by one context is visible to the next. Dispose to tear down.
|
||||
/// </summary>
|
||||
public sealed class TestDb : IDbContextFactory<ApplicationDbContext>, IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<ApplicationDbContext> _options;
|
||||
|
||||
public TestDb()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
_options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public ApplicationDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public void Dispose() => _connection.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\WishNinja\WishNinja.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WishNinja.Data;
|
||||
using WishNinja.Data.Entities;
|
||||
using WishNinja.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace WishNinja.Tests;
|
||||
|
||||
public class WishlistServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDb _db = new();
|
||||
private readonly WishlistService _svc;
|
||||
|
||||
private const string OwnerId = "owner-1";
|
||||
private const string FriendId = "friend-1";
|
||||
private const string OutsiderId = "outsider-1";
|
||||
|
||||
public WishlistServiceTests()
|
||||
{
|
||||
_svc = new WishlistService(_db);
|
||||
Seed();
|
||||
}
|
||||
|
||||
private void Seed()
|
||||
{
|
||||
using var ctx = _db.CreateDbContext();
|
||||
ctx.Users.AddRange(
|
||||
new ApplicationUser { Id = OwnerId, UserName = "owner@x", DisplayName = "Owner" },
|
||||
new ApplicationUser { Id = FriendId, UserName = "friend@x", DisplayName = "Friend" },
|
||||
new ApplicationUser { Id = OutsiderId, UserName = "out@x", DisplayName = "Outsider" });
|
||||
|
||||
var list = new Wishlist
|
||||
{
|
||||
Id = 1,
|
||||
OwnerId = OwnerId,
|
||||
Title = "Birthday",
|
||||
Visibility = WishlistVisibility.AllMembers,
|
||||
Items =
|
||||
{
|
||||
new WishlistItem { Id = 10, Name = "Book", Quantity = 1 },
|
||||
new WishlistItem { Id = 11, Name = "Mug", Quantity = 3 },
|
||||
},
|
||||
};
|
||||
ctx.Wishlists.Add(list);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
// --- The defining rule: the owner never sees claim data --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_view_never_exposes_claims()
|
||||
{
|
||||
// Friend claims both items.
|
||||
await _svc.ClaimAsync(10, FriendId, 1, "got it");
|
||||
await _svc.ClaimAsync(11, FriendId, 2, null);
|
||||
|
||||
var view = await _svc.GetDetailAsync(1, OwnerId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.True(view!.IsViewerOwner);
|
||||
Assert.All(view.Items, iv =>
|
||||
{
|
||||
Assert.True(iv.IsViewerOwner);
|
||||
Assert.Equal(0, iv.ClaimedQuantity);
|
||||
Assert.False(iv.ClaimedByViewer);
|
||||
Assert.Empty(iv.OtherClaims);
|
||||
// Remaining always equals the wanted quantity for the owner — no leakage.
|
||||
Assert.Equal(iv.Item.Quantity, iv.RemainingQuantity);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonOwner_view_shows_claims()
|
||||
{
|
||||
await _svc.ClaimAsync(11, FriendId, 2, "two mugs");
|
||||
|
||||
// Outsider (also a member) sees the claim made by Friend.
|
||||
var outsiderView = await _svc.GetDetailAsync(1, OutsiderId);
|
||||
var mug = outsiderView!.Items.Single(i => i.Item.Id == 11);
|
||||
Assert.False(outsiderView.IsViewerOwner);
|
||||
Assert.Equal(2, mug.ClaimedQuantity);
|
||||
Assert.False(mug.ClaimedByViewer);
|
||||
Assert.Single(mug.OtherClaims);
|
||||
Assert.Equal("Friend", mug.OtherClaims[0].ClaimedByDisplayName);
|
||||
|
||||
// Friend sees their own claim flagged.
|
||||
var friendView = await _svc.GetDetailAsync(1, FriendId);
|
||||
var friendMug = friendView!.Items.Single(i => i.Item.Id == 11);
|
||||
Assert.True(friendMug.ClaimedByViewer);
|
||||
Assert.Equal(1, friendMug.RemainingQuantity); // 3 wanted - 2 claimed
|
||||
}
|
||||
|
||||
// --- Claim mutation rules --------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_cannot_claim_their_own_item()
|
||||
{
|
||||
var result = await _svc.ClaimAsync(10, OwnerId, 1, null);
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_cannot_exceed_remaining_quantity()
|
||||
{
|
||||
var first = await _svc.ClaimAsync(11, FriendId, 2, null);
|
||||
Assert.True(first.Succeeded);
|
||||
|
||||
// Only 1 of 3 remains; outsider asking for 2 should fail.
|
||||
var second = await _svc.ClaimAsync(11, OutsiderId, 2, null);
|
||||
Assert.False(second.Succeeded);
|
||||
|
||||
// But claiming the last 1 succeeds.
|
||||
var third = await _svc.ClaimAsync(11, OutsiderId, 1, null);
|
||||
Assert.True(third.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unclaim_frees_the_quantity()
|
||||
{
|
||||
await _svc.ClaimAsync(10, FriendId, 1, null);
|
||||
var blocked = await _svc.ClaimAsync(10, OutsiderId, 1, null);
|
||||
Assert.False(blocked.Succeeded); // single-quantity item already taken
|
||||
|
||||
await _svc.UnclaimAsync(10, FriendId);
|
||||
var nowOk = await _svc.ClaimAsync(10, OutsiderId, 1, null);
|
||||
Assert.True(nowOk.Succeeded);
|
||||
}
|
||||
|
||||
// --- Visibility ------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SpecificUsers_visibility_blocks_unshared_users()
|
||||
{
|
||||
using (var ctx = _db.CreateDbContext())
|
||||
{
|
||||
var list = ctx.Wishlists.Single(w => w.Id == 1);
|
||||
list.Visibility = WishlistVisibility.SpecificUsers;
|
||||
ctx.WishlistShares.Add(new WishlistShare { WishlistId = 1, UserId = FriendId });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
Assert.NotNull(await _svc.GetDetailAsync(1, FriendId)); // explicitly shared
|
||||
Assert.Null(await _svc.GetDetailAsync(1, OutsiderId)); // not shared
|
||||
Assert.NotNull(await _svc.GetDetailAsync(1, OwnerId)); // owner always
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user