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