mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(service): registration commits the account, the invite use and the first session together (OC-0376)
CreateUserWithInvite committed the user and burned the invite; the session insert ran outside that transaction, so a store fault there answered 500 with a half-registered account — a retry got "invalid invite or credentials" while a login with the same password worked. Option B from the ledger: the session token is generated first and the session row is inserted inside the same transaction (db.insertSession through dbgen.Queries.WithTx; no query or migration change, so no sqlc regen). A fault at any step rolls the whole registration back and the caller simply retries. The H-6 cap needs no eviction for a user with no sessions. Characterization row flipped in the same commit: `RegisterPolicyAndFailurePaths/ session insert fails -> 500, nothing committed` — user row absent, invite use_count 0, message "registration failed — please try again". db tests pass the three new arguments; the happy-path test asserts the session row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo
This commit is contained in:
@@ -449,23 +449,22 @@ func TestAuthCharacterization_RegisterPolicyAndFailurePaths(t *testing.T) {
|
||||
t.Error("user row exists after a failed insert")
|
||||
}
|
||||
})
|
||||
t.Run("session insert fails after the user is committed -> 500", func(t *testing.T) {
|
||||
t.Run("session insert fails -> 500, nothing committed", func(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildAuthRouter(database, auth.NewRateLimiter())
|
||||
code := seedInvite(t, database)
|
||||
failWrite(t, database, "INSERT", "sessions")
|
||||
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
|
||||
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
|
||||
// known: the account and the invite use are already committed when the
|
||||
// session insert fails, so the caller sees a 500 for a registration
|
||||
// that succeeded — a retry gets "invalid invite or credentials" while
|
||||
// a login with the same password works (ledger OC-0376).
|
||||
if userByName(t, database, "fresh") == nil {
|
||||
t.Error("user row missing: this row pins the partial-success behaviour, which has changed")
|
||||
// OC-0376 (fixed in B3-9): the first session is inserted inside the
|
||||
// registration transaction, so a store fault leaves no half-registered
|
||||
// account and does not burn the invite — the caller simply retries.
|
||||
if userByName(t, database, "fresh") != nil {
|
||||
t.Error("user row exists after the session insert failed")
|
||||
}
|
||||
if n := inviteUseCount(t, database, code); n != 1 {
|
||||
t.Errorf("invite use_count = %d, want 1 (pinned partial success)", n)
|
||||
if n := inviteUseCount(t, database, code); n != 0 {
|
||||
t.Errorf("invite use_count = %d, want 0 (transaction rolled back)", n)
|
||||
}
|
||||
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "registration failed — please try again")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,13 @@ func (d *DB) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash stri
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// CreateUserWithInvite atomically consumes an invite and creates the user in
|
||||
// the same transaction so a failed registration does not burn the invite.
|
||||
func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) {
|
||||
// CreateUserWithInvite atomically consumes an invite, creates the user and
|
||||
// inserts the account's first session in one transaction, so a failure at any
|
||||
// step — the session insert included (OC-0376) — leaves no half-registered
|
||||
// account and does not burn the invite. sessionTokenHash must already be
|
||||
// hashed. The H-6 session cap needs no eviction here: the user has no sessions
|
||||
// yet.
|
||||
func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode, sessionTokenHash, device, ip string) (int64, error) {
|
||||
tx, err := d.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CreateUserWithInvite begin: %w", err)
|
||||
@@ -114,6 +118,9 @@ func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash st
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CreateUserWithInvite last insert id: %w", err)
|
||||
}
|
||||
if _, err := insertSession(ctx, d.q.WithTx(tx), uid, sessionTokenHash, device, ip); err != nil {
|
||||
return 0, fmt.Errorf("CreateUserWithInvite create session: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("CreateUserWithInvite commit: %w", err)
|
||||
}
|
||||
@@ -254,9 +261,15 @@ func (d *DB) CreateSession(ctx context.Context, userID int64, tokenHash, device,
|
||||
"user_id", userID, "err", err)
|
||||
}
|
||||
|
||||
return insertSession(ctx, d.q, userID, tokenHash, device, ip)
|
||||
}
|
||||
|
||||
// insertSession inserts one session row through q — d.q, or d.q.WithTx(tx)
|
||||
// when the row must commit with other writes (CreateUserWithInvite).
|
||||
func insertSession(ctx context.Context, q *dbgen.Queries, userID int64, tokenHash, device, ip string) (int64, error) {
|
||||
expiresAt := time.Now().Add(sessionTTL).UTC().Format(sessionTimeLayout)
|
||||
deviceCopy, ipCopy := device, ip
|
||||
res, err := d.q.InsertSession(ctx, dbgen.InsertSessionParams{
|
||||
res, err := q.InsertSession(ctx, dbgen.InsertSessionParams{
|
||||
UserID: userID,
|
||||
Token: tokenHash,
|
||||
Device: &deviceCopy,
|
||||
|
||||
@@ -756,7 +756,7 @@ func TestCreateUserWithInvite_Success(t *testing.T) {
|
||||
t.Fatalf("CreateInvite: %v", err)
|
||||
}
|
||||
|
||||
uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code)
|
||||
uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code, "sess-newuser", "test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithInvite: %v", err)
|
||||
}
|
||||
@@ -769,12 +769,16 @@ func TestCreateUserWithInvite_Success(t *testing.T) {
|
||||
if inv == nil || inv.Uses != 1 {
|
||||
t.Errorf("invite uses = %v, want 1", inv)
|
||||
}
|
||||
// The first session commits with the account (OC-0376).
|
||||
if sess, err := database.GetSessionByTokenHash(context.Background(), "sess-newuser"); err != nil || sess == nil || sess.UserID != uid {
|
||||
t.Errorf("session = %+v, %v; want a session for user %d", sess, err, uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserWithInvite_InvalidCode(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code")
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code", "sess-bad", "test", "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid invite code")
|
||||
}
|
||||
@@ -787,7 +791,7 @@ func TestCreateUserWithInvite_RevokedInvite(t *testing.T) {
|
||||
code, _ := database.CreateInvite(context.Background(), creatorID, 0, nil)
|
||||
_ = database.RevokeInvite(context.Background(), code)
|
||||
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code)
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code, "sess-revoked", "test", "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("expected error for revoked invite")
|
||||
}
|
||||
@@ -801,7 +805,7 @@ func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) {
|
||||
pastTime := time.Now().Add(-1 * time.Hour)
|
||||
code, _ := database.CreateInvite(context.Background(), creatorID, 0, &pastTime)
|
||||
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code)
|
||||
_, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code, "sess-expired", "test", "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("expected error for expired invite")
|
||||
}
|
||||
|
||||
+10
-9
@@ -296,9 +296,16 @@ func (s *AuthService) Register(ctx context.Context, in RegisterInput) (*AuthResu
|
||||
return nil, ErrPasswordHash
|
||||
}
|
||||
|
||||
// Atomically consume the invite and create the user so failed
|
||||
// registrations do not burn a valid invite code.
|
||||
uid, err := s.st.CreateUserWithInvite(ctx, in.Username, hash, int(permissions.MemberRoleID), in.InviteCode)
|
||||
// The session token exists before the transaction so the account, the
|
||||
// invite use and the first session commit together: a fault at any step
|
||||
// — the session insert included — rolls the whole registration back and
|
||||
// burns nothing (OC-0376).
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
return nil, ErrSessionIssue
|
||||
}
|
||||
uid, err := s.st.CreateUserWithInvite(ctx, in.Username, hash, int(permissions.MemberRoleID), in.InviteCode,
|
||||
auth.HashToken(token), in.Device, in.IP)
|
||||
if err != nil {
|
||||
// UNIQUE constraint violation → duplicate username → 400.
|
||||
// Any other DB error → 500.
|
||||
@@ -317,12 +324,6 @@ func (s *AuthService) Register(ctx context.Context, in RegisterInput) (*AuthResu
|
||||
db.WriteAudit(context.WithoutCancel(ctx), s.st, uid, "user_register", "user", uid,
|
||||
"new account created via invite")
|
||||
|
||||
// Issue session.
|
||||
token, err := issueSession(ctx, s.st, uid, in.Device, in.IP)
|
||||
if err != nil {
|
||||
return nil, ErrSessionIssue
|
||||
}
|
||||
|
||||
user, err := s.st.GetUserByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
slog.Error("failed to fetch user after registration", "user_id", uid, "error", err)
|
||||
|
||||
@@ -81,7 +81,7 @@ type Store interface {
|
||||
GetUserByUsername(ctx context.Context, username string) (*db.User, error)
|
||||
CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error)
|
||||
CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error)
|
||||
CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error)
|
||||
CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode, sessionTokenHash, device, ip string) (int64, error)
|
||||
UpdateUserProfile(ctx context.Context, userID int64, username string, avatar, displayName, about *string) error
|
||||
UpdateUserCustomStatus(ctx context.Context, userID int64, customStatus *string) error
|
||||
UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error
|
||||
|
||||
Reference in New Issue
Block a user