package auth import ( "fmt" "os" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // TestAcceptInviteOrphanRecoversPasswordAndOwnsCompany covers the removed-member // re-invite path: zero active memberships may set a new password, and accept also // provisions a personal owned workspace alongside the invited company. func TestAcceptInviteOrphanRecoversPasswordAndOwnsCompany(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) svc := &Service{Pool: pg} prefix := uuid.New().String()[:8] inviteCompanyID := uuid.New() userID := uuid.New() email := fmt.Sprintf("orphan-invite-%s@example.test", prefix) oldHash, err := HashPassword("OldPassword123!") if err != nil { t.Fatalf("hash: %v", err) } _, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, inviteCompanyID, "Invite Target "+prefix) if err != nil { t.Fatalf("seed invite company: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, $4, false, false, true)`, userID, email, "Orphan", oldHash) if err != nil { t.Fatalf("seed user: %v", err) } // Inactive membership only — mimics remove-member. _, err = pg.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, 'member', 'inactive')`, inviteCompanyID, userID) if err != nil { t.Fatalf("seed inactive membership: %v", err) } mode, err := svc.InvitePasswordMode(ctx, email) if err != nil { t.Fatalf("InvitePasswordMode: %v", err) } if mode != InvitePasswordRecover { t.Fatalf("password mode=%q want %q", mode, InvitePasswordRecover) } inv, token, err := svc.CreateInvite(ctx, inviteCompanyID, userID, email, "member") if err != nil { t.Fatalf("CreateInvite: %v", err) } t.Cleanup(func() { cctx := t.Context() _, _ = pg.Exec(cctx, `DELETE FROM invites WHERE id = $1`, inv.ID) _, _ = pg.Exec(cctx, `DELETE FROM memberships WHERE user_id = $1`, userID) _, _ = pg.Exec(cctx, `DELETE FROM credit_balances WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID) _, _ = pg.Exec(cctx, `DELETE FROM company_settings WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID) _, _ = pg.Exec(cctx, `DELETE FROM company_plans WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID) _, _ = pg.Exec(cctx, `DELETE FROM companies WHERE owner_user_id = $1 OR id = $2`, userID, inviteCompanyID) _, _ = pg.Exec(cctx, `DELETE FROM users WHERE id = $1`, userID) }) res, err := svc.AcceptInvite(ctx, token, "NewPassword123!", "Orphan") if err != nil { t.Fatalf("AcceptInvite: %v", err) } if res.CompanyID != inviteCompanyID { t.Fatalf("active company=%s want invited %s", res.CompanyID, inviteCompanyID) } if len(res.ProvisionCompanyIDs) != 1 { t.Fatalf("expected one newly provisioned owned company, got %#v", res.ProvisionCompanyIDs) } ownedID := res.ProvisionCompanyIDs[0] if ownedID == inviteCompanyID { t.Fatal("owned company must differ from invited company") } var owner uuid.UUID if err := pg.QueryRow(ctx, `SELECT owner_user_id FROM companies WHERE id = $1`, ownedID).Scan(&owner); err != nil { t.Fatalf("owned company: %v", err) } if owner != userID { t.Fatalf("owner=%s want %s", owner, userID) } var activeCount int64 if err := pg.QueryRow(ctx, ` SELECT count(*) FROM memberships WHERE user_id = $1 AND status = 'active'`, userID).Scan(&activeCount); err != nil { t.Fatalf("active memberships: %v", err) } if activeCount < 2 { t.Fatalf("active memberships=%d want at least 2 (owned + invited)", activeCount) } if _, err := svc.Login(ctx, email, "NewPassword123!"); err != nil { t.Fatalf("login with new password: %v", err) } if _, err := svc.Login(ctx, email, "OldPassword123!"); err != ErrInvalidCredentials { t.Fatalf("old password should fail: err=%v", err) } }