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:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user