Add email-based invites and email confirmation in one flow

Family admins can now invite new members by email. The recipient gets a
templated email with a single-use, time-limited join link; clicking it
opens the registration form bound to the invite, and submitting the
form simultaneously consumes the invite and marks the email confirmed.
Self-registration via the shareable family code remains available.

Backend
- New Invite entity (token hash only — raw token never stored), with
  per-family uniqueness on the active hash.
- User gains nullable Email and EmailConfirmedAt; partial unique index
  so legacy rows with no email do not collide.
- /api/family/invites — admin endpoints to list pending, issue, resend
  (rotates the token), and revoke.
- /api/auth/invite/{token} — public lookup returning email + family name
  so the registration form can show "you have been invited to X".
- /api/auth/register accepts InviteToken; the invite vouches for the
  email, so any client-supplied email field is ignored. Falls back to
  FamilyCode when no invite token is present.
- AppUrlOptions / AppBaseUrl plumbed through so emails build absolute
  links to the deployed frontend.

Frontend
- /login reads ?invite=<token>, looks it up, and switches the form into
  invite-registration mode (pre-binding to the invited email + family).
- /family admin section gains an invite-by-email form, a pending list,
  and resend/revoke actions with a confirmation modal.

Tests
- 14 new integration tests covering: admin issue, member-forbidden,
  lookup, valid/expired/consumed/unknown-token registration, resend
  rotation, revocation, pending-only list filter, and conflict for
  inviting an existing member. RecordingEmailSender captures dispatched
  messages so tests can assert on the link without standing up SMTP.
This commit is contained in:
Josh Rogers
2026-05-08 22:42:55 -05:00
parent a1635218a8
commit d9ffe18b21
17 changed files with 1658 additions and 30 deletions
@@ -0,0 +1,244 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Auth;
using YesChef.Api.Features.Families;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
/// <summary>
/// Covers the full email-invite lifecycle: admin issues invite, recipient
/// looks it up, registers with it (consuming + confirming email in one step),
/// plus the unhappy paths for non-admin callers, expired/consumed/wrong tokens,
/// and tampered registration payloads.
/// </summary>
public class InviteEndpointsTests : AuthenticatedIntegrationTest
{
[Test]
public async Task Admin_creates_invite_and_email_is_sent()
{
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("alice@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var invite = await response.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
await Assert.That(invite!.Email).IsEqualTo("alice@example.com");
await Assert.That(App.Emails.Messages.Count).IsEqualTo(1);
var message = App.Emails.Messages.Single();
await Assert.That(message.ToAddress).IsEqualTo("alice@example.com");
await Assert.That(message.TextBody).Contains("/register?invite=");
}
[Test]
public async Task Member_cannot_create_invite()
{
using var member = await Data.RegisterAsync("rank-and-file");
var response = await member.Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("alice@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}
[Test]
public async Task Lookup_returns_family_name_and_email()
{
var token = await IssueInviteAndExtractTokenAsync("bob@example.com");
var lookup = await AnonymousClient.GetFromJsonAsync<AuthEndpoints.InviteLookupResponse>($"/api/auth/invite/{token}");
await Assert.That(lookup!.Email).IsEqualTo("bob@example.com");
await Assert.That(lookup.FamilyName).IsEqualTo("Family");
}
[Test]
public async Task Lookup_404s_for_unknown_token()
{
var response = await AnonymousClient.GetAsync("/api/auth/invite/not-a-real-token");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Register_with_invite_consumes_it_and_confirms_email()
{
var token = await IssueInviteAndExtractTokenAsync("carol@example.com");
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("carol", "pw-1234", null, token));
await Assert.That(register.StatusCode).IsEqualTo(HttpStatusCode.OK);
var user = await UseDbAsync(db => db.Users.SingleAsync(u => u.Name == "carol"));
await Assert.That(user.Email).IsEqualTo("carol@example.com");
await Assert.That(user.EmailConfirmedAt).IsNotNull();
var invite = await UseDbAsync(db => db.Invites.SingleAsync(i => i.Email == "carol@example.com"));
await Assert.That(invite.ConsumedAt).IsNotNull();
await Assert.That(invite.ConsumedByUserId).IsEqualTo(user.Id);
}
[Test]
public async Task Register_with_invite_joins_correct_family_as_member()
{
var token = await IssueInviteAndExtractTokenAsync("dave@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("dave", "pw-1234", null, token));
var (familyId, role) = await UseDbAsync(db =>
(from u in db.Users
join m in db.FamilyMemberships on u.Id equals m.UserId
where u.Name == "dave"
select new ValueTuple<int, Entities.FamilyRole>(m.FamilyId, m.Role)).SingleAsync());
// The default family from the bootstrap is the only one — the new
// user must land in it as a Member (admin already exists: that's User).
var bootstrapFamilyId = await UseDbAsync(db => db.Families.Select(f => f.Id).SingleAsync());
await Assert.That(familyId).IsEqualTo(bootstrapFamilyId);
await Assert.That(role).IsEqualTo(Entities.FamilyRole.Member);
}
[Test]
public async Task Register_with_consumed_invite_is_rejected()
{
var token = await IssueInviteAndExtractTokenAsync("eve@example.com");
var first = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("eve", "pw-1234", null, token));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.OK);
var second = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("eve-2", "pw-1234", null, token));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_with_unknown_token_is_rejected()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("fran", "pw-1234", null, "this-token-does-not-exist"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_with_expired_invite_is_rejected()
{
var token = await IssueInviteAndExtractTokenAsync("greg@example.com");
// Force the invite into the past directly via the DB.
await UseDbAsync(async db =>
{
var invite = await db.Invites.SingleAsync(i => i.Email == "greg@example.com");
invite.ExpiresAt = DateTime.UtcNow.AddMinutes(-1);
await db.SaveChangesAsync();
});
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("greg", "pw-1234", null, token));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_without_invite_or_family_code_is_rejected()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("hank", "pw-1234"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Resend_rotates_token_and_invalidates_previous()
{
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("ivy@example.com"));
var invite = await inviteResponse.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
var firstToken = ExtractTokenFromMostRecentEmail();
var resend = await Client.PostAsync($"/api/family/invites/{invite!.Id}/resend", content: null);
await Assert.That(resend.StatusCode).IsEqualTo(HttpStatusCode.OK);
var secondToken = ExtractTokenFromMostRecentEmail();
await Assert.That(secondToken).IsNotEqualTo(firstToken);
// Old token must no longer resolve.
var oldLookup = await AnonymousClient.GetAsync($"/api/auth/invite/{firstToken}");
await Assert.That(oldLookup.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
// New token still works.
var newLookup = await AnonymousClient.GetAsync($"/api/auth/invite/{secondToken}");
await Assert.That(newLookup.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
[Test]
public async Task Revoke_makes_invite_unusable()
{
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("jack@example.com"));
var invite = await inviteResponse.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
var token = ExtractTokenFromMostRecentEmail();
var revoke = await Client.DeleteAsync($"/api/family/invites/{invite!.Id}");
await Assert.That(revoke.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var lookup = await AnonymousClient.GetAsync($"/api/auth/invite/{token}");
await Assert.That(lookup.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task List_invites_returns_pending_only()
{
await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("kate@example.com"));
var lenInvite = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("len@example.com"));
var consumed = await lenInvite.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
await Client.DeleteAsync($"/api/family/invites/{consumed!.Id}");
var pending = await Client.GetFromJsonAsync<List<InviteEndpoints.InviteDto>>("/api/family/invites");
await Assert.That(pending!.Count).IsEqualTo(1);
await Assert.That(pending[0].Email).IsEqualTo("kate@example.com");
}
[Test]
public async Task Invite_for_existing_member_returns_conflict()
{
// Make the admin's email match the invite target so the same-email
// member check trips. (Direct DB set — there's no API to attach an
// email to an existing user yet.)
await UseDbAsync(async db =>
{
var me = await db.Users.SingleAsync(u => u.Id == User.Id);
me.Email = "already@example.com";
await db.SaveChangesAsync();
});
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("already@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
private async Task<string> IssueInviteAndExtractTokenAsync(string email)
{
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest(email));
response.EnsureSuccessStatusCode();
return ExtractTokenFromMostRecentEmail();
}
private string ExtractTokenFromMostRecentEmail()
{
var message = App.Emails.Last;
const string marker = "/register?invite=";
var start = message.TextBody.IndexOf(marker, StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("No invite link in email body.");
start += marker.Length;
var end = message.TextBody.IndexOfAny(['\r', '\n', ' '], start);
if (end < 0) end = message.TextBody.Length;
return Uri.UnescapeDataString(message.TextBody[start..end]);
}
}
@@ -0,0 +1,36 @@
using YesChef.Api.Email;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// Test double for IEmailSender that records every dispatched message in
/// memory (preserving send order). Tests assert on the captured list rather
/// than hitting a real SMTP server.
/// </summary>
public sealed class RecordingEmailSender : IEmailSender
{
private readonly List<EmailMessage> _messages = new();
private readonly Lock _gate = new();
public IReadOnlyList<EmailMessage> Messages
{
get
{
lock (_gate) return _messages.ToArray();
}
}
public EmailMessage Last
{
get
{
lock (_gate) return _messages[^1];
}
}
public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
lock (_gate) _messages.Add(message);
return Task.CompletedTask;
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YesChef.Api.Data;
using YesChef.Api.Email;
namespace YesChef.Api.IntegrationTests.Infrastructure;
@@ -19,6 +20,13 @@ public sealed class YesChefAppFactory : WebApplicationFactory<Program>
public required string ConnectionString { get; init; }
/// <summary>
/// In-memory email sink used for assertions. Replaces the production
/// IEmailSender registration so tests can inspect dispatched messages
/// without standing up an SMTP server.
/// </summary>
public RecordingEmailSender Emails { get; } = new();
/// <summary>
/// Hosting environment override. Defaults to "Testing", which the API
/// uses to disable rate limits. Tests that exercise rate-limit behavior
@@ -49,6 +57,10 @@ public sealed class YesChefAppFactory : WebApplicationFactory<Program>
var descriptor = services.Single(d => d.ServiceType == typeof(DbContextOptions<YesChefDb>));
services.Remove(descriptor);
services.AddDbContext<YesChefDb>(options => options.UseNpgsql(ConnectionString));
var emailDescriptor = services.Single(d => d.ServiceType == typeof(IEmailSender));
services.Remove(emailDescriptor);
services.AddSingleton<IEmailSender>(Emails);
});
}
}