refactor: remove unused Pinia stores and related code
- Deleted the layout-store, profiles-store, and index.ts files from the Pinia store setup. - Removed the zod-sync script as it is no longer needed. - Cleaned up the tsconfig.json file by removing unnecessary references. - Updated the login logic in the profiles package to use polling for login results instead of in-process waiters. - Added validation methods for personal data and profile parameters. - Introduced new tests for validation logic in profiles and registration packages. - Enhanced email normalization and validation in the registration package.
This commit is contained in:
@@ -3,7 +3,6 @@ package profiles
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"encore.app/auth"
|
||||
@@ -34,12 +33,6 @@ var loginRequests = pubsub.TopicRef[pubsub.Publisher[*auth.LoginRequested]](auth
|
||||
// auth.LoginRequests) and waits for the matching auth.LoginCompleted reply
|
||||
// on auth.LoginResults before responding to the caller.
|
||||
//
|
||||
// NOTE: the wait is implemented with an in-process registry keyed by request
|
||||
// ID, so the instance that publishes the request must be the one that
|
||||
// receives the reply. That holds for local development (a single instance)
|
||||
// but not for a horizontally scaled deployment, which would need a shared
|
||||
// mechanism instead (e.g. a results table polled with retry_until).
|
||||
//
|
||||
//encore:api public method=POST path=/auth/login
|
||||
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
var userID uuid.UUID
|
||||
@@ -58,9 +51,6 @@ func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate request id")
|
||||
}
|
||||
|
||||
wait := loginWaiters.register(requestID)
|
||||
defer loginWaiters.cancel(requestID)
|
||||
|
||||
if _, err := loginRequests.Publish(ctx, &auth.LoginRequested{
|
||||
RequestID: requestID,
|
||||
UserID: userID,
|
||||
@@ -69,64 +59,47 @@ func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to publish login request")
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-wait:
|
||||
if !result.OK {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
result, err := pollLoginResult(ctx, func(ctx context.Context) (*auth.LoginCompleted, error) {
|
||||
var ok bool
|
||||
var token uuid.UUID
|
||||
var expiresAt time.Time
|
||||
err := authDB.QueryRow(ctx, `
|
||||
SELECT ok, token, expires_at FROM login_results WHERE request_id = $1
|
||||
`, requestID).Scan(&ok, &token, &expiresAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, sqldb.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to read login result")
|
||||
}
|
||||
return &auth.LoginCompleted{RequestID: requestID, OK: ok, Token: token, ExpiresAt: expiresAt}, nil
|
||||
}, loginTimeout, 50*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !result.OK {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
return &LoginResponse{Token: result.Token, ExpiresAt: result.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func pollLoginResult(ctx context.Context, fetch func(context.Context) (*auth.LoginCompleted, error), timeout, interval time.Duration) (*auth.LoginCompleted, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
result, err := fetch(ctx)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, &errs.Error{Code: errs.DeadlineExceeded, Message: "login timed out"}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
return &LoginResponse{Token: result.Token, ExpiresAt: result.ExpiresAt}, nil
|
||||
case <-time.After(loginTimeout):
|
||||
return nil, &errs.Error{Code: errs.DeadlineExceeded, Message: "login timed out"}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var _ = pubsub.NewSubscription(
|
||||
auth.LoginResults, "deliver-to-waiting-login",
|
||||
pubsub.SubscriptionConfig[*auth.LoginCompleted]{
|
||||
Handler: func(ctx context.Context, ev *auth.LoginCompleted) error {
|
||||
loginWaiters.deliver(ev)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// loginWaiters lets the synchronous Login handler above wait for the
|
||||
// asynchronous LoginCompleted reply that matches its RequestID.
|
||||
var loginWaiters = newWaiterRegistry()
|
||||
|
||||
type waiterRegistry struct {
|
||||
mu sync.Mutex
|
||||
waiting map[uuid.UUID]chan *auth.LoginCompleted
|
||||
}
|
||||
|
||||
func newWaiterRegistry() *waiterRegistry {
|
||||
return &waiterRegistry{waiting: make(map[uuid.UUID]chan *auth.LoginCompleted)}
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) register(id uuid.UUID) <-chan *auth.LoginCompleted {
|
||||
ch := make(chan *auth.LoginCompleted, 1)
|
||||
r.mu.Lock()
|
||||
r.waiting[id] = ch
|
||||
r.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) cancel(id uuid.UUID) {
|
||||
r.mu.Lock()
|
||||
delete(r.waiting, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *waiterRegistry) deliver(ev *auth.LoginCompleted) {
|
||||
r.mu.Lock()
|
||||
ch, ok := r.waiting[ev.RequestID]
|
||||
if ok {
|
||||
delete(r.waiting, ev.RequestID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
ch <- ev
|
||||
}
|
||||
}
|
||||
|
||||
65
profiles/login_test.go
Normal file
65
profiles/login_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"encore.app/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
func TestPollLoginResultReturnsValueWhenReady(t *testing.T) {
|
||||
requestID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("uuid.NewV4() failed: %v", err)
|
||||
}
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("uuid.NewV4() failed: %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
result, err := pollLoginResult(context.Background(), func(context.Context) (*auth.LoginCompleted, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return nil, sqldb.ErrNoRows
|
||||
}
|
||||
return &auth.LoginCompleted{
|
||||
RequestID: requestID,
|
||||
OK: true,
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}, nil
|
||||
}, 200*time.Millisecond, 10*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("pollLoginResult() returned error: %v", err)
|
||||
}
|
||||
if !result.OK {
|
||||
t.Fatal("expected successful login result")
|
||||
}
|
||||
if result.RequestID != requestID {
|
||||
t.Fatalf("request ID mismatch: got %v, want %v", result.RequestID, requestID)
|
||||
}
|
||||
if result.Token != token {
|
||||
t.Fatalf("token mismatch: got %v, want %v", result.Token, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollLoginResultTimesOut(t *testing.T) {
|
||||
_, err := pollLoginResult(context.Background(), func(context.Context) (*auth.LoginCompleted, error) {
|
||||
return nil, sqldb.ErrNoRows
|
||||
}, 50*time.Millisecond, 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
e, ok := err.(*errs.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.Error, got %T", err)
|
||||
}
|
||||
if e.Code != errs.DeadlineExceeded {
|
||||
t.Fatalf("expected deadline exceeded error, got %v", e.Code)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package profiles
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
@@ -35,6 +36,28 @@ type PersonalDataParams struct {
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func (p *PersonalDataParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "personal data params are required"}
|
||||
}
|
||||
p.FirstName = normalizeText(p.FirstName)
|
||||
p.LastName = normalizeText(p.LastName)
|
||||
p.Address = normalizeText(p.Address)
|
||||
p.City = normalizeText(p.City)
|
||||
p.Country = strings.ToUpper(strings.TrimSpace(p.Country))
|
||||
p.Cap = strings.TrimSpace(p.Cap)
|
||||
if p.FirstName == "" || p.LastName == "" || p.Address == "" || p.City == "" || p.Country == "" {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "all personal data fields are required"}
|
||||
}
|
||||
if len(p.Cap) < 4 || len(p.Cap) > 10 {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "postal code is invalid"}
|
||||
}
|
||||
if len(p.Country) != 2 {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "country must be a 2-letter code"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPersonalData returns the authenticated user's own personal data.
|
||||
//
|
||||
//encore:api auth method=GET path=/profiles/personal-data
|
||||
@@ -61,6 +84,9 @@ func GetPersonalData(ctx context.Context) (*PersonalData, error) {
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles/personal-data
|
||||
func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalData, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID, err := authedUserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,9 @@ package profiles
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
@@ -18,6 +21,8 @@ var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
var authDB = sqldb.Named("auth")
|
||||
|
||||
type Profile struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
@@ -42,11 +47,74 @@ type RegisterParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
func (p *RegisterParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "register params are required"}
|
||||
}
|
||||
email, err := normalizeEmailValue(p.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Email = email
|
||||
name := normalizeText(p.DisplayName)
|
||||
if name == "" {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "display name is required"}
|
||||
}
|
||||
p.DisplayName = name
|
||||
if p.AvatarURL != "" {
|
||||
if _, err := url.ParseRequestURI(p.AvatarURL); err != nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "avatar url is invalid"}
|
||||
}
|
||||
}
|
||||
return authsvc.ValidatePassword(p.Password)
|
||||
}
|
||||
|
||||
func (p *ProfileParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "profile params are required"}
|
||||
}
|
||||
name := normalizeText(p.DisplayName)
|
||||
if name == "" {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "display name is required"}
|
||||
}
|
||||
if p.AvatarURL != "" {
|
||||
if _, err := url.ParseRequestURI(p.AvatarURL); err != nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "avatar url is invalid"}
|
||||
}
|
||||
}
|
||||
p.DisplayName = name
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmailValue(email string) (string, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if email == "" {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"}
|
||||
}
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(parsed.Address)), nil
|
||||
}
|
||||
|
||||
func normalizeText(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Fields(value)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Insert registers a new profile with a server-generated UUID v4, delegating
|
||||
// credential setup to the auth service.
|
||||
//
|
||||
//encore:api public method=POST path=/profiles
|
||||
func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
|
||||
@@ -73,6 +141,9 @@ func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
||||
//
|
||||
//encore:api auth method=PUT path=/profiles
|
||||
func Update(ctx context.Context, p *ProfileParams) (*Profile, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
|
||||
@@ -161,6 +232,10 @@ func requireSelf(userID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProfileName(value string) string {
|
||||
return normalizeText(value)
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
36
profiles/validation_test.go
Normal file
36
profiles/validation_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package profiles
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProfileParamsValidate(t *testing.T) {
|
||||
if err := (&ProfileParams{DisplayName: "", AvatarURL: ""}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject empty display name")
|
||||
}
|
||||
if err := (&ProfileParams{DisplayName: " Alice ", AvatarURL: "https://example.com/avatar.png"}).Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterParamsValidate(t *testing.T) {
|
||||
if err := (&RegisterParams{Email: "", DisplayName: "Alice", Password: "validpass"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject empty email")
|
||||
}
|
||||
if err := (&RegisterParams{Email: "alice@example.com", DisplayName: "Alice", Password: "weak"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject weak password")
|
||||
}
|
||||
if err := (&RegisterParams{Email: " Alice@Example.com ", DisplayName: " Alice ", Password: "validpass"}).Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalDataParamsValidate(t *testing.T) {
|
||||
if err := (&PersonalDataParams{FirstName: "", LastName: "Rossi", Address: "Main 1", Cap: "20100", City: "Milan", Country: "IT"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject empty first name")
|
||||
}
|
||||
if err := (&PersonalDataParams{FirstName: "Mario", LastName: "Rossi", Address: "Main 1", Cap: "20", City: "Milan", Country: "IT"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject invalid cap")
|
||||
}
|
||||
if err := (&PersonalDataParams{FirstName: " Mario ", LastName: " Rossi ", Address: " Main 1 ", Cap: "20100", City: " Milan ", Country: " IT "}).Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user