Collapse migrations, require email at registration

No deployed environments yet, so consolidate the entire migration history
into a single Initial migration and tighten the schema accordingly.

- User.Email is now non-nullable (required); the partial unique index
  used to tolerate legacy null emails is gone in favor of a plain
  unique index.
- Both register paths require an email up front. Invite-path
  registrations must match the invited address (server ignores any
  mismatched client value); family-code registrations bind whatever the
  user supplies but stay unconfirmed (EmailConfirmedAt = null) since
  the family code does not prove email ownership.
- Validation order in /register reworked: invite/family-code resolution
  runs before duplicate-name/email checks so a consumed token surfaces
  a clean "invitation invalid" error instead of getting masked by the
  duplicate-email response.
- All 14 prior migrations replaced with a single Initial migration.
- Test fixtures, builders, and unit tests updated to supply emails.
- Login page register form now collects an email field; invite-bound
  registrations show the invited address as a read-only input.

Local dev DBs need to be recreated (drop the yeschef-pgdata volume or
the yeschef Postgres database). No production data exists yet.
This commit is contained in:
Josh Rogers
2026-05-08 22:58:27 -05:00
parent af085cfb90
commit 09bec105f6
27 changed files with 608 additions and 3574 deletions
@@ -8,15 +8,22 @@ public sealed class UserBuilder
{
private string _name = $"user-{Guid.NewGuid():N}"[..16];
private string _password = "correct-horse-battery-staple";
private string? _email;
public UserBuilder Named(string name) { _name = name; return this; }
public UserBuilder WithPassword(string password) { _password = password; return this; }
public UserBuilder WithEmail(string email) { _email = email; return this; }
public string PlaintextPassword => _password;
public User Build()
{
var user = new User { Name = _name, PasswordHash = "" };
var user = new User
{
Name = _name,
PasswordHash = "",
Email = _email ?? $"{_name}@example.test",
};
user.PasswordHash = new PasswordHasher<User>().HashPassword(user, _password);
return user;
}
@@ -11,7 +11,7 @@ public class AuthEndpointsTests : IntegrationTest
public async Task Register_creates_user_and_returns_token()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("alice", "hunter2", YesChefAppFactory.FamilyCode));
new AuthEndpoints.RegisterRequest("alice", "hunter2", "alice@example.com", YesChefAppFactory.FamilyCode));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
@@ -24,7 +24,7 @@ public class AuthEndpointsTests : IntegrationTest
public async Task Register_rejects_wrong_family_code()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("bob", "pw", "wrong-code"));
new AuthEndpoints.RegisterRequest("bob", "pw", "bob@example.com", "wrong-code"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
@@ -35,7 +35,7 @@ public class AuthEndpointsTests : IntegrationTest
await Data.RegisterAsync("carol");
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("carol", "another", YesChefAppFactory.FamilyCode));
new AuthEndpoints.RegisterRequest("carol", "another", "carol-2@example.com", YesChefAppFactory.FamilyCode));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
@@ -56,7 +56,7 @@ public class AuthRateLimitTests : IntegrationTest
{
lastResponse?.Dispose();
lastResponse = await client.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest($"rate-{i}", "pw-1234", YesChefAppFactory.FamilyCode));
new AuthEndpoints.RegisterRequest($"rate-{i}", "pw-1234", $"rate-{i}@example.com", YesChefAppFactory.FamilyCode));
}
await Assert.That(lastResponse!.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
@@ -66,7 +66,7 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
var token = await IssueInviteAndExtractTokenAsync("carol@example.com");
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("carol", "pw-1234", null, token));
new AuthEndpoints.RegisterRequest("carol", "pw-1234", "carol@example.com", null, token));
await Assert.That(register.StatusCode).IsEqualTo(HttpStatusCode.OK);
var user = await UseDbAsync(db => db.Users.SingleAsync(u => u.Name == "carol"));
@@ -84,7 +84,7 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
var token = await IssueInviteAndExtractTokenAsync("dave@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("dave", "pw-1234", null, token));
new AuthEndpoints.RegisterRequest("dave", "pw-1234", "dave@example.com", null, token));
var (familyId, role) = await UseDbAsync(db =>
(from u in db.Users
@@ -104,11 +104,11 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
{
var token = await IssueInviteAndExtractTokenAsync("eve@example.com");
var first = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("eve", "pw-1234", null, token));
new AuthEndpoints.RegisterRequest("eve", "pw-1234", "eve@example.com", 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));
new AuthEndpoints.RegisterRequest("eve-2", "pw-1234", "eve@example.com", null, token));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
@@ -117,7 +117,7 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
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"));
new AuthEndpoints.RegisterRequest("fran", "pw-1234", "fran@example.com", null, "this-token-does-not-exist"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
@@ -135,7 +135,7 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
});
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("greg", "pw-1234", null, token));
new AuthEndpoints.RegisterRequest("greg", "pw-1234", "greg@example.com", null, token));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
@@ -144,7 +144,7 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
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"));
new AuthEndpoints.RegisterRequest("hank", "pw-1234", "hank@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
@@ -206,18 +206,13 @@ public class InviteEndpointsTests : AuthenticatedIntegrationTest
[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();
});
// The bootstrap admin (User) has an email assigned at registration
// time by TestDataFactory. Inviting that exact address must trip the
// same-email check.
var existing = await UseDbAsync(db => db.Users.Where(u => u.Id == User.Id).Select(u => u.Email).SingleAsync());
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("already@example.com"));
new InviteEndpoints.CreateInviteRequest(existing));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
@@ -46,9 +46,9 @@ public class PasswordResetTests : AuthenticatedIntegrationTest
[Test]
public async Task Forgot_password_does_not_email_unconfirmed_user()
{
// The default authed user (registered via family code) has no email,
// so they're ineligible. Attach an unconfirmed email directly to
// verify the EmailConfirmedAt requirement fires.
// The default authed user has an email (TestDataFactory always sets
// one) but registered via the family-code path, which does not set
// EmailConfirmedAt. Replace the address so we can target it explicitly.
await UseDbAsync(async db =>
{
var u = await db.Users.SingleAsync(x => x.Id == User.Id);
@@ -185,7 +185,7 @@ public class PasswordResetTests : AuthenticatedIntegrationTest
var inviteToken = ExtractInviteToken();
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest(name, "pw-1234", null, inviteToken));
new AuthEndpoints.RegisterRequest(name, "pw-1234", email, null, inviteToken));
register.EnsureSuccessStatusCode();
return (name, email);
}
@@ -49,7 +49,8 @@ public sealed class TestDataFactory(IntegrationTest test)
public async Task<AuthenticatedUser> RegisterAsync(string? name = null, string password = "correct-horse-battery-staple")
{
name ??= $"user-{Guid.NewGuid():N}"[..16];
var register = new AuthEndpoints.RegisterRequest(name, password, YesChefAppFactory.FamilyCode);
var email = $"{name}@example.test";
var register = new AuthEndpoints.RegisterRequest(name, password, email, YesChefAppFactory.FamilyCode);
var response = await test.AnonymousClient.PostAsJsonAsync("/api/auth/register", register);
response.EnsureSuccessStatusCode();
var auth = await response.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();