From fea7348e5974dc250c61d2f097766ce629f48abf Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 12:55:27 +0200 Subject: [PATCH 01/54] feat(api): add ZLA models, PASession cache, and preauth config Signed-off-by: Maciek --- api/.env.sample | 5 +++ api/cache/cache.go | 6 ++-- api/cache/pasession.go | 55 ++++++++++++++++++++++++++++ api/cache/redis.go | 35 ------------------ api/config/config.go | 14 +++++--- api/model/pasession.go | 9 +++++ api/model/preauth.go | 13 +++++++ api/model/subscription.go | 76 +++++++++++++++++++++++++++++++++++---- 8 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 api/cache/pasession.go create mode 100644 api/model/pasession.go create mode 100644 api/model/preauth.go diff --git a/api/.env.sample b/api/.env.sample index 5e472a8c..5355502c 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -28,6 +28,11 @@ API_SIGNUP_WEBHOOK_URL="" API_SIGNUP_WEBHOOK_PSK="" PROFILE_ID_MIN_LENGTH=10 +### ZLA PRE-AUTH CONFIG +PREAUTH_URL= +PREAUTH_PSK= +PREAUTH_TTL=60m + ### DB CONFIG DB_URI="mongodb://mongodb:27017" DB_NAME="dns" diff --git a/api/cache/cache.go b/api/cache/cache.go index 2f33f2ee..e72411c9 100644 --- a/api/cache/cache.go +++ b/api/cache/cache.go @@ -25,9 +25,9 @@ type Cache interface { RemoveBlocklistsFromProfileSettings(ctx context.Context, profileId string, blocklistIds ...string) error AppendServicesBlockedToProfileSettings(ctx context.Context, profileId string, serviceIds ...string) error RemoveServicesBlockedFromProfileSettings(ctx context.Context, profileId string, serviceIds ...string) error - AddSubscription(ctx context.Context, subscriptionId string, activeUntil string, expiresIn time.Duration) error - GetSubscription(ctx context.Context, subscriptionId string) (string, error) - RemoveSubscription(ctx context.Context, subscriptionId string) error + AddPASession(ctx context.Context, session *model.PASession, expiresIn time.Duration) error + GetPASession(ctx context.Context, sessionID string) (*model.PASession, error) + RemovePASession(ctx context.Context, sessionID string) error } // NewCache creates a new BlocklistCache instance diff --git a/api/cache/pasession.go b/api/cache/pasession.go new file mode 100644 index 00000000..76f67c5f --- /dev/null +++ b/api/cache/pasession.go @@ -0,0 +1,55 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/ivpn/dns/api/model" + "github.com/rs/zerolog/log" +) + +// AddPASession stores a PASession in Redis with the given expiration. +// Key format: pasession:{sessionId} +func (c *RedisCache) AddPASession(ctx context.Context, session *model.PASession, expiresIn time.Duration) error { + key := fmt.Sprintf("pasession:%s", session.ID) + data, err := json.Marshal(session) + if err != nil { + log.Err(err).Str("key", key).Msg("Cache: failed to marshal PA session") + return err + } + if err := c.client.Set(ctx, key, string(data), expiresIn).Err(); err != nil { + log.Err(err).Str("key", key).Msg("Cache: failed to add PA session") + return err + } + log.Info().Str("key", key).Dur("expires_in", expiresIn).Msg("Cache: added PA session") + return nil +} + +// GetPASession retrieves a PASession from Redis by session ID. +func (c *RedisCache) GetPASession(ctx context.Context, sessionID string) (*model.PASession, error) { + key := fmt.Sprintf("pasession:%s", sessionID) + val, err := c.client.Get(ctx, key).Result() + if err != nil { + log.Err(err).Str("key", key).Msg("Cache: failed to get PA session") + return nil, err + } + var session model.PASession + if err := json.Unmarshal([]byte(val), &session); err != nil { + log.Err(err).Str("key", key).Msg("Cache: failed to unmarshal PA session") + return nil, err + } + return &session, nil +} + +// RemovePASession deletes a PASession from Redis. +func (c *RedisCache) RemovePASession(ctx context.Context, sessionID string) error { + key := fmt.Sprintf("pasession:%s", sessionID) + if err := c.client.Del(ctx, key).Err(); err != nil { + log.Err(err).Str("key", key).Msg("Cache: failed to remove PA session") + return err + } + log.Info().Str("key", key).Msg("Cache: removed PA session") + return nil +} diff --git a/api/cache/redis.go b/api/cache/redis.go index 1baf3121..a2663585 100644 --- a/api/cache/redis.go +++ b/api/cache/redis.go @@ -281,41 +281,6 @@ func (c *RedisCache) AddCustomRule(ctx context.Context, profileId string, custom return nil } -// AddSubscription sets a simple presence key for an account's subscription with expiration -// Key format: subscription: -func (c *RedisCache) AddSubscription(ctx context.Context, subscriptionId, activeUntil string, expiresIn time.Duration) error { - key := fmt.Sprintf("subscription:%s", subscriptionId) - setCmd := c.client.Set(ctx, key, activeUntil, expiresIn) - if err := setCmd.Err(); err != nil { - log.Err(err).Str("key", key).Msg("Cache: failed to add subscription key") - return err - } - log.Info().Str("key", key).Dur("expires_in", expiresIn).Msg("Cache: added subscription key") - return nil -} - -// GetSubscription retrieves the activeUntil value for a subscription from the cache -func (c *RedisCache) GetSubscription(ctx context.Context, subscriptionId string) (string, error) { - key := fmt.Sprintf("subscription:%s", subscriptionId) - getCmd := c.client.Get(ctx, key) - if err := getCmd.Err(); err != nil { - log.Err(err).Str("key", key).Msg("Cache: failed to get subscription key") - return "", err - } - log.Info().Str("key", key).Msg("Cache: retrieved subscription key") - return getCmd.Val(), nil -} - -func (c *RedisCache) RemoveSubscription(ctx context.Context, subscriptionId string) error { - key := fmt.Sprintf("subscription:%s", subscriptionId) - delCmd := c.client.Del(ctx, key) - if err := delCmd.Err(); err != nil { - log.Err(err).Str("key", key).Msg("Cache: failed to remove subscription key") - return err - } - log.Info().Str("key", key).Msg("Cache: removed subscription key") - return nil -} func (c *RedisCache) RemoveCustomRule(ctx context.Context, profileId, customRuleId string) error { customRuleHash := fmt.Sprintf("settings:%s:custom_rule:%s", profileId, customRuleId) diff --git a/api/config/config.go b/api/config/config.go index 869c1528..817d1933 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -46,7 +46,6 @@ type ServiceConfig struct { IdLimiterExpiration time.Duration MaxProfiles int MaxCredentials int - SubscriptionCacheExpiration time.Duration ServicesCatalogPath string ServicesCatalogReloadEvery time.Duration } @@ -82,6 +81,9 @@ type APIConfig struct { SignupWebhookURL string SignupWebhookPSK string DisableRateLimit bool + PreauthURL string + PreauthPSK string + PreauthTTL time.Duration } type EmailSenderConfig struct { @@ -114,8 +116,7 @@ func New() (*Config, error) { return nil, err } - // subscription cache expiration (used for AddSubscription endpoint) - subCacheExp, err := time.ParseDuration(envOrDefault("SUBSCRIPTION_CACHE_EXPIRATION", "15m")) + preauthTTL, err := time.ParseDuration(envOrDefault("PREAUTH_TTL", "60m")) if err != nil { return nil, err } @@ -174,6 +175,9 @@ func New() (*Config, error) { if os.Getenv("API_BASIC_AUTH_USER") == "" || os.Getenv("API_BASIC_AUTH_PASSWORD") == "" { log.Warn().Msg("API_BASIC_AUTH_USER or API_BASIC_AUTH_PASSWORD is not set; Swagger docs endpoint will be unprotected") } + if os.Getenv("PREAUTH_URL") == "" { + log.Warn().Msg("PREAUTH_URL is not set; ZLA pre-auth session flow will be unavailable") + } return &Config{ Server: &ServerConfig{ @@ -197,6 +201,9 @@ func New() (*Config, error) { SignupWebhookURL: os.Getenv("API_SIGNUP_WEBHOOK_URL"), SignupWebhookPSK: os.Getenv("API_SIGNUP_WEBHOOK_PSK"), DisableRateLimit: parseBoolEnv("API_DISABLE_RATE_LIMIT"), + PreauthURL: os.Getenv("PREAUTH_URL"), + PreauthPSK: os.Getenv("PREAUTH_PSK"), + PreauthTTL: preauthTTL, }, DB: &store.Config{ DbURI: os.Getenv("DB_URI"), @@ -244,7 +251,6 @@ func New() (*Config, error) { IdLimiterExpiration: idLimiterExpiration, MaxProfiles: maxProfiles, MaxCredentials: maxCredentials, - SubscriptionCacheExpiration: subCacheExp, ServicesCatalogPath: servicesCatalogPath, ServicesCatalogReloadEvery: servicesCatalogReloadEvery, }, diff --git a/api/model/pasession.go b/api/model/pasession.go new file mode 100644 index 00000000..ba0a2d7d --- /dev/null +++ b/api/model/pasession.go @@ -0,0 +1,9 @@ +package model + +// PASession represents a pre-auth session stored in Redis during the ZLA signup flow. +// It is created by the preauth service and consumed during account registration. +type PASession struct { + ID string `json:"id"` + Token string `json:"token"` + PreauthID string `json:"preauth_id"` +} diff --git a/api/model/preauth.go b/api/model/preauth.go new file mode 100644 index 00000000..941bdc87 --- /dev/null +++ b/api/model/preauth.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// Preauth represents an entry returned by the external preauth service. +// It contains subscription entitlement data validated via ZLA token hash. +type Preauth struct { + ID string `json:"id"` + TokenHash string `json:"token_hash"` + IsActive bool `json:"is_active"` + ActiveUntil time.Time `json:"active_until"` + Tier string `json:"tier"` +} diff --git a/api/model/subscription.go b/api/model/subscription.go index 9da28e08..54e6e669 100644 --- a/api/model/subscription.go +++ b/api/model/subscription.go @@ -1,31 +1,95 @@ package model import ( + "strings" "time" "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson/primitive" ) -type SubscriptionType string +// SubscriptionStatus represents the computed lifecycle state of a subscription. +type SubscriptionStatus string const ( - Free SubscriptionType = "Free" - Managed SubscriptionType = "Managed" + StatusActive SubscriptionStatus = "active" + StatusGracePeriod SubscriptionStatus = "grace_period" + StatusLimitedAccess SubscriptionStatus = "limited_access" + StatusPendingDelete SubscriptionStatus = "pending_delete" ) +const Tier1 = "Tier 1" + // Subscription represents a subscription with its properties type Subscription struct { // ID is the primary key (UUIDv4) stored in Mongo _id ID uuid.UUID `json:"-" bson:"_id"` AccountID primitive.ObjectID `json:"-" bson:"account_id"` - Type SubscriptionType `json:"type" bson:"type"` ActiveUntil time.Time `json:"active_until" bson:"active_until"` + IsActive bool `json:"-" bson:"is_active"` + Tier string `json:"tier,omitempty" bson:"tier,omitempty"` + TokenHash string `json:"-" bson:"token_hash,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` + Notified bool `json:"-" bson:"notified"` Limits SubscriptionLimits `json:"-" bson:"limits"` + + // Computed fields (not persisted) + Status SubscriptionStatus `json:"status" bson:"-"` + Outage bool `json:"outage" bson:"-"` +} + +// Active returns true when the subscription is valid: not expired, not Tier1, and no outage. +func (s *Subscription) Active() bool { + return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) && !s.IsOutage() +} + +// GracePeriod returns true during a sync outage when both 3-day grace windows still hold. +func (s *Subscription) GracePeriod() bool { + return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3) +} + +// LimitedAccess returns true when at least one 14-day grace period is still active. +func (s *Subscription) LimitedAccess() bool { + return s.GracePeriodDays(14) || s.OutageGracePeriodDays(14) +} + +// PendingDelete returns true when both 14-day grace periods have been exceeded. +func (s *Subscription) PendingDelete() bool { + return !s.GracePeriodDays(14) || !s.OutageGracePeriodDays(14) +} + +// ActiveStatus returns true when the subscription permits normal operations (Active or GracePeriod). +func (s *Subscription) ActiveStatus() bool { + return s.Active() || s.GracePeriod() +} + +// IsOutage returns true when the subscription hasn't been updated in over 48 hours. +func (s *Subscription) IsOutage() bool { + return s.UpdatedAt.Add(48 * time.Hour).Before(time.Now()) +} + +// GracePeriodDays returns true when ActiveUntil + days is still in the future. +func (s *Subscription) GracePeriodDays(days int) bool { + return s.ActiveUntil.AddDate(0, 0, days).After(time.Now()) +} + +// OutageGracePeriodDays returns true when UpdatedAt + days is still in the future. +func (s *Subscription) OutageGracePeriodDays(days int) bool { + return s.UpdatedAt.AddDate(0, 0, days).After(time.Now()) } -func (s *Subscription) IsActive() bool { - return s.ActiveUntil.After(time.Now()) +// GetStatus computes the current lifecycle status. +func (s *Subscription) GetStatus() SubscriptionStatus { + if s.Active() { + return StatusActive + } + if s.GracePeriod() { + return StatusGracePeriod + } + if s.LimitedAccess() { + return StatusLimitedAccess + } + return StatusPendingDelete } type SubscriptionLimits struct { From a46a9773e2d25ea7d75960cedc64c3e29238e62f Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:12:46 +0200 Subject: [PATCH 02/54] feat(api): implement ZLA PASession validation and registration flow Signed-off-by: Maciek --- api/internal/client/http.go | 30 ++++++- api/service/account/account.go | 101 +++++------------------ api/service/service.go | 16 ++-- api/service/subscription/service.go | 121 +++++++++++++++++++++------- api/service/webauthn.go | 4 +- 5 files changed, 150 insertions(+), 122 deletions(-) diff --git a/api/internal/client/http.go b/api/internal/client/http.go index e14f3593..695eef1e 100644 --- a/api/internal/client/http.go +++ b/api/internal/client/http.go @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/ivpn/dns/api/config" + "github.com/ivpn/dns/api/model" "github.com/rs/zerolog/log" ) @@ -27,7 +28,7 @@ func (h Http) SignupWebhook(subID string) error { req.Set("Content-Type", "application/json") req.Set("Accept", "application/json") req.Set("Authorization", "Bearer "+h.Cfg.SignupWebhookPSK) - body, _ := json.Marshal(map[string]string{"uuid": subID}) + body, _ := json.Marshal(map[string]string{"uuid": subID, "service": "dns"}) req.Body(body) status, _, err := req.Bytes() @@ -45,3 +46,30 @@ func (h Http) SignupWebhook(subID string) error { log.Debug().Msg("No signup webhook configured, skipping") return nil } + +// GetPreauth fetches a preauth entry from the external preauth service. +func (h Http) GetPreauth(id string) (model.Preauth, error) { + req := fiber.Get(h.Cfg.PreauthURL + "/" + id) + req.Set("Content-Type", "application/json") + req.Set("Accept", "application/json") + req.Set("Authorization", "Bearer "+h.Cfg.PreauthPSK) + + status, body, errs := req.Bytes() + if len(errs) > 0 { + log.Error().Interface("errors", errs).Msg("Error calling preauth service") + return model.Preauth{}, errors.New("error calling preauth service") + } + + if status != http.StatusOK { + log.Error().Int("status", status).Msg("Error calling preauth service") + return model.Preauth{}, errors.New("error response from preauth service") + } + + var preauth model.Preauth + if err := json.Unmarshal(body, &preauth); err != nil { + log.Error().Err(err).Msg("Error parsing preauth response") + return model.Preauth{}, errors.New("error parsing preauth response") + } + + return preauth, nil +} diff --git a/api/service/account/account.go b/api/service/account/account.go index dbbc225a..18cb972b 100644 --- a/api/service/account/account.go +++ b/api/service/account/account.go @@ -65,10 +65,10 @@ func NewAccountService(serviceCfg config.ServiceConfig, db repository.AccountRep } } -func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, email, password string, subscriptionID string) (*model.Account, error) { - // 1. Ensure subscription cache presence (activeUntil retrieval) - activeUntil, cacheErr := a.Cache.GetSubscription(ctx, subscriptionID) - if cacheErr != nil { +func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, email, password string, subscriptionID string, sessionID string) (*model.Account, error) { + // 1. Validate PASession and get preauth entry + preauth, err := a.SubscriptionService.ValidateAndGetPreauth(ctx, sessionID) + if err != nil { return nil, ErrUnableToCreateAccount } @@ -83,11 +83,9 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e if acc == nil { return false } - // Password present -> finished if acc.Password != nil { return true } - // Check passkey credentials count; treat errors as not finished (log only) if a.CredentialRepository != nil { count, err := a.CredentialRepository.GetCredentialsCount(ctx, acc.ID) if err != nil { @@ -108,8 +106,7 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e _, subErr := a.SubscriptionService.GetSubscription(ctx, existingAcc.ID.Hex()) if subErr != nil { if errors.Is(subErr, dbErrors.ErrSubscriptionNotFound) { - // create subscription now using previously validated cache activeUntil - createErr := a.SubscriptionService.CreateSubscription(ctx, existingAcc.ID.Hex(), subscriptionID, activeUntil) + createErr := a.SubscriptionService.CreateSubscriptionFromPreauth(ctx, existingAcc.ID.Hex(), preauth) if createErr != nil { return nil, ErrUnableToCreateAccount } @@ -117,7 +114,7 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e return nil, subErr } } - // If password provided and not yet set, set it and mark finished (derived) + // If password provided and not yet set, set it if existingAcc.Password == nil && strings.TrimSpace(password) != "" { if err := existingAcc.SetPassword(password); err != nil { return nil, err @@ -129,7 +126,7 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e log.Debug().Msg("Reusing unfinished account for registration - completing signup") if password != "" { - if err := a.CompleteRegistration(ctx, existingAcc, subscriptionID); err != nil { + if err := a.CompleteRegistration(ctx, existingAcc, subscriptionID, sessionID); err != nil { log.Error().Err(err).Str("subscription_id", subscriptionID).Msg("Failed to complete registration") return nil, err } @@ -137,22 +134,15 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e return existingAcc, nil } - // 3. Account does not exist - // Check if subscription UUID already exists (Scenario 2) - // Implement via repository method; if found -> unified failure - if subById, _ := a.SubscriptionService.GetSubscriptionById(ctx, subscriptionID); subById != nil { - return nil, ErrUnableToCreateAccount - } - - // 4. Proceed with new account creation (Scenario 3) - acc, regErr := a.RegisterAccountWithActiveUntil(ctx, email, password, subscriptionID, activeUntil) + // 3. Account does not exist — proceed with new account creation + acc, regErr := a.RegisterAccountWithPreauth(ctx, email, password, preauth) if regErr != nil { return nil, regErr } log.Debug().Msg("Created new account for registration - before webhook") if password != "" { - if err := a.CompleteRegistration(ctx, acc, subscriptionID); err != nil { + if err := a.CompleteRegistration(ctx, acc, subscriptionID, sessionID); err != nil { log.Error().Err(err).Str("subscription_id", subscriptionID).Msg("Failed to complete registration") return nil, err } @@ -160,18 +150,18 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e return acc, nil } -// CompleteRegistration finalizes registration steps for an account -// Sends signup webhook, removes subscription cache entry, sends welcome email -func (a *AccountService) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string) error { +// CompleteRegistration finalizes registration steps for an account. +// Sends signup webhook, removes PASession cache entry, sends welcome email. +func (a *AccountService) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error { err := a.Http.SignupWebhook(subscriptionID) if err != nil { log.Debug().Err(err).Str("subscription_id", subscriptionID).Msg("Failed to send signup webhook") } if err == nil { - // Remove subscription cache key (idempotent; ignore removal error as non-critical) - if err := a.Cache.RemoveSubscription(ctx, subscriptionID); err != nil { - log.Debug().Err(err).Str("subscription_id", subscriptionID).Msg("Failed to remove subscription cache entry") + // Remove PASession cache key (idempotent) + if rmErr := a.Cache.RemovePASession(ctx, sessionID); rmErr != nil { + log.Debug().Err(rmErr).Str("session_id", sessionID).Msg("Failed to remove PA session cache entry") } err = a.sendWelcomeEmail(ctx, account, account.Email) if err != nil { @@ -181,57 +171,6 @@ func (a *AccountService) CompleteRegistration(ctx context.Context, account *mode return err } -// RegisterAccount registers (creates) a new account -func (a *AccountService) RegisterAccount(ctx context.Context, email, passwordPlain, subscriptionID string) (*model.Account, error) { - activeUntil, err := a.Cache.GetSubscription(ctx, subscriptionID) - if err != nil { - return nil, err - } - - // check if given email is already registered - existingAcc, err := a.AccountRepository.GetAccountByEmail(ctx, email) - if err != nil { - if !errors.Is(err, dbErrors.ErrAccountNotFound) { - return nil, err - } - } - if existingAcc != nil { - return nil, ErrAccountAlreadyExists - } - - accountId := primitive.NewObjectID() - profile, err := a.ProfileService.CreateProfile(ctx, firstProfileName, accountId.Hex()) - if err != nil { - return nil, err - } - - acc, err := a.AccountRepository.CreateAccount(ctx, email, passwordPlain, accountId.Hex(), profile.ProfileId) - if err != nil { - return nil, err - } - - err = a.SubscriptionService.CreateSubscription(ctx, acc.ID.Hex(), subscriptionID, activeUntil) - if err != nil { - return nil, err - } - - if err = a.Cache.RemoveSubscription(ctx, subscriptionID); err != nil { - return nil, err - } - - // eg, _ := errgroup.WithContext(ctx) - // eg.Go(func() (err error) { - // return a.Mailer.Verify(email) - // }) - - err = a.sendWelcomeEmail(ctx, acc, email) - if err != nil { - return nil, err - } - - return acc, nil -} - func (a *AccountService) sendWelcomeEmail(ctx context.Context, acc *model.Account, email string) error { eg, _ := errgroup.WithContext(ctx) eg.Go(func() (err error) { return a.Mailer.Verify(email) }) @@ -251,8 +190,8 @@ func (a *AccountService) sendWelcomeEmail(ctx context.Context, acc *model.Accoun return nil } -// RegisterAccountWithActiveUntil is internal helper when activeUntil already retrieved & validated -func (a *AccountService) RegisterAccountWithActiveUntil(ctx context.Context, email, passwordPlain, subscriptionID, activeUntil string) (*model.Account, error) { +// RegisterAccountWithPreauth creates a new account with subscription from preauth data. +func (a *AccountService) RegisterAccountWithPreauth(ctx context.Context, email, passwordPlain string, preauth *model.Preauth) (*model.Account, error) { // check if given email is already registered (defensive re-check) existingAcc, err := a.AccountRepository.GetAccountByEmail(ctx, email) if err != nil && !errors.Is(err, dbErrors.ErrAccountNotFound) { @@ -268,8 +207,8 @@ func (a *AccountService) RegisterAccountWithActiveUntil(ctx context.Context, ema return nil, err } - // create subscription - if err = a.SubscriptionService.CreateSubscription(ctx, accountId.Hex(), subscriptionID, activeUntil); err != nil { + // create subscription from preauth data + if err = a.SubscriptionService.CreateSubscriptionFromPreauth(ctx, accountId.Hex(), preauth); err != nil { return nil, err } diff --git a/api/service/service.go b/api/service/service.go index b7bbce6c..78ef13a2 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -47,8 +47,8 @@ func New(cfg config.Config, store db.Db, cache cache.Cache, idGen idgen.Generato queryLogsSrv := querylogs.NewQueryLogsService(store) statsSrv := statistics.NewStatisticsService(store) profSrv := profile.NewProfileService(*cfg.Server, *cfg.Service, store, blocklistSrv, queryLogsSrv, statsSrv, cache, idGen, apiValidator.Validator) - subSrv := subscription.NewSubscriptionService(store, cache, *cfg.Service) httpClient := webhookClient.New(*cfg.API) + subSrv := subscription.NewSubscriptionService(store, cache, *cfg.Service, *cfg.API, *httpClient) accSrv := account.NewAccountService(*cfg.Service, store, profSrv, statsSrv, subSrv, store, cache, mailer, idGen, apiValidator.Validator, *httpClient) appleSrv := apple.NewAppleService(&cfg, cache, shortener) return Service{ @@ -86,7 +86,7 @@ type CredentialServicer interface { type PasskeyServicer interface { BeginRegistration(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error) - FinishRegistration(ctx context.Context, token string, httpReq *http.Request) error + FinishRegistration(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error BeginLogin(ctx context.Context, email string) (*protocol.CredentialAssertion, string, error) FinishLogin(ctx context.Context, token string, httpReq *http.Request, saveSession bool) (*model.Account, string, string, error) GetPasskeys(ctx context.Context, account *model.Account) ([]model.Credential, error) @@ -111,9 +111,8 @@ type AccountServicer interface { DeleteAccount(ctx context.Context, accountId string, req requests.AccountDeletionRequest, mfa *model.MfaData) error GenerateDeletionCode(ctx context.Context, accountId string) (*responses.DeletionCodeResponse, error) MfaCheck(ctx context.Context, acc *model.Account, mfa *model.MfaData) error - RegisterAccount(ctx context.Context, email, password, subID string) (*model.Account, error) - CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string) error - GetUnfinishedSignupOrPostAccount(ctx context.Context, email, password string, subscriptionID string) (*model.Account, error) + CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error + GetUnfinishedSignupOrPostAccount(ctx context.Context, email, password string, subscriptionID string, sessionID string) (*model.Account, error) SendResetPasswordEmail(ctx context.Context, email string) error VerifyPasswordReset(ctx context.Context, tokenValue, newPassword string, mfa *model.MfaData) error TotpEnable(ctx context.Context, accountId string) (*model.TOTPNew, error) @@ -173,8 +172,11 @@ type BlocklistServicer interface { type SubscriptionServicer interface { GetSubscription(ctx context.Context, accountId string) (*model.Subscription, error) UpdateSubscription(ctx context.Context, accountId string, updates []model.SubscriptionUpdate) (*model.Subscription, error) - CreateSubscription(ctx context.Context, accountId, subscriptionId, activeUntil string) error - AddSubscription(ctx context.Context, subscriptionId string, activeUntil string) error + CreateSubscriptionFromPreauth(ctx context.Context, accountId string, preauth *model.Preauth) error + AddPASession(ctx context.Context, session *model.PASession) error + RotatePASessionID(ctx context.Context, oldID string) (string, error) + ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) + GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) } // DeleteAccount deletes account with all connected data including sessions diff --git a/api/service/subscription/service.go b/api/service/subscription/service.go index 1f6ea048..f0329cdb 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -2,34 +2,49 @@ package subscription import ( "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" "errors" + "time" - "github.com/araddon/dateparse" "github.com/google/uuid" "github.com/ivpn/dns/api/cache" "github.com/ivpn/dns/api/config" dbErrors "github.com/ivpn/dns/api/db/errors" "github.com/ivpn/dns/api/db/repository" + "github.com/ivpn/dns/api/internal/client" "github.com/ivpn/dns/api/model" + "github.com/rs/zerolog/log" "go.mongodb.org/mongo-driver/bson/primitive" ) +var ( + ErrPASessionNotFound = errors.New("pre-auth session not found or expired") + ErrPANotFound = errors.New("pre-auth entry not found") + ErrTokenHashMismatch = errors.New("token validation failed") +) + type SubscriptionService struct { ServiceCfg config.ServiceConfig + APICfg config.APIConfig SubscriptionRepository repository.SubscriptionRepository Cache cache.Cache + Http client.Http } -// NewSubscriptionService creates a new blocklist service -func NewSubscriptionService(db repository.SubscriptionRepository, cache cache.Cache, cfg config.ServiceConfig) *SubscriptionService { +// NewSubscriptionService creates a new subscription service +func NewSubscriptionService(db repository.SubscriptionRepository, cache cache.Cache, srvCfg config.ServiceConfig, apiCfg config.APIConfig, http client.Http) *SubscriptionService { return &SubscriptionService{ SubscriptionRepository: db, Cache: cache, - ServiceCfg: cfg, + ServiceCfg: srvCfg, + APICfg: apiCfg, + Http: http, } } -// GetSubscription returns subscription data by account ID +// GetSubscription returns subscription data by account ID with computed status fields. func (s *SubscriptionService) GetSubscription(ctx context.Context, accountId string) (*model.Subscription, error) { subscription, err := s.SubscriptionRepository.GetSubscriptionByAccountId(ctx, accountId) if err != nil { @@ -39,10 +54,18 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, accountId str return nil, err } + subscription.Status = subscription.GetStatus() + subscription.Outage = subscription.IsOutage() + return subscription, nil } -// UpdateSubscription updates subscription data +// GetSubscriptionById returns subscription by its UUID. +func (s *SubscriptionService) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { + return s.SubscriptionRepository.GetSubscriptionById(ctx, subscriptionId) +} + +// UpdateSubscription updates subscription data. func (s *SubscriptionService) UpdateSubscription(ctx context.Context, accountId string, updates []model.SubscriptionUpdate) (*model.Subscription, error) { subscription, err := s.SubscriptionRepository.GetSubscriptionByAccountId(ctx, accountId) if err != nil { @@ -53,43 +76,79 @@ func (s *SubscriptionService) UpdateSubscription(ctx context.Context, accountId return subscription, err } -// CreateSubscription creates a new subscription for an account if one does not already exist -func (s *SubscriptionService) CreateSubscription(ctx context.Context, accountId, subscriptionId, activeUntil string) error { - // Parse account ObjectID +// CreateSubscriptionFromPreauth creates a new subscription using preauth entry data. +func (s *SubscriptionService) CreateSubscriptionFromPreauth(ctx context.Context, accountId string, preauth *model.Preauth) error { accOID, err := primitive.ObjectIDFromHex(accountId) if err != nil { return err } - // Parse activeUntil using flexible date parser (supports multiple timestamp formats) - activeUntilTime, err := dateparse.ParseAny(activeUntil) - if err != nil { - return err - } - - subUUID, err := uuid.Parse(subscriptionId) - if err != nil { - return err - } - subscription := model.Subscription{ - ID: subUUID, + sub := model.Subscription{ + ID: uuid.New(), AccountID: accOID, - Type: model.Managed, - ActiveUntil: activeUntilTime, + ActiveUntil: preauth.ActiveUntil, + IsActive: preauth.IsActive, + Tier: preauth.Tier, + TokenHash: preauth.TokenHash, + UpdatedAt: time.Now(), Limits: model.SubscriptionLimits{ - MaxQueriesPerMonth: 0, // default + MaxQueriesPerMonth: 0, }, } - return s.SubscriptionRepository.Create(ctx, subscription) + return s.SubscriptionRepository.Create(ctx, sub) } -// AddSubscription creates a new subscription and writes a cache marker with expiration -func (s *SubscriptionService) AddSubscription(ctx context.Context, subscriptionId, activeUntil string) error { - return s.Cache.AddSubscription(ctx, subscriptionId, activeUntil, s.ServiceCfg.SubscriptionCacheExpiration) +// AddPASession stores a PASession in cache. +func (s *SubscriptionService) AddPASession(ctx context.Context, session *model.PASession) error { + return s.Cache.AddPASession(ctx, session, s.APICfg.PreauthTTL) } -// GetSubscriptionById returns subscription by its UUID -func (s *SubscriptionService) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - return s.SubscriptionRepository.GetSubscriptionById(ctx, subscriptionId) +// RotatePASessionID atomically rotates a session ID: fetches old, creates new, deletes old. +func (s *SubscriptionService) RotatePASessionID(ctx context.Context, oldID string) (string, error) { + paSession, err := s.Cache.GetPASession(ctx, oldID) + if err != nil { + log.Debug().Err(err).Str("old_id", oldID).Msg("Failed to get PA session for rotation") + return "", ErrPASessionNotFound + } + + newID := uuid.NewString() + rotated := &model.PASession{ + ID: newID, + Token: paSession.Token, + PreauthID: paSession.PreauthID, + } + + if err := s.Cache.AddPASession(ctx, rotated, 15*time.Minute); err != nil { + return "", err + } + + if err := s.Cache.RemovePASession(ctx, oldID); err != nil { + log.Debug().Err(err).Str("old_id", oldID).Msg("Failed to delete old PA session after rotation") + } + + return newID, nil +} + +// ValidateAndGetPreauth validates the PASession token against the preauth service entry. +func (s *SubscriptionService) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { + paSession, err := s.Cache.GetPASession(ctx, sessionID) + if err != nil { + return nil, ErrPASessionNotFound + } + + preauth, err := s.Http.GetPreauth(paSession.PreauthID) + if err != nil { + return nil, ErrPANotFound + } + + tokenHash := sha256.Sum256([]byte(paSession.Token)) + tokenHashStr := base64.StdEncoding.EncodeToString(tokenHash[:]) + + if subtle.ConstantTimeCompare([]byte(tokenHashStr), []byte(preauth.TokenHash)) != 1 { + log.Warn().Str("session_id", sessionID).Msg("Token hash mismatch during PASession validation") + return nil, ErrTokenHashMismatch + } + + return &preauth, nil } diff --git a/api/service/webauthn.go b/api/service/webauthn.go index 6605470e..cfec01ca 100644 --- a/api/service/webauthn.go +++ b/api/service/webauthn.go @@ -49,7 +49,7 @@ func (s *Service) BeginRegistration(ctx context.Context, account *model.Account) } // FinishRegistration completes the WebAuthn registration process -func (s *Service) FinishRegistration(ctx context.Context, token string, httpReq *http.Request) error { +func (s *Service) FinishRegistration(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error { // Get session session, exists, err := s.GetSession(ctx, token) if err != nil || !exists { @@ -79,7 +79,7 @@ func (s *Service) FinishRegistration(ctx context.Context, token string, httpReq if err != nil { return fmt.Errorf("failed to get subscription ID for account: %w", err) } - if err = s.CompleteRegistration(ctx, account, sub.ID.String()); err != nil { + if err = s.CompleteRegistration(ctx, account, sub.ID.String(), paSessionID); err != nil { return fmt.Errorf("failed to complete registration: %w", err) } From caff7aeec1746935e6193905ea1fce57b560225b Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:29:23 +0200 Subject: [PATCH 03/54] feat(api): add PASession endpoints, remove /subscription/add Signed-off-by: Maciek --- api/api/accounts.go | 3 +- api/api/accounts_test.go | 10 +- api/api/pasession.go | 90 +++++++ api/api/requests/pasession.go | 13 + api/api/requests/subscription.go | 7 - api/api/server.go | 11 +- api/api/subscription.go | 45 ---- api/api/subscription_test.go | 69 +---- api/api/webauthn.go | 6 +- api/mocks/account_servicer.go | 132 +++------ api/mocks/asn1_object_pkcs7.go | 89 +++++++ api/mocks/cache_cache.go | 178 ++++++------- api/mocks/passkey_servicer.go | 22 +- api/mocks/servicer.go | 398 ++++++++++++++++++---------- api/mocks/subscription_servicer.go | 282 ++++++++++++++++---- api/service/account/reauth_test.go | 2 +- api/service/account/service_test.go | 293 +++++++------------- api/service/account/verify_test.go | 2 +- 18 files changed, 935 insertions(+), 717 deletions(-) create mode 100644 api/api/pasession.go create mode 100644 api/api/requests/pasession.go create mode 100644 api/mocks/asn1_object_pkcs7.go diff --git a/api/api/accounts.go b/api/api/accounts.go index 2852c7af..62d64dca 100644 --- a/api/api/accounts.go +++ b/api/api/accounts.go @@ -43,7 +43,8 @@ func (s *APIServer) registerAccount() fiber.Handler { return HandleError(c, ErrValidationFailed, "validation failed", tags...) } - _, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), p.Email, p.Password, p.SubID) + sessionID := c.Cookies(PASessionCookie) + _, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), p.Email, p.Password, p.SubID, sessionID) if err != nil { // Map specific service errors to unified user-facing failure if _, ok := err.(*account.ServiceAccountError); ok && err == account.ErrUnableToCreateAccount { diff --git a/api/api/accounts_test.go b/api/api/accounts_test.go index 553536f2..e3a93770 100644 --- a/api/api/accounts_test.go +++ b/api/api/accounts_test.go @@ -796,7 +796,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_SuccessNewAccount() { // Mock service returning newly created finished account acc := &model.Account{ID: primitive.NewObjectID(), Email: email, Password: &password} - suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(acc, nil) + suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(acc, nil) resp, err := suite.doRegister(email, password, subID) suite.Require().NoError(err) @@ -814,7 +814,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_SuccessUnfinishedReuse() // Unfinished account simulated by nil Password in returned account (service after reuse sets password internally) acc := &model.Account{ID: primitive.NewObjectID(), Email: email, Password: nil} - suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(acc, nil) + suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(acc, nil) resp, err := suite.doRegister(email, password, subID) suite.Require().NoError(err) @@ -830,7 +830,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorCacheMissing() { password := "StrongPass123!" subID := "550e8400-e29b-41d4-a716-446655440002" - suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount) + suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount) resp, err := suite.doRegister(email, password, subID) suite.Require().NoError(err) @@ -846,7 +846,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorSubscriptionDuplicat password := "StrongPass123!" subID := "550e8400-e29b-41d4-a716-446655440003" - suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount) + suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount) resp, err := suite.doRegister(email, password, subID) suite.Require().NoError(err) @@ -862,7 +862,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorFinishedAccountReuse password := "StrongPass123!" subID := "550e8400-e29b-41d4-a716-446655440004" - suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount) + suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount) resp, err := suite.doRegister(email, password, subID) suite.Require().NoError(err) diff --git a/api/api/pasession.go b/api/api/pasession.go new file mode 100644 index 00000000..11c4691f --- /dev/null +++ b/api/api/pasession.go @@ -0,0 +1,90 @@ +package api + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/ivpn/dns/api/api/requests" + "github.com/ivpn/dns/api/model" +) + +// PASessionCookie is the cookie name for the pre-auth session ID. +const PASessionCookie = "pa_session" + +// @Summary Add pre-auth session +// @Description Add a pre-auth session to cache (called by preauth service) +// @Tags PASession +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body requests.PASessionReq true "Pre-auth session request" +// @Success 200 {object} fiber.Map +// @Failure 400 {object} ErrResponse +// @Failure 401 {object} ErrResponse +// @Router /api/v1/pasession/add [post] +func (s *APIServer) addPASession() fiber.Handler { + return func(c *fiber.Ctx) error { + req := new(requests.PASessionReq) + if err := c.BodyParser(req); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, req, ErrInvalidRequestBody.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) + } + + session := &model.PASession{ + ID: req.ID, + Token: req.Token, + PreauthID: req.PreauthID, + } + + if err := s.Service.AddPASession(c.Context(), session); err != nil { + return HandleError(c, err, "failed to add pre-auth session") + } + + return c.Status(200).JSON(fiber.Map{"message": "pre-auth session added"}) + } +} + +// @Summary Rotate pre-auth session ID +// @Description Rotate pre-auth session ID and set new ID as cookie +// @Tags PASession +// @Accept json +// @Produce json +// @Param body body requests.RotatePASessionReq true "Rotate pre-auth session request" +// @Success 200 +// @Failure 400 {object} ErrResponse +// @Router /api/v1/pasession/rotate [put] +func (s *APIServer) rotatePASession() fiber.Handler { + return func(c *fiber.Ctx) error { + req := new(requests.RotatePASessionReq) + if err := c.BodyParser(req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) + } + + errMsgs := s.Validator.ValidateRequest(c, req, "") + if len(errMsgs) > 0 { + return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) + } + + newID, err := s.Service.RotatePASessionID(c.Context(), req.SessionID) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) + } + + c.Cookie(&fiber.Cookie{ + Name: PASessionCookie, + Value: newID, + HTTPOnly: true, + Secure: true, + SameSite: fiber.CookieSameSiteLaxMode, + MaxAge: 900, + Expires: time.Now().Add(15 * time.Minute), + }) + + return c.SendStatus(fiber.StatusOK) + } +} diff --git a/api/api/requests/pasession.go b/api/api/requests/pasession.go new file mode 100644 index 00000000..ad141087 --- /dev/null +++ b/api/api/requests/pasession.go @@ -0,0 +1,13 @@ +package requests + +// PASessionReq represents the request body for adding a pre-auth session. +type PASessionReq struct { + ID string `json:"id" validate:"required,uuid4"` + PreauthID string `json:"preauth_id" validate:"required,uuid4"` + Token string `json:"token" validate:"required"` +} + +// RotatePASessionReq represents the request body for rotating a pre-auth session ID. +type RotatePASessionReq struct { + SessionID string `json:"sessionid" validate:"required,uuid4"` +} diff --git a/api/api/requests/subscription.go b/api/api/requests/subscription.go index f3d2b6ec..22d4a7cf 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -6,10 +6,3 @@ type SubscriptionUpdates struct { Updates []model.SubscriptionUpdate `json:"updates" validate:"required,dive"` } -// SubscriptionReq represents a subscription creation request -// ActiveUntil is accepted as-is (no format validation at handler level) -type SubscriptionReq struct { - // ID is the external Subscription ID (UUIDv4) - ID string `json:"id" validate:"required,uuid4"` - ActiveUntil string `json:"active_until" validate:"required"` -} diff --git a/api/api/server.go b/api/api/server.go index 86b5d0ec..e736de70 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -109,10 +109,13 @@ func (s *APIServer) RegisterRoutes() { v1.Post("/login", middleware.NewLimit(10, 1*time.Minute), s.login()) - // PSK-provisioning subscription endpoint (outside v1 auth chain) - subscriptions := s.App.Group("/api/v1/subscription") - subscriptions.Use(middleware.NewPSK(*s.Config.API)) - subscriptions.Post("/add", middleware.NewLimit(10, 1*time.Minute), s.addSubscription()) + // PSK-protected PASession endpoint (outside v1 auth chain) + pasession := s.App.Group("/api/v1/pasession") + pasession.Use(middleware.NewPSK(*s.Config.API)) + pasession.Post("/add", middleware.NewLimit(10, 1*time.Minute), s.addPASession()) + + // Public PASession rotation endpoint + v1.Put("/pasession/rotate", middleware.NewLimit(10, 1*time.Minute), s.rotatePASession()) accounts := v1.Group("/accounts") profiles := v1.Group("/profiles") diff --git a/api/api/subscription.go b/api/api/subscription.go index 6298cc37..0abf4af5 100644 --- a/api/api/subscription.go +++ b/api/api/subscription.go @@ -1,56 +1,11 @@ package api import ( - "strings" - - "github.com/araddon/dateparse" "github.com/gofiber/fiber/v2" - "github.com/ivpn/dns/api/api/requests" "github.com/ivpn/dns/api/internal/auth" "github.com/ivpn/dns/api/model" - "github.com/rs/zerolog/log" ) -// @Summary Add subscription -// @Description Add subscription and cache its presence -// @Tags Subscription -// @Accept json -// @Produce json -// @Security ApiKeyAuth -// @Param body body requests.SubscriptionReq true "Subscription request" -// @Success 200 {object} fiber.Map -// @Failure 400 {object} ErrResponse -// @Failure 500 {object} ErrResponse -// @Router /api/v1/subscription/add [post] -func (s *APIServer) addSubscription() fiber.Handler { - handler := func(c *fiber.Ctx) error { - req := new(requests.SubscriptionReq) - if err := c.BodyParser(req); err != nil { - return HandleError(c, err, ErrInvalidRequestBody.Error()) - } - - // Validate request body - errMsgs := s.Validator.ValidateRequest(c, req, ErrInvalidRequestBody.Error()) - if len(errMsgs) > 0 { - return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) - } - - // Attempt flexible timestamp parse; on failure treat as invalid request body - if _, err := dateparse.ParseAny(req.ActiveUntil); err != nil { - log.Error().Err(err).Msg("invalid active_until timestamp") - return HandleError(c, ErrInvalidRequestBody, "invalid active_until timestamp") - } - - // Add subscription info in cache via service - if err := s.Service.AddSubscription(c.Context(), req.ID, req.ActiveUntil); err != nil { - return HandleError(c, err, "failed to add subscription") - } - - return c.Status(200).JSON(fiber.Map{"message": "subscription added"}) - } - return handler -} - // reference model.Subscription to satisfy import for swagger annotations var _ model.Subscription diff --git a/api/api/subscription_test.go b/api/api/subscription_test.go index 506673d8..cabb4a1c 100644 --- a/api/api/subscription_test.go +++ b/api/api/subscription_test.go @@ -1,19 +1,14 @@ package api import ( - "bytes" - "encoding/json" "net/http" "net/http/httptest" "testing" - "time" - "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" - "github.com/ivpn/dns/api/api/requests" "github.com/ivpn/dns/api/config" dbErrors "github.com/ivpn/dns/api/db/errors" "github.com/ivpn/dns/api/internal/auth" @@ -43,7 +38,7 @@ func (suite *SubscriptionAPITestSuite) SetupSuite() { PSK: "test-psk-token", }, Server: &config.ServerConfig{Name: "modDNS Test", FQDN: "test.local"}, - Service: &config.ServiceConfig{SubscriptionCacheExpiration: 15 * time.Minute}, + Service: &config.ServiceConfig{}, } } @@ -80,72 +75,12 @@ func (suite *SubscriptionAPITestSuite) createTestServer() *APIServer { return server } -func (suite *SubscriptionAPITestSuite) TestAddSubscription_Success() { - subscriptionID := "550e8400-e29b-41d4-a716-446655440000" // valid UUIDv4 - activeUntil := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) - suite.mockService.On("AddSubscription", mock.Anything, subscriptionID, activeUntil).Return(nil) - payload := requests.SubscriptionReq{ID: subscriptionID, ActiveUntil: activeUntil} - body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/subscription/add", bytes.NewReader(body)) - req.Header.Set("Content-Type", fiber.MIMEApplicationJSON) - req.Header.Set("Authorization", "Bearer "+suite.config.API.PSK) - // Also set auth cookie as fallback for PSK middleware token extraction - req.AddCookie(&http.Cookie{Name: auth.AUTH_COOKIE, Value: suite.config.API.PSK}) - server := suite.createTestServer() - resp, err := server.App.Test(req, -1) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) -} - -func (suite *SubscriptionAPITestSuite) TestAddSubscription_InvalidID() { - payload := requests.SubscriptionReq{ID: "not-a-uuid", ActiveUntil: time.Now().UTC().Format(time.RFC3339)} - body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/subscription/add", bytes.NewReader(body)) - req.Header.Set("Content-Type", fiber.MIMEApplicationJSON) - req.Header.Set("Authorization", "Bearer "+suite.config.API.PSK) - req.AddCookie(&http.Cookie{Name: auth.AUTH_COOKIE, Value: suite.config.API.PSK}) - server := suite.createTestServer() - resp, err := server.App.Test(req, -1) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) -} - -func (suite *SubscriptionAPITestSuite) TestAddSubscription_InvalidTimestamp() { - // Provide an obviously invalid timestamp; handler should return 400 without calling service - subscriptionID := "550e8400-e29b-41d4-a716-446655440000" - payload := requests.SubscriptionReq{ID: subscriptionID, ActiveUntil: "not-a-date"} - body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/subscription/add", bytes.NewReader(body)) - req.Header.Set("Content-Type", fiber.MIMEApplicationJSON) - req.Header.Set("Authorization", "Bearer "+suite.config.API.PSK) - req.AddCookie(&http.Cookie{Name: auth.AUTH_COOKIE, Value: suite.config.API.PSK}) - server := suite.createTestServer() - resp, err := server.App.Test(req, -1) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) -} - -func (suite *SubscriptionAPITestSuite) TestAddSubscription_InvalidBody() { - // malformed JSON: active_until should be string, we pass number - body := []byte(`{"id": 123, "active_until": 456}`) - req := httptest.NewRequest(http.MethodPost, "/api/v1/subscription/add", bytes.NewReader(body)) - req.Header.Set("Content-Type", fiber.MIMEApplicationJSON) - req.Header.Set("Authorization", "Bearer "+suite.config.API.PSK) - req.AddCookie(&http.Cookie{Name: auth.AUTH_COOKIE, Value: suite.config.API.PSK}) - server := suite.createTestServer() - resp, err := server.App.Test(req, -1) - assert.NoError(suite.T(), err) - assert.True(suite.T(), resp.StatusCode >= 400) -} - -// --- New GET /api/v1/subscription endpoint tests --- - func (suite *SubscriptionAPITestSuite) TestGetSubscription_Success() { accountID := "507f1f77bcf86cd799439011" sessionToken := "test-session-token" // Auth middleware requires a valid session cookie; mock session retrieval suite.mockDB.On("GetSession", mock.Anything, sessionToken).Return(model.Session{AccountID: accountID}, true, nil) - sub := &model.Subscription{Type: model.Managed} + sub := &model.Subscription{} // Mock subscription service call suite.mockService.On("GetSubscription", mock.Anything, accountID).Return(sub, nil) diff --git a/api/api/webauthn.go b/api/api/webauthn.go index 68cce02d..7b37f602 100644 --- a/api/api/webauthn.go +++ b/api/api/webauthn.go @@ -58,7 +58,8 @@ func (s *APIServer) beginRegistration() fiber.Handler { return HandleError(c, ErrValidationFailed, "validation failed", tags...) } - acc, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), req.Email, "", req.SubID) + sessionID := c.Cookies(PASessionCookie) + acc, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), req.Email, "", req.SubID, sessionID) if err != nil { return HandleError(c, err, ErrFailedToRegisterAccount.Error()) } @@ -109,7 +110,8 @@ func (s *APIServer) finishRegistration() fiber.Handler { }) } - if err = s.Service.FinishRegistration(c.Context(), token, httpReq); err != nil { + paSessionID := c.Cookies(PASessionCookie) + if err = s.Service.FinishRegistration(c.Context(), token, httpReq, paSessionID); err != nil { return HandleError(c, err, "Failed to finish registration") } diff --git a/api/mocks/account_servicer.go b/api/mocks/account_servicer.go index 837528aa..c579aaf2 100644 --- a/api/mocks/account_servicer.go +++ b/api/mocks/account_servicer.go @@ -41,16 +41,16 @@ func (_m *AccountServicer) EXPECT() *AccountServicer_Expecter { } // CompleteRegistration provides a mock function for the type AccountServicer -func (_mock *AccountServicer) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string) error { - ret := _mock.Called(ctx, account, subscriptionID) +func (_mock *AccountServicer) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error { + ret := _mock.Called(ctx, account, subscriptionID, sessionID) if len(ret) == 0 { panic("no return value specified for CompleteRegistration") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) error); ok { - r0 = returnFunc(ctx, account, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string, string) error); ok { + r0 = returnFunc(ctx, account, subscriptionID, sessionID) } else { r0 = ret.Error(0) } @@ -66,11 +66,12 @@ type AccountServicer_CompleteRegistration_Call struct { // - ctx context.Context // - account *model.Account // - subscriptionID string -func (_e *AccountServicer_Expecter) CompleteRegistration(ctx interface{}, account interface{}, subscriptionID interface{}) *AccountServicer_CompleteRegistration_Call { - return &AccountServicer_CompleteRegistration_Call{Call: _e.mock.On("CompleteRegistration", ctx, account, subscriptionID)} +// - sessionID string +func (_e *AccountServicer_Expecter) CompleteRegistration(ctx interface{}, account interface{}, subscriptionID interface{}, sessionID interface{}) *AccountServicer_CompleteRegistration_Call { + return &AccountServicer_CompleteRegistration_Call{Call: _e.mock.On("CompleteRegistration", ctx, account, subscriptionID, sessionID)} } -func (_c *AccountServicer_CompleteRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subscriptionID string)) *AccountServicer_CompleteRegistration_Call { +func (_c *AccountServicer_CompleteRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subscriptionID string, sessionID string)) *AccountServicer_CompleteRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -84,10 +85,15 @@ func (_c *AccountServicer_CompleteRegistration_Call) Run(run func(ctx context.Co if args[2] != nil { arg2 = args[2].(string) } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -98,7 +104,7 @@ func (_c *AccountServicer_CompleteRegistration_Call) Return(err error) *AccountS return _c } -func (_c *AccountServicer_CompleteRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subscriptionID string) error) *AccountServicer_CompleteRegistration_Call { +func (_c *AccountServicer_CompleteRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error) *AccountServicer_CompleteRegistration_Call { _c.Call.Return(run) return _c } @@ -383,8 +389,8 @@ func (_c *AccountServicer_GetAccountMetrics_Call) RunAndReturn(run func(ctx cont } // GetUnfinishedSignupOrPostAccount provides a mock function for the type AccountServicer -func (_mock *AccountServicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string) (*model.Account, error) { - ret := _mock.Called(ctx, email, password, subscriptionID) +func (_mock *AccountServicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error) { + ret := _mock.Called(ctx, email, password, subscriptionID, sessionID) if len(ret) == 0 { panic("no return value specified for GetUnfinishedSignupOrPostAccount") @@ -392,18 +398,18 @@ func (_mock *AccountServicer) GetUnfinishedSignupOrPostAccount(ctx context.Conte var r0 *model.Account var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*model.Account, error)); ok { - return returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*model.Account, error)); ok { + return returnFunc(ctx, email, password, subscriptionID, sessionID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *model.Account); ok { - r0 = returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *model.Account); ok { + r0 = returnFunc(ctx, email, password, subscriptionID, sessionID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Account) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { + r1 = returnFunc(ctx, email, password, subscriptionID, sessionID) } else { r1 = ret.Error(1) } @@ -420,11 +426,12 @@ type AccountServicer_GetUnfinishedSignupOrPostAccount_Call struct { // - email string // - password string // - subscriptionID string -func (_e *AccountServicer_Expecter) GetUnfinishedSignupOrPostAccount(ctx interface{}, email interface{}, password interface{}, subscriptionID interface{}) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { - return &AccountServicer_GetUnfinishedSignupOrPostAccount_Call{Call: _e.mock.On("GetUnfinishedSignupOrPostAccount", ctx, email, password, subscriptionID)} +// - sessionID string +func (_e *AccountServicer_Expecter) GetUnfinishedSignupOrPostAccount(ctx interface{}, email interface{}, password interface{}, subscriptionID interface{}, sessionID interface{}) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { + return &AccountServicer_GetUnfinishedSignupOrPostAccount_Call{Call: _e.mock.On("GetUnfinishedSignupOrPostAccount", ctx, email, password, subscriptionID, sessionID)} } -func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ctx context.Context, email string, password string, subscriptionID string)) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { +func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ctx context.Context, email string, password string, subscriptionID string, sessionID string)) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -442,11 +449,16 @@ func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ct if args[3] != nil { arg3 = args[3].(string) } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -457,7 +469,7 @@ func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) Return(account return _c } -func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subscriptionID string) (*model.Account, error)) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { +func (_c *AccountServicer_GetUnfinishedSignupOrPostAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error)) *AccountServicer_GetUnfinishedSignupOrPostAccount_Call { _c.Call.Return(run) return _c } @@ -525,86 +537,6 @@ func (_c *AccountServicer_MfaCheck_Call) RunAndReturn(run func(ctx context.Conte return _c } -// RegisterAccount provides a mock function for the type AccountServicer -func (_mock *AccountServicer) RegisterAccount(ctx context.Context, email string, password string, subID string) (*model.Account, error) { - ret := _mock.Called(ctx, email, password, subID) - - if len(ret) == 0 { - panic("no return value specified for RegisterAccount") - } - - var r0 *model.Account - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*model.Account, error)); ok { - return returnFunc(ctx, email, password, subID) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *model.Account); ok { - r0 = returnFunc(ctx, email, password, subID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Account) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = returnFunc(ctx, email, password, subID) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// AccountServicer_RegisterAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterAccount' -type AccountServicer_RegisterAccount_Call struct { - *mock.Call -} - -// RegisterAccount is a helper method to define mock.On call -// - ctx context.Context -// - email string -// - password string -// - subID string -func (_e *AccountServicer_Expecter) RegisterAccount(ctx interface{}, email interface{}, password interface{}, subID interface{}) *AccountServicer_RegisterAccount_Call { - return &AccountServicer_RegisterAccount_Call{Call: _e.mock.On("RegisterAccount", ctx, email, password, subID)} -} - -func (_c *AccountServicer_RegisterAccount_Call) Run(run func(ctx context.Context, email string, password string, subID string)) *AccountServicer_RegisterAccount_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) - } - run( - arg0, - arg1, - arg2, - arg3, - ) - }) - return _c -} - -func (_c *AccountServicer_RegisterAccount_Call) Return(account *model.Account, err error) *AccountServicer_RegisterAccount_Call { - _c.Call.Return(account, err) - return _c -} - -func (_c *AccountServicer_RegisterAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subID string) (*model.Account, error)) *AccountServicer_RegisterAccount_Call { - _c.Call.Return(run) - return _c -} - // RequestEmailVerificationOTP provides a mock function for the type AccountServicer func (_mock *AccountServicer) RequestEmailVerificationOTP(ctx context.Context, accountId string) error { ret := _mock.Called(ctx, accountId) diff --git a/api/mocks/asn1_object_pkcs7.go b/api/mocks/asn1_object_pkcs7.go new file mode 100644 index 00000000..87992997 --- /dev/null +++ b/api/mocks/asn1_object_pkcs7.go @@ -0,0 +1,89 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "bytes" + + mock "github.com/stretchr/testify/mock" +) + +// newAsn1Objectpkcs7 creates a new instance of asn1Objectpkcs7. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newAsn1Objectpkcs7(t interface { + mock.TestingT + Cleanup(func()) +}) *asn1Objectpkcs7 { + mock := &asn1Objectpkcs7{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// asn1Objectpkcs7 is an autogenerated mock type for the asn1Object type +type asn1Objectpkcs7 struct { + mock.Mock +} + +type asn1Objectpkcs7_Expecter struct { + mock *mock.Mock +} + +func (_m *asn1Objectpkcs7) EXPECT() *asn1Objectpkcs7_Expecter { + return &asn1Objectpkcs7_Expecter{mock: &_m.Mock} +} + +// EncodeTo provides a mock function for the type asn1Objectpkcs7 +func (_mock *asn1Objectpkcs7) EncodeTo(writer *bytes.Buffer) error { + ret := _mock.Called(writer) + + if len(ret) == 0 { + panic("no return value specified for EncodeTo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*bytes.Buffer) error); ok { + r0 = returnFunc(writer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// asn1Objectpkcs7_EncodeTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EncodeTo' +type asn1Objectpkcs7_EncodeTo_Call struct { + *mock.Call +} + +// EncodeTo is a helper method to define mock.On call +// - writer *bytes.Buffer +func (_e *asn1Objectpkcs7_Expecter) EncodeTo(writer interface{}) *asn1Objectpkcs7_EncodeTo_Call { + return &asn1Objectpkcs7_EncodeTo_Call{Call: _e.mock.On("EncodeTo", writer)} +} + +func (_c *asn1Objectpkcs7_EncodeTo_Call) Run(run func(writer *bytes.Buffer)) *asn1Objectpkcs7_EncodeTo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *bytes.Buffer + if args[0] != nil { + arg0 = args[0].(*bytes.Buffer) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *asn1Objectpkcs7_EncodeTo_Call) Return(err error) *asn1Objectpkcs7_EncodeTo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *asn1Objectpkcs7_EncodeTo_Call) RunAndReturn(run func(writer *bytes.Buffer) error) *asn1Objectpkcs7_EncodeTo_Call { + _c.Call.Return(run) + return _c +} diff --git a/api/mocks/cache_cache.go b/api/mocks/cache_cache.go index 6693ff06..81ac9cf6 100644 --- a/api/mocks/cache_cache.go +++ b/api/mocks/cache_cache.go @@ -165,71 +165,65 @@ func (_c *Cachecache_AddCustomRule_Call) RunAndReturn(run func(ctx context.Conte return _c } -// AddSubscription provides a mock function for the type Cachecache -func (_mock *Cachecache) AddSubscription(ctx context.Context, subscriptionId string, activeUntil string, expiresIn time.Duration) error { - ret := _mock.Called(ctx, subscriptionId, activeUntil, expiresIn) +// AddPASession provides a mock function for the type Cachecache +func (_mock *Cachecache) AddPASession(ctx context.Context, session *model.PASession, expiresIn time.Duration) error { + ret := _mock.Called(ctx, session, expiresIn) if len(ret) == 0 { - panic("no return value specified for AddSubscription") + panic("no return value specified for AddPASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, time.Duration) error); ok { - r0 = returnFunc(ctx, subscriptionId, activeUntil, expiresIn) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.PASession, time.Duration) error); ok { + r0 = returnFunc(ctx, session, expiresIn) } else { r0 = ret.Error(0) } return r0 } -// Cachecache_AddSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddSubscription' -type Cachecache_AddSubscription_Call struct { +// Cachecache_AddPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPASession' +type Cachecache_AddPASession_Call struct { *mock.Call } -// AddSubscription is a helper method to define mock.On call +// AddPASession is a helper method to define mock.On call // - ctx context.Context -// - subscriptionId string -// - activeUntil string +// - session *model.PASession // - expiresIn time.Duration -func (_e *Cachecache_Expecter) AddSubscription(ctx interface{}, subscriptionId interface{}, activeUntil interface{}, expiresIn interface{}) *Cachecache_AddSubscription_Call { - return &Cachecache_AddSubscription_Call{Call: _e.mock.On("AddSubscription", ctx, subscriptionId, activeUntil, expiresIn)} +func (_e *Cachecache_Expecter) AddPASession(ctx interface{}, session interface{}, expiresIn interface{}) *Cachecache_AddPASession_Call { + return &Cachecache_AddPASession_Call{Call: _e.mock.On("AddPASession", ctx, session, expiresIn)} } -func (_c *Cachecache_AddSubscription_Call) Run(run func(ctx context.Context, subscriptionId string, activeUntil string, expiresIn time.Duration)) *Cachecache_AddSubscription_Call { +func (_c *Cachecache_AddPASession_Call) Run(run func(ctx context.Context, session *model.PASession, expiresIn time.Duration)) *Cachecache_AddPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 *model.PASession if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(*model.PASession) } - var arg2 string + var arg2 time.Duration if args[2] != nil { - arg2 = args[2].(string) - } - var arg3 time.Duration - if args[3] != nil { - arg3 = args[3].(time.Duration) + arg2 = args[2].(time.Duration) } run( arg0, arg1, arg2, - arg3, ) }) return _c } -func (_c *Cachecache_AddSubscription_Call) Return(err error) *Cachecache_AddSubscription_Call { +func (_c *Cachecache_AddPASession_Call) Return(err error) *Cachecache_AddPASession_Call { _c.Call.Return(err) return _c } -func (_c *Cachecache_AddSubscription_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string, activeUntil string, expiresIn time.Duration) error) *Cachecache_AddSubscription_Call { +func (_c *Cachecache_AddPASession_Call) RunAndReturn(run func(ctx context.Context, session *model.PASession, expiresIn time.Duration) error) *Cachecache_AddPASession_Call { _c.Call.Return(run) return _c } @@ -621,45 +615,47 @@ func (_c *Cachecache_Get_Call) RunAndReturn(run func(context1 context.Context, s return _c } -// GetSubscription provides a mock function for the type Cachecache -func (_mock *Cachecache) GetSubscription(ctx context.Context, subscriptionId string) (string, error) { - ret := _mock.Called(ctx, subscriptionId) +// GetPASession provides a mock function for the type Cachecache +func (_mock *Cachecache) GetPASession(ctx context.Context, sessionID string) (*model.PASession, error) { + ret := _mock.Called(ctx, sessionID) if len(ret) == 0 { - panic("no return value specified for GetSubscription") + panic("no return value specified for GetPASession") } - var r0 string + var r0 *model.PASession var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { - return returnFunc(ctx, subscriptionId) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.PASession, error)); ok { + return returnFunc(ctx, sessionID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { - r0 = returnFunc(ctx, subscriptionId) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.PASession); ok { + r0 = returnFunc(ctx, sessionID) } else { - r0 = ret.Get(0).(string) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PASession) + } } if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) + r1 = returnFunc(ctx, sessionID) } else { r1 = ret.Error(1) } return r0, r1 } -// Cachecache_GetSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscription' -type Cachecache_GetSubscription_Call struct { +// Cachecache_GetPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPASession' +type Cachecache_GetPASession_Call struct { *mock.Call } -// GetSubscription is a helper method to define mock.On call +// GetPASession is a helper method to define mock.On call // - ctx context.Context -// - subscriptionId string -func (_e *Cachecache_Expecter) GetSubscription(ctx interface{}, subscriptionId interface{}) *Cachecache_GetSubscription_Call { - return &Cachecache_GetSubscription_Call{Call: _e.mock.On("GetSubscription", ctx, subscriptionId)} +// - sessionID string +func (_e *Cachecache_Expecter) GetPASession(ctx interface{}, sessionID interface{}) *Cachecache_GetPASession_Call { + return &Cachecache_GetPASession_Call{Call: _e.mock.On("GetPASession", ctx, sessionID)} } -func (_c *Cachecache_GetSubscription_Call) Run(run func(ctx context.Context, subscriptionId string)) *Cachecache_GetSubscription_Call { +func (_c *Cachecache_GetPASession_Call) Run(run func(ctx context.Context, sessionID string)) *Cachecache_GetPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -677,12 +673,12 @@ func (_c *Cachecache_GetSubscription_Call) Run(run func(ctx context.Context, sub return _c } -func (_c *Cachecache_GetSubscription_Call) Return(s string, err error) *Cachecache_GetSubscription_Call { - _c.Call.Return(s, err) +func (_c *Cachecache_GetPASession_Call) Return(pASession *model.PASession, err error) *Cachecache_GetPASession_Call { + _c.Call.Return(pASession, err) return _c } -func (_c *Cachecache_GetSubscription_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (string, error)) *Cachecache_GetSubscription_Call { +func (_c *Cachecache_GetPASession_Call) RunAndReturn(run func(ctx context.Context, sessionID string) (*model.PASession, error)) *Cachecache_GetPASession_Call { _c.Call.Return(run) return _c } @@ -960,44 +956,36 @@ func (_c *Cachecache_RemoveCustomRule_Call) RunAndReturn(run func(ctx context.Co return _c } -// RemoveServicesBlockedFromProfileSettings provides a mock function for the type Cachecache -func (_mock *Cachecache) RemoveServicesBlockedFromProfileSettings(ctx context.Context, profileId string, serviceIds ...string) error { - var tmpRet mock.Arguments - if len(serviceIds) > 0 { - tmpRet = _mock.Called(ctx, profileId, serviceIds) - } else { - tmpRet = _mock.Called(ctx, profileId) - } - ret := tmpRet +// RemovePASession provides a mock function for the type Cachecache +func (_mock *Cachecache) RemovePASession(ctx context.Context, sessionID string) error { + ret := _mock.Called(ctx, sessionID) if len(ret) == 0 { - panic("no return value specified for RemoveServicesBlockedFromProfileSettings") + panic("no return value specified for RemovePASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) error); ok { - r0 = returnFunc(ctx, profileId, serviceIds...) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sessionID) } else { r0 = ret.Error(0) } return r0 } -// Cachecache_RemoveServicesBlockedFromProfileSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveServicesBlockedFromProfileSettings' -type Cachecache_RemoveServicesBlockedFromProfileSettings_Call struct { +// Cachecache_RemovePASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemovePASession' +type Cachecache_RemovePASession_Call struct { *mock.Call } -// RemoveServicesBlockedFromProfileSettings is a helper method to define mock.On call +// RemovePASession is a helper method to define mock.On call // - ctx context.Context -// - profileId string -// - serviceIds ...string -func (_e *Cachecache_Expecter) RemoveServicesBlockedFromProfileSettings(ctx interface{}, profileId interface{}, serviceIds ...interface{}) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { - return &Cachecache_RemoveServicesBlockedFromProfileSettings_Call{Call: _e.mock.On("RemoveServicesBlockedFromProfileSettings", - append([]interface{}{ctx, profileId}, serviceIds...)...)} +// - sessionID string +func (_e *Cachecache_Expecter) RemovePASession(ctx interface{}, sessionID interface{}) *Cachecache_RemovePASession_Call { + return &Cachecache_RemovePASession_Call{Call: _e.mock.On("RemovePASession", ctx, sessionID)} } -func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) Run(run func(ctx context.Context, profileId string, serviceIds ...string)) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { +func (_c *Cachecache_RemovePASession_Call) Run(run func(ctx context.Context, sessionID string)) *Cachecache_RemovePASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -1007,61 +995,62 @@ func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) Run(run func if args[1] != nil { arg1 = args[1].(string) } - var arg2 []string - var variadicArgs []string - if len(args) > 2 { - variadicArgs = args[2].([]string) - } - arg2 = variadicArgs run( arg0, arg1, - arg2..., ) }) return _c } -func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) Return(err error) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { +func (_c *Cachecache_RemovePASession_Call) Return(err error) *Cachecache_RemovePASession_Call { _c.Call.Return(err) return _c } -func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) RunAndReturn(run func(ctx context.Context, profileId string, serviceIds ...string) error) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { +func (_c *Cachecache_RemovePASession_Call) RunAndReturn(run func(ctx context.Context, sessionID string) error) *Cachecache_RemovePASession_Call { _c.Call.Return(run) return _c } -// RemoveSubscription provides a mock function for the type Cachecache -func (_mock *Cachecache) RemoveSubscription(ctx context.Context, subscriptionId string) error { - ret := _mock.Called(ctx, subscriptionId) +// RemoveServicesBlockedFromProfileSettings provides a mock function for the type Cachecache +func (_mock *Cachecache) RemoveServicesBlockedFromProfileSettings(ctx context.Context, profileId string, serviceIds ...string) error { + var tmpRet mock.Arguments + if len(serviceIds) > 0 { + tmpRet = _mock.Called(ctx, profileId, serviceIds) + } else { + tmpRet = _mock.Called(ctx, profileId) + } + ret := tmpRet if len(ret) == 0 { - panic("no return value specified for RemoveSubscription") + panic("no return value specified for RemoveServicesBlockedFromProfileSettings") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, subscriptionId) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, ...string) error); ok { + r0 = returnFunc(ctx, profileId, serviceIds...) } else { r0 = ret.Error(0) } return r0 } -// Cachecache_RemoveSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveSubscription' -type Cachecache_RemoveSubscription_Call struct { +// Cachecache_RemoveServicesBlockedFromProfileSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveServicesBlockedFromProfileSettings' +type Cachecache_RemoveServicesBlockedFromProfileSettings_Call struct { *mock.Call } -// RemoveSubscription is a helper method to define mock.On call +// RemoveServicesBlockedFromProfileSettings is a helper method to define mock.On call // - ctx context.Context -// - subscriptionId string -func (_e *Cachecache_Expecter) RemoveSubscription(ctx interface{}, subscriptionId interface{}) *Cachecache_RemoveSubscription_Call { - return &Cachecache_RemoveSubscription_Call{Call: _e.mock.On("RemoveSubscription", ctx, subscriptionId)} +// - profileId string +// - serviceIds ...string +func (_e *Cachecache_Expecter) RemoveServicesBlockedFromProfileSettings(ctx interface{}, profileId interface{}, serviceIds ...interface{}) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { + return &Cachecache_RemoveServicesBlockedFromProfileSettings_Call{Call: _e.mock.On("RemoveServicesBlockedFromProfileSettings", + append([]interface{}{ctx, profileId}, serviceIds...)...)} } -func (_c *Cachecache_RemoveSubscription_Call) Run(run func(ctx context.Context, subscriptionId string)) *Cachecache_RemoveSubscription_Call { +func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) Run(run func(ctx context.Context, profileId string, serviceIds ...string)) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -1071,20 +1060,27 @@ func (_c *Cachecache_RemoveSubscription_Call) Run(run func(ctx context.Context, if args[1] != nil { arg1 = args[1].(string) } + var arg2 []string + var variadicArgs []string + if len(args) > 2 { + variadicArgs = args[2].([]string) + } + arg2 = variadicArgs run( arg0, arg1, + arg2..., ) }) return _c } -func (_c *Cachecache_RemoveSubscription_Call) Return(err error) *Cachecache_RemoveSubscription_Call { +func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) Return(err error) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { _c.Call.Return(err) return _c } -func (_c *Cachecache_RemoveSubscription_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) error) *Cachecache_RemoveSubscription_Call { +func (_c *Cachecache_RemoveServicesBlockedFromProfileSettings_Call) RunAndReturn(run func(ctx context.Context, profileId string, serviceIds ...string) error) *Cachecache_RemoveServicesBlockedFromProfileSettings_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/passkey_servicer.go b/api/mocks/passkey_servicer.go index c94e4ac9..9d7b20b7 100644 --- a/api/mocks/passkey_servicer.go +++ b/api/mocks/passkey_servicer.go @@ -435,16 +435,16 @@ func (_c *PasskeyServicer_FinishReauth_Call) RunAndReturn(run func(ctx context.C } // FinishRegistration provides a mock function for the type PasskeyServicer -func (_mock *PasskeyServicer) FinishRegistration(ctx context.Context, token string, httpReq *http.Request) error { - ret := _mock.Called(ctx, token, httpReq) +func (_mock *PasskeyServicer) FinishRegistration(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error { + ret := _mock.Called(ctx, token, httpReq, paSessionID) if len(ret) == 0 { panic("no return value specified for FinishRegistration") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, *http.Request) error); ok { - r0 = returnFunc(ctx, token, httpReq) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *http.Request, string) error); ok { + r0 = returnFunc(ctx, token, httpReq, paSessionID) } else { r0 = ret.Error(0) } @@ -460,11 +460,12 @@ type PasskeyServicer_FinishRegistration_Call struct { // - ctx context.Context // - token string // - httpReq *http.Request -func (_e *PasskeyServicer_Expecter) FinishRegistration(ctx interface{}, token interface{}, httpReq interface{}) *PasskeyServicer_FinishRegistration_Call { - return &PasskeyServicer_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, token, httpReq)} +// - paSessionID string +func (_e *PasskeyServicer_Expecter) FinishRegistration(ctx interface{}, token interface{}, httpReq interface{}, paSessionID interface{}) *PasskeyServicer_FinishRegistration_Call { + return &PasskeyServicer_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, token, httpReq, paSessionID)} } -func (_c *PasskeyServicer_FinishRegistration_Call) Run(run func(ctx context.Context, token string, httpReq *http.Request)) *PasskeyServicer_FinishRegistration_Call { +func (_c *PasskeyServicer_FinishRegistration_Call) Run(run func(ctx context.Context, token string, httpReq *http.Request, paSessionID string)) *PasskeyServicer_FinishRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -478,10 +479,15 @@ func (_c *PasskeyServicer_FinishRegistration_Call) Run(run func(ctx context.Cont if args[2] != nil { arg2 = args[2].(*http.Request) } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -492,7 +498,7 @@ func (_c *PasskeyServicer_FinishRegistration_Call) Return(err error) *PasskeySer return _c } -func (_c *PasskeyServicer_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, token string, httpReq *http.Request) error) *PasskeyServicer_FinishRegistration_Call { +func (_c *PasskeyServicer_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error) *PasskeyServicer_FinishRegistration_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index ec09bf82..e0639a13 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -45,65 +45,59 @@ func (_m *Servicer) EXPECT() *Servicer_Expecter { return &Servicer_Expecter{mock: &_m.Mock} } -// AddSubscription provides a mock function for the type Servicer -func (_mock *Servicer) AddSubscription(ctx context.Context, subscriptionId string, activeUntil string) error { - ret := _mock.Called(ctx, subscriptionId, activeUntil) +// AddPASession provides a mock function for the type Servicer +func (_mock *Servicer) AddPASession(ctx context.Context, session *model.PASession) error { + ret := _mock.Called(ctx, session) if len(ret) == 0 { - panic("no return value specified for AddSubscription") + panic("no return value specified for AddPASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = returnFunc(ctx, subscriptionId, activeUntil) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.PASession) error); ok { + r0 = returnFunc(ctx, session) } else { r0 = ret.Error(0) } return r0 } -// Servicer_AddSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddSubscription' -type Servicer_AddSubscription_Call struct { +// Servicer_AddPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPASession' +type Servicer_AddPASession_Call struct { *mock.Call } -// AddSubscription is a helper method to define mock.On call +// AddPASession is a helper method to define mock.On call // - ctx context.Context -// - subscriptionId string -// - activeUntil string -func (_e *Servicer_Expecter) AddSubscription(ctx interface{}, subscriptionId interface{}, activeUntil interface{}) *Servicer_AddSubscription_Call { - return &Servicer_AddSubscription_Call{Call: _e.mock.On("AddSubscription", ctx, subscriptionId, activeUntil)} +// - session *model.PASession +func (_e *Servicer_Expecter) AddPASession(ctx interface{}, session interface{}) *Servicer_AddPASession_Call { + return &Servicer_AddPASession_Call{Call: _e.mock.On("AddPASession", ctx, session)} } -func (_c *Servicer_AddSubscription_Call) Run(run func(ctx context.Context, subscriptionId string, activeUntil string)) *Servicer_AddSubscription_Call { +func (_c *Servicer_AddPASession_Call) Run(run func(ctx context.Context, session *model.PASession)) *Servicer_AddPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 *model.PASession if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) + arg1 = args[1].(*model.PASession) } run( arg0, arg1, - arg2, ) }) return _c } -func (_c *Servicer_AddSubscription_Call) Return(err error) *Servicer_AddSubscription_Call { +func (_c *Servicer_AddPASession_Call) Return(err error) *Servicer_AddPASession_Call { _c.Call.Return(err) return _c } -func (_c *Servicer_AddSubscription_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string, activeUntil string) error) *Servicer_AddSubscription_Call { +func (_c *Servicer_AddPASession_Call) RunAndReturn(run func(ctx context.Context, session *model.PASession) error) *Servicer_AddPASession_Call { _c.Call.Return(run) return _c } @@ -337,16 +331,16 @@ func (_c *Servicer_BeginRegistration_Call) RunAndReturn(run func(ctx context.Con } // CompleteRegistration provides a mock function for the type Servicer -func (_mock *Servicer) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string) error { - ret := _mock.Called(ctx, account, subscriptionID) +func (_mock *Servicer) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error { + ret := _mock.Called(ctx, account, subscriptionID, sessionID) if len(ret) == 0 { panic("no return value specified for CompleteRegistration") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) error); ok { - r0 = returnFunc(ctx, account, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string, string) error); ok { + r0 = returnFunc(ctx, account, subscriptionID, sessionID) } else { r0 = ret.Error(0) } @@ -362,11 +356,12 @@ type Servicer_CompleteRegistration_Call struct { // - ctx context.Context // - account *model.Account // - subscriptionID string -func (_e *Servicer_Expecter) CompleteRegistration(ctx interface{}, account interface{}, subscriptionID interface{}) *Servicer_CompleteRegistration_Call { - return &Servicer_CompleteRegistration_Call{Call: _e.mock.On("CompleteRegistration", ctx, account, subscriptionID)} +// - sessionID string +func (_e *Servicer_Expecter) CompleteRegistration(ctx interface{}, account interface{}, subscriptionID interface{}, sessionID interface{}) *Servicer_CompleteRegistration_Call { + return &Servicer_CompleteRegistration_Call{Call: _e.mock.On("CompleteRegistration", ctx, account, subscriptionID, sessionID)} } -func (_c *Servicer_CompleteRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subscriptionID string)) *Servicer_CompleteRegistration_Call { +func (_c *Servicer_CompleteRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subscriptionID string, sessionID string)) *Servicer_CompleteRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -380,10 +375,15 @@ func (_c *Servicer_CompleteRegistration_Call) Run(run func(ctx context.Context, if args[2] != nil { arg2 = args[2].(string) } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -394,7 +394,7 @@ func (_c *Servicer_CompleteRegistration_Call) Return(err error) *Servicer_Comple return _c } -func (_c *Servicer_CompleteRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subscriptionID string) error) *Servicer_CompleteRegistration_Call { +func (_c *Servicer_CompleteRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error) *Servicer_CompleteRegistration_Call { _c.Call.Return(run) return _c } @@ -700,38 +700,37 @@ func (_c *Servicer_CreateProfile_Call) RunAndReturn(run func(ctx context.Context return _c } -// CreateSubscription provides a mock function for the type Servicer -func (_mock *Servicer) CreateSubscription(ctx context.Context, accountId string, subscriptionId string, activeUntil string) error { - ret := _mock.Called(ctx, accountId, subscriptionId, activeUntil) +// CreateSubscriptionFromPreauth provides a mock function for the type Servicer +func (_mock *Servicer) CreateSubscriptionFromPreauth(ctx context.Context, accountId string, preauth *model.Preauth) error { + ret := _mock.Called(ctx, accountId, preauth) if len(ret) == 0 { - panic("no return value specified for CreateSubscription") + panic("no return value specified for CreateSubscriptionFromPreauth") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { - r0 = returnFunc(ctx, accountId, subscriptionId, activeUntil) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *model.Preauth) error); ok { + r0 = returnFunc(ctx, accountId, preauth) } else { r0 = ret.Error(0) } return r0 } -// Servicer_CreateSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSubscription' -type Servicer_CreateSubscription_Call struct { +// Servicer_CreateSubscriptionFromPreauth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSubscriptionFromPreauth' +type Servicer_CreateSubscriptionFromPreauth_Call struct { *mock.Call } -// CreateSubscription is a helper method to define mock.On call +// CreateSubscriptionFromPreauth is a helper method to define mock.On call // - ctx context.Context // - accountId string -// - subscriptionId string -// - activeUntil string -func (_e *Servicer_Expecter) CreateSubscription(ctx interface{}, accountId interface{}, subscriptionId interface{}, activeUntil interface{}) *Servicer_CreateSubscription_Call { - return &Servicer_CreateSubscription_Call{Call: _e.mock.On("CreateSubscription", ctx, accountId, subscriptionId, activeUntil)} +// - preauth *model.Preauth +func (_e *Servicer_Expecter) CreateSubscriptionFromPreauth(ctx interface{}, accountId interface{}, preauth interface{}) *Servicer_CreateSubscriptionFromPreauth_Call { + return &Servicer_CreateSubscriptionFromPreauth_Call{Call: _e.mock.On("CreateSubscriptionFromPreauth", ctx, accountId, preauth)} } -func (_c *Servicer_CreateSubscription_Call) Run(run func(ctx context.Context, accountId string, subscriptionId string, activeUntil string)) *Servicer_CreateSubscription_Call { +func (_c *Servicer_CreateSubscriptionFromPreauth_Call) Run(run func(ctx context.Context, accountId string, preauth *model.Preauth)) *Servicer_CreateSubscriptionFromPreauth_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -741,30 +740,25 @@ func (_c *Servicer_CreateSubscription_Call) Run(run func(ctx context.Context, ac if args[1] != nil { arg1 = args[1].(string) } - var arg2 string + var arg2 *model.Preauth if args[2] != nil { - arg2 = args[2].(string) - } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) + arg2 = args[2].(*model.Preauth) } run( arg0, arg1, arg2, - arg3, ) }) return _c } -func (_c *Servicer_CreateSubscription_Call) Return(err error) *Servicer_CreateSubscription_Call { +func (_c *Servicer_CreateSubscriptionFromPreauth_Call) Return(err error) *Servicer_CreateSubscriptionFromPreauth_Call { _c.Call.Return(err) return _c } -func (_c *Servicer_CreateSubscription_Call) RunAndReturn(run func(ctx context.Context, accountId string, subscriptionId string, activeUntil string) error) *Servicer_CreateSubscription_Call { +func (_c *Servicer_CreateSubscriptionFromPreauth_Call) RunAndReturn(run func(ctx context.Context, accountId string, preauth *model.Preauth) error) *Servicer_CreateSubscriptionFromPreauth_Call { _c.Call.Return(run) return _c } @@ -1871,16 +1865,16 @@ func (_c *Servicer_FinishReauth_Call) RunAndReturn(run func(ctx context.Context, } // FinishRegistration provides a mock function for the type Servicer -func (_mock *Servicer) FinishRegistration(ctx context.Context, token string, httpReq *http.Request) error { - ret := _mock.Called(ctx, token, httpReq) +func (_mock *Servicer) FinishRegistration(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error { + ret := _mock.Called(ctx, token, httpReq, paSessionID) if len(ret) == 0 { panic("no return value specified for FinishRegistration") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, *http.Request) error); ok { - r0 = returnFunc(ctx, token, httpReq) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *http.Request, string) error); ok { + r0 = returnFunc(ctx, token, httpReq, paSessionID) } else { r0 = ret.Error(0) } @@ -1896,11 +1890,12 @@ type Servicer_FinishRegistration_Call struct { // - ctx context.Context // - token string // - httpReq *http.Request -func (_e *Servicer_Expecter) FinishRegistration(ctx interface{}, token interface{}, httpReq interface{}) *Servicer_FinishRegistration_Call { - return &Servicer_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, token, httpReq)} +// - paSessionID string +func (_e *Servicer_Expecter) FinishRegistration(ctx interface{}, token interface{}, httpReq interface{}, paSessionID interface{}) *Servicer_FinishRegistration_Call { + return &Servicer_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, token, httpReq, paSessionID)} } -func (_c *Servicer_FinishRegistration_Call) Run(run func(ctx context.Context, token string, httpReq *http.Request)) *Servicer_FinishRegistration_Call { +func (_c *Servicer_FinishRegistration_Call) Run(run func(ctx context.Context, token string, httpReq *http.Request, paSessionID string)) *Servicer_FinishRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -1914,10 +1909,15 @@ func (_c *Servicer_FinishRegistration_Call) Run(run func(ctx context.Context, to if args[2] != nil { arg2 = args[2].(*http.Request) } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -1928,7 +1928,7 @@ func (_c *Servicer_FinishRegistration_Call) Return(err error) *Servicer_FinishRe return _c } -func (_c *Servicer_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, token string, httpReq *http.Request) error) *Servicer_FinishRegistration_Call { +func (_c *Servicer_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error) *Servicer_FinishRegistration_Call { _c.Call.Return(run) return _c } @@ -2917,9 +2917,77 @@ func (_c *Servicer_GetSubscription_Call) RunAndReturn(run func(ctx context.Conte return _c } +// GetSubscriptionById provides a mock function for the type Servicer +func (_mock *Servicer) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { + ret := _mock.Called(ctx, subscriptionId) + + if len(ret) == 0 { + panic("no return value specified for GetSubscriptionById") + } + + var r0 *model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { + return returnFunc(ctx, subscriptionId) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { + r0 = returnFunc(ctx, subscriptionId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, subscriptionId) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Servicer_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' +type Servicer_GetSubscriptionById_Call struct { + *mock.Call +} + +// GetSubscriptionById is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionId string +func (_e *Servicer_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *Servicer_GetSubscriptionById_Call { + return &Servicer_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} +} + +func (_c *Servicer_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *Servicer_GetSubscriptionById_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Servicer_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *Servicer_GetSubscriptionById_Call { + _c.Call.Return(subscription, err) + return _c +} + +func (_c *Servicer_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *Servicer_GetSubscriptionById_Call { + _c.Call.Return(run) + return _c +} + // GetUnfinishedSignupOrPostAccount provides a mock function for the type Servicer -func (_mock *Servicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string) (*model.Account, error) { - ret := _mock.Called(ctx, email, password, subscriptionID) +func (_mock *Servicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error) { + ret := _mock.Called(ctx, email, password, subscriptionID, sessionID) if len(ret) == 0 { panic("no return value specified for GetUnfinishedSignupOrPostAccount") @@ -2927,18 +2995,18 @@ func (_mock *Servicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, ema var r0 *model.Account var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*model.Account, error)); ok { - return returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*model.Account, error)); ok { + return returnFunc(ctx, email, password, subscriptionID, sessionID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *model.Account); ok { - r0 = returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *model.Account); ok { + r0 = returnFunc(ctx, email, password, subscriptionID, sessionID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Account) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = returnFunc(ctx, email, password, subscriptionID) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { + r1 = returnFunc(ctx, email, password, subscriptionID, sessionID) } else { r1 = ret.Error(1) } @@ -2955,11 +3023,12 @@ type Servicer_GetUnfinishedSignupOrPostAccount_Call struct { // - email string // - password string // - subscriptionID string -func (_e *Servicer_Expecter) GetUnfinishedSignupOrPostAccount(ctx interface{}, email interface{}, password interface{}, subscriptionID interface{}) *Servicer_GetUnfinishedSignupOrPostAccount_Call { - return &Servicer_GetUnfinishedSignupOrPostAccount_Call{Call: _e.mock.On("GetUnfinishedSignupOrPostAccount", ctx, email, password, subscriptionID)} +// - sessionID string +func (_e *Servicer_Expecter) GetUnfinishedSignupOrPostAccount(ctx interface{}, email interface{}, password interface{}, subscriptionID interface{}, sessionID interface{}) *Servicer_GetUnfinishedSignupOrPostAccount_Call { + return &Servicer_GetUnfinishedSignupOrPostAccount_Call{Call: _e.mock.On("GetUnfinishedSignupOrPostAccount", ctx, email, password, subscriptionID, sessionID)} } -func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ctx context.Context, email string, password string, subscriptionID string)) *Servicer_GetUnfinishedSignupOrPostAccount_Call { +func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ctx context.Context, email string, password string, subscriptionID string, sessionID string)) *Servicer_GetUnfinishedSignupOrPostAccount_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -2977,11 +3046,16 @@ func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) Run(run func(ctx conte if args[3] != nil { arg3 = args[3].(string) } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -2992,7 +3066,7 @@ func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) Return(account *model. return _c } -func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subscriptionID string) (*model.Account, error)) *Servicer_GetUnfinishedSignupOrPostAccount_Call { +func (_c *Servicer_GetUnfinishedSignupOrPostAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error)) *Servicer_GetUnfinishedSignupOrPostAccount_Call { _c.Call.Return(run) return _c } @@ -3060,49 +3134,36 @@ func (_c *Servicer_MfaCheck_Call) RunAndReturn(run func(ctx context.Context, acc return _c } -// RegisterAccount provides a mock function for the type Servicer -func (_mock *Servicer) RegisterAccount(ctx context.Context, email string, password string, subID string) (*model.Account, error) { - ret := _mock.Called(ctx, email, password, subID) +// RequestEmailVerificationOTP provides a mock function for the type Servicer +func (_mock *Servicer) RequestEmailVerificationOTP(ctx context.Context, accountId string) error { + ret := _mock.Called(ctx, accountId) if len(ret) == 0 { - panic("no return value specified for RegisterAccount") + panic("no return value specified for RequestEmailVerificationOTP") } - var r0 *model.Account - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*model.Account, error)); ok { - return returnFunc(ctx, email, password, subID) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *model.Account); ok { - r0 = returnFunc(ctx, email, password, subID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Account) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = returnFunc(ctx, email, password, subID) + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, accountId) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// Servicer_RegisterAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterAccount' -type Servicer_RegisterAccount_Call struct { +// Servicer_RequestEmailVerificationOTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestEmailVerificationOTP' +type Servicer_RequestEmailVerificationOTP_Call struct { *mock.Call } -// RegisterAccount is a helper method to define mock.On call +// RequestEmailVerificationOTP is a helper method to define mock.On call // - ctx context.Context -// - email string -// - password string -// - subID string -func (_e *Servicer_Expecter) RegisterAccount(ctx interface{}, email interface{}, password interface{}, subID interface{}) *Servicer_RegisterAccount_Call { - return &Servicer_RegisterAccount_Call{Call: _e.mock.On("RegisterAccount", ctx, email, password, subID)} +// - accountId string +func (_e *Servicer_Expecter) RequestEmailVerificationOTP(ctx interface{}, accountId interface{}) *Servicer_RequestEmailVerificationOTP_Call { + return &Servicer_RequestEmailVerificationOTP_Call{Call: _e.mock.On("RequestEmailVerificationOTP", ctx, accountId)} } -func (_c *Servicer_RegisterAccount_Call) Run(run func(ctx context.Context, email string, password string, subID string)) *Servicer_RegisterAccount_Call { +func (_c *Servicer_RequestEmailVerificationOTP_Call) Run(run func(ctx context.Context, accountId string)) *Servicer_RequestEmailVerificationOTP_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -3112,64 +3173,63 @@ func (_c *Servicer_RegisterAccount_Call) Run(run func(ctx context.Context, email if args[1] != nil { arg1 = args[1].(string) } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) - } run( arg0, arg1, - arg2, - arg3, ) }) return _c } -func (_c *Servicer_RegisterAccount_Call) Return(account *model.Account, err error) *Servicer_RegisterAccount_Call { - _c.Call.Return(account, err) +func (_c *Servicer_RequestEmailVerificationOTP_Call) Return(err error) *Servicer_RequestEmailVerificationOTP_Call { + _c.Call.Return(err) return _c } -func (_c *Servicer_RegisterAccount_Call) RunAndReturn(run func(ctx context.Context, email string, password string, subID string) (*model.Account, error)) *Servicer_RegisterAccount_Call { +func (_c *Servicer_RequestEmailVerificationOTP_Call) RunAndReturn(run func(ctx context.Context, accountId string) error) *Servicer_RequestEmailVerificationOTP_Call { _c.Call.Return(run) return _c } -// RequestEmailVerificationOTP provides a mock function for the type Servicer -func (_mock *Servicer) RequestEmailVerificationOTP(ctx context.Context, accountId string) error { - ret := _mock.Called(ctx, accountId) +// RotatePASessionID provides a mock function for the type Servicer +func (_mock *Servicer) RotatePASessionID(ctx context.Context, oldID string) (string, error) { + ret := _mock.Called(ctx, oldID) if len(ret) == 0 { - panic("no return value specified for RequestEmailVerificationOTP") + panic("no return value specified for RotatePASessionID") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, accountId) + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return returnFunc(ctx, oldID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = returnFunc(ctx, oldID) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(string) } - return r0 + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, oldID) + } else { + r1 = ret.Error(1) + } + return r0, r1 } -// Servicer_RequestEmailVerificationOTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestEmailVerificationOTP' -type Servicer_RequestEmailVerificationOTP_Call struct { +// Servicer_RotatePASessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotatePASessionID' +type Servicer_RotatePASessionID_Call struct { *mock.Call } -// RequestEmailVerificationOTP is a helper method to define mock.On call +// RotatePASessionID is a helper method to define mock.On call // - ctx context.Context -// - accountId string -func (_e *Servicer_Expecter) RequestEmailVerificationOTP(ctx interface{}, accountId interface{}) *Servicer_RequestEmailVerificationOTP_Call { - return &Servicer_RequestEmailVerificationOTP_Call{Call: _e.mock.On("RequestEmailVerificationOTP", ctx, accountId)} +// - oldID string +func (_e *Servicer_Expecter) RotatePASessionID(ctx interface{}, oldID interface{}) *Servicer_RotatePASessionID_Call { + return &Servicer_RotatePASessionID_Call{Call: _e.mock.On("RotatePASessionID", ctx, oldID)} } -func (_c *Servicer_RequestEmailVerificationOTP_Call) Run(run func(ctx context.Context, accountId string)) *Servicer_RequestEmailVerificationOTP_Call { +func (_c *Servicer_RotatePASessionID_Call) Run(run func(ctx context.Context, oldID string)) *Servicer_RotatePASessionID_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -3187,12 +3247,12 @@ func (_c *Servicer_RequestEmailVerificationOTP_Call) Run(run func(ctx context.Co return _c } -func (_c *Servicer_RequestEmailVerificationOTP_Call) Return(err error) *Servicer_RequestEmailVerificationOTP_Call { - _c.Call.Return(err) +func (_c *Servicer_RotatePASessionID_Call) Return(s string, err error) *Servicer_RotatePASessionID_Call { + _c.Call.Return(s, err) return _c } -func (_c *Servicer_RequestEmailVerificationOTP_Call) RunAndReturn(run func(ctx context.Context, accountId string) error) *Servicer_RequestEmailVerificationOTP_Call { +func (_c *Servicer_RotatePASessionID_Call) RunAndReturn(run func(ctx context.Context, oldID string) (string, error)) *Servicer_RotatePASessionID_Call { _c.Call.Return(run) return _c } @@ -3894,6 +3954,74 @@ func (_c *Servicer_UpdateSubscription_Call) RunAndReturn(run func(ctx context.Co return _c } +// ValidateAndGetPreauth provides a mock function for the type Servicer +func (_mock *Servicer) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for ValidateAndGetPreauth") + } + + var r0 *model.Preauth + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Preauth, error)); ok { + return returnFunc(ctx, sessionID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Preauth); ok { + r0 = returnFunc(ctx, sessionID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Preauth) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, sessionID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Servicer_ValidateAndGetPreauth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateAndGetPreauth' +type Servicer_ValidateAndGetPreauth_Call struct { + *mock.Call +} + +// ValidateAndGetPreauth is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *Servicer_Expecter) ValidateAndGetPreauth(ctx interface{}, sessionID interface{}) *Servicer_ValidateAndGetPreauth_Call { + return &Servicer_ValidateAndGetPreauth_Call{Call: _e.mock.On("ValidateAndGetPreauth", ctx, sessionID)} +} + +func (_c *Servicer_ValidateAndGetPreauth_Call) Run(run func(ctx context.Context, sessionID string)) *Servicer_ValidateAndGetPreauth_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Servicer_ValidateAndGetPreauth_Call) Return(preauth *model.Preauth, err error) *Servicer_ValidateAndGetPreauth_Call { + _c.Call.Return(preauth, err) + return _c +} + +func (_c *Servicer_ValidateAndGetPreauth_Call) RunAndReturn(run func(ctx context.Context, sessionID string) (*model.Preauth, error)) *Servicer_ValidateAndGetPreauth_Call { + _c.Call.Return(run) + return _c +} + // VerifyEmailOTP provides a mock function for the type Servicer func (_mock *Servicer) VerifyEmailOTP(ctx context.Context, accountId string, otp string) error { ret := _mock.Called(ctx, accountId, otp) diff --git a/api/mocks/subscription_servicer.go b/api/mocks/subscription_servicer.go index eb118b5e..8a1aced0 100644 --- a/api/mocks/subscription_servicer.go +++ b/api/mocks/subscription_servicer.go @@ -38,101 +38,94 @@ func (_m *SubscriptionServicer) EXPECT() *SubscriptionServicer_Expecter { return &SubscriptionServicer_Expecter{mock: &_m.Mock} } -// AddSubscription provides a mock function for the type SubscriptionServicer -func (_mock *SubscriptionServicer) AddSubscription(ctx context.Context, subscriptionId string, activeUntil string) error { - ret := _mock.Called(ctx, subscriptionId, activeUntil) +// AddPASession provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) AddPASession(ctx context.Context, session *model.PASession) error { + ret := _mock.Called(ctx, session) if len(ret) == 0 { - panic("no return value specified for AddSubscription") + panic("no return value specified for AddPASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = returnFunc(ctx, subscriptionId, activeUntil) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.PASession) error); ok { + r0 = returnFunc(ctx, session) } else { r0 = ret.Error(0) } return r0 } -// SubscriptionServicer_AddSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddSubscription' -type SubscriptionServicer_AddSubscription_Call struct { +// SubscriptionServicer_AddPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPASession' +type SubscriptionServicer_AddPASession_Call struct { *mock.Call } -// AddSubscription is a helper method to define mock.On call +// AddPASession is a helper method to define mock.On call // - ctx context.Context -// - subscriptionId string -// - activeUntil string -func (_e *SubscriptionServicer_Expecter) AddSubscription(ctx interface{}, subscriptionId interface{}, activeUntil interface{}) *SubscriptionServicer_AddSubscription_Call { - return &SubscriptionServicer_AddSubscription_Call{Call: _e.mock.On("AddSubscription", ctx, subscriptionId, activeUntil)} +// - session *model.PASession +func (_e *SubscriptionServicer_Expecter) AddPASession(ctx interface{}, session interface{}) *SubscriptionServicer_AddPASession_Call { + return &SubscriptionServicer_AddPASession_Call{Call: _e.mock.On("AddPASession", ctx, session)} } -func (_c *SubscriptionServicer_AddSubscription_Call) Run(run func(ctx context.Context, subscriptionId string, activeUntil string)) *SubscriptionServicer_AddSubscription_Call { +func (_c *SubscriptionServicer_AddPASession_Call) Run(run func(ctx context.Context, session *model.PASession)) *SubscriptionServicer_AddPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 *model.PASession if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) + arg1 = args[1].(*model.PASession) } run( arg0, arg1, - arg2, ) }) return _c } -func (_c *SubscriptionServicer_AddSubscription_Call) Return(err error) *SubscriptionServicer_AddSubscription_Call { +func (_c *SubscriptionServicer_AddPASession_Call) Return(err error) *SubscriptionServicer_AddPASession_Call { _c.Call.Return(err) return _c } -func (_c *SubscriptionServicer_AddSubscription_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string, activeUntil string) error) *SubscriptionServicer_AddSubscription_Call { +func (_c *SubscriptionServicer_AddPASession_Call) RunAndReturn(run func(ctx context.Context, session *model.PASession) error) *SubscriptionServicer_AddPASession_Call { _c.Call.Return(run) return _c } -// CreateSubscription provides a mock function for the type SubscriptionServicer -func (_mock *SubscriptionServicer) CreateSubscription(ctx context.Context, accountId string, subscriptionId string, activeUntil string) error { - ret := _mock.Called(ctx, accountId, subscriptionId, activeUntil) +// CreateSubscriptionFromPreauth provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) CreateSubscriptionFromPreauth(ctx context.Context, accountId string, preauth *model.Preauth) error { + ret := _mock.Called(ctx, accountId, preauth) if len(ret) == 0 { - panic("no return value specified for CreateSubscription") + panic("no return value specified for CreateSubscriptionFromPreauth") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { - r0 = returnFunc(ctx, accountId, subscriptionId, activeUntil) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *model.Preauth) error); ok { + r0 = returnFunc(ctx, accountId, preauth) } else { r0 = ret.Error(0) } return r0 } -// SubscriptionServicer_CreateSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSubscription' -type SubscriptionServicer_CreateSubscription_Call struct { +// SubscriptionServicer_CreateSubscriptionFromPreauth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSubscriptionFromPreauth' +type SubscriptionServicer_CreateSubscriptionFromPreauth_Call struct { *mock.Call } -// CreateSubscription is a helper method to define mock.On call +// CreateSubscriptionFromPreauth is a helper method to define mock.On call // - ctx context.Context // - accountId string -// - subscriptionId string -// - activeUntil string -func (_e *SubscriptionServicer_Expecter) CreateSubscription(ctx interface{}, accountId interface{}, subscriptionId interface{}, activeUntil interface{}) *SubscriptionServicer_CreateSubscription_Call { - return &SubscriptionServicer_CreateSubscription_Call{Call: _e.mock.On("CreateSubscription", ctx, accountId, subscriptionId, activeUntil)} +// - preauth *model.Preauth +func (_e *SubscriptionServicer_Expecter) CreateSubscriptionFromPreauth(ctx interface{}, accountId interface{}, preauth interface{}) *SubscriptionServicer_CreateSubscriptionFromPreauth_Call { + return &SubscriptionServicer_CreateSubscriptionFromPreauth_Call{Call: _e.mock.On("CreateSubscriptionFromPreauth", ctx, accountId, preauth)} } -func (_c *SubscriptionServicer_CreateSubscription_Call) Run(run func(ctx context.Context, accountId string, subscriptionId string, activeUntil string)) *SubscriptionServicer_CreateSubscription_Call { +func (_c *SubscriptionServicer_CreateSubscriptionFromPreauth_Call) Run(run func(ctx context.Context, accountId string, preauth *model.Preauth)) *SubscriptionServicer_CreateSubscriptionFromPreauth_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -142,30 +135,25 @@ func (_c *SubscriptionServicer_CreateSubscription_Call) Run(run func(ctx context if args[1] != nil { arg1 = args[1].(string) } - var arg2 string + var arg2 *model.Preauth if args[2] != nil { - arg2 = args[2].(string) - } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) + arg2 = args[2].(*model.Preauth) } run( arg0, arg1, arg2, - arg3, ) }) return _c } -func (_c *SubscriptionServicer_CreateSubscription_Call) Return(err error) *SubscriptionServicer_CreateSubscription_Call { +func (_c *SubscriptionServicer_CreateSubscriptionFromPreauth_Call) Return(err error) *SubscriptionServicer_CreateSubscriptionFromPreauth_Call { _c.Call.Return(err) return _c } -func (_c *SubscriptionServicer_CreateSubscription_Call) RunAndReturn(run func(ctx context.Context, accountId string, subscriptionId string, activeUntil string) error) *SubscriptionServicer_CreateSubscription_Call { +func (_c *SubscriptionServicer_CreateSubscriptionFromPreauth_Call) RunAndReturn(run func(ctx context.Context, accountId string, preauth *model.Preauth) error) *SubscriptionServicer_CreateSubscriptionFromPreauth_Call { _c.Call.Return(run) return _c } @@ -238,6 +226,140 @@ func (_c *SubscriptionServicer_GetSubscription_Call) RunAndReturn(run func(ctx c return _c } +// GetSubscriptionById provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { + ret := _mock.Called(ctx, subscriptionId) + + if len(ret) == 0 { + panic("no return value specified for GetSubscriptionById") + } + + var r0 *model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { + return returnFunc(ctx, subscriptionId) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { + r0 = returnFunc(ctx, subscriptionId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, subscriptionId) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionServicer_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' +type SubscriptionServicer_GetSubscriptionById_Call struct { + *mock.Call +} + +// GetSubscriptionById is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionId string +func (_e *SubscriptionServicer_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *SubscriptionServicer_GetSubscriptionById_Call { + return &SubscriptionServicer_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} +} + +func (_c *SubscriptionServicer_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *SubscriptionServicer_GetSubscriptionById_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionServicer_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *SubscriptionServicer_GetSubscriptionById_Call { + _c.Call.Return(subscription, err) + return _c +} + +func (_c *SubscriptionServicer_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *SubscriptionServicer_GetSubscriptionById_Call { + _c.Call.Return(run) + return _c +} + +// RotatePASessionID provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) RotatePASessionID(ctx context.Context, oldID string) (string, error) { + ret := _mock.Called(ctx, oldID) + + if len(ret) == 0 { + panic("no return value specified for RotatePASessionID") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return returnFunc(ctx, oldID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = returnFunc(ctx, oldID) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, oldID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionServicer_RotatePASessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotatePASessionID' +type SubscriptionServicer_RotatePASessionID_Call struct { + *mock.Call +} + +// RotatePASessionID is a helper method to define mock.On call +// - ctx context.Context +// - oldID string +func (_e *SubscriptionServicer_Expecter) RotatePASessionID(ctx interface{}, oldID interface{}) *SubscriptionServicer_RotatePASessionID_Call { + return &SubscriptionServicer_RotatePASessionID_Call{Call: _e.mock.On("RotatePASessionID", ctx, oldID)} +} + +func (_c *SubscriptionServicer_RotatePASessionID_Call) Run(run func(ctx context.Context, oldID string)) *SubscriptionServicer_RotatePASessionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionServicer_RotatePASessionID_Call) Return(s string, err error) *SubscriptionServicer_RotatePASessionID_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *SubscriptionServicer_RotatePASessionID_Call) RunAndReturn(run func(ctx context.Context, oldID string) (string, error)) *SubscriptionServicer_RotatePASessionID_Call { + _c.Call.Return(run) + return _c +} + // UpdateSubscription provides a mock function for the type SubscriptionServicer func (_mock *SubscriptionServicer) UpdateSubscription(ctx context.Context, accountId string, updates []model.SubscriptionUpdate) (*model.Subscription, error) { ret := _mock.Called(ctx, accountId, updates) @@ -311,3 +433,71 @@ func (_c *SubscriptionServicer_UpdateSubscription_Call) RunAndReturn(run func(ct _c.Call.Return(run) return _c } + +// ValidateAndGetPreauth provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for ValidateAndGetPreauth") + } + + var r0 *model.Preauth + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Preauth, error)); ok { + return returnFunc(ctx, sessionID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Preauth); ok { + r0 = returnFunc(ctx, sessionID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Preauth) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, sessionID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionServicer_ValidateAndGetPreauth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateAndGetPreauth' +type SubscriptionServicer_ValidateAndGetPreauth_Call struct { + *mock.Call +} + +// ValidateAndGetPreauth is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *SubscriptionServicer_Expecter) ValidateAndGetPreauth(ctx interface{}, sessionID interface{}) *SubscriptionServicer_ValidateAndGetPreauth_Call { + return &SubscriptionServicer_ValidateAndGetPreauth_Call{Call: _e.mock.On("ValidateAndGetPreauth", ctx, sessionID)} +} + +func (_c *SubscriptionServicer_ValidateAndGetPreauth_Call) Run(run func(ctx context.Context, sessionID string)) *SubscriptionServicer_ValidateAndGetPreauth_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionServicer_ValidateAndGetPreauth_Call) Return(preauth *model.Preauth, err error) *SubscriptionServicer_ValidateAndGetPreauth_Call { + _c.Call.Return(preauth, err) + return _c +} + +func (_c *SubscriptionServicer_ValidateAndGetPreauth_Call) RunAndReturn(run func(ctx context.Context, sessionID string) (*model.Preauth, error)) *SubscriptionServicer_ValidateAndGetPreauth_Call { + _c.Call.Return(run) + return _c +} diff --git a/api/service/account/reauth_test.go b/api/service/account/reauth_test.go index 75a0a36f..b3aa85c7 100644 --- a/api/service/account/reauth_test.go +++ b/api/service/account/reauth_test.go @@ -43,7 +43,7 @@ func (suite *ReauthTokenSuite) SetupSuite() { val := validatorv10.New() // Provide a minimal subscription service dependency required by constructor mockSubRepo := mocks.NewSubscriptionRepository(suite.T()) - subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}) + subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) // credential repo is not used in these tests; pass nil suite.service = account.NewAccountService(*cfg.Service, suite.mockAccountRepo, nil, nil, subService, nil, suite.mockCache, suite.mockMailer, nil, val, webhookClient.Http{}) } diff --git a/api/service/account/service_test.go b/api/service/account/service_test.go index 0cf2c028..dad43efd 100644 --- a/api/service/account/service_test.go +++ b/api/service/account/service_test.go @@ -2,14 +2,18 @@ package account_test import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "os" "strings" "testing" "time" - "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "github.com/go-playground/validator/v10" @@ -86,7 +90,7 @@ func (suite *AccountTestSuite) SetupSuite() { suite.queryLogsService = querylogs.NewQueryLogsService(suite.mockQueryLogsRepo) suite.statisticsService = statistics.NewStatisticsService(suite.mockStatsRepo) suite.mockSubscriptionRepo = mocks.NewSubscriptionRepository(suite.T()) - suite.subscriptionService = subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockCache, suite.serviceConfig) + suite.subscriptionService = subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{}, webhookClient.Http{}) // Create the profile service with mocks suite.profileService = profile.NewProfileService( @@ -117,156 +121,55 @@ func (suite *AccountTestSuite) SetupSuite() { ) } -// TestRegisterAccount tests the RegisterAccount method using table-driven tests -func (suite *AccountTestSuite) TestRegisterAccount() { - subID := "550e8400-e29b-41d4-a716-446655440000" // test subscription UUID - activeUntilStr := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) - tests := []struct { - name string - email string - password string - existingAccount *model.Account - getAccountError error - profileCreateError error - createAccountError error - expectedError string - expectSuccess bool - }{ - { - name: "Successful registration", - email: "test@example.com", - password: "StrongPass123!", - expectSuccess: true, - }, - { - name: "Account already exists", - email: "existing@example.com", - password: "StrongPass123!", - existingAccount: &model.Account{Email: "existing@example.com"}, - expectedError: "account with this email already exists", - }, - { - name: "Database error on check", - email: "test@example.com", - password: "StrongPass123!", - getAccountError: errors.New("database error"), - expectedError: "database error", - }, - { - name: "Profile creation fails", - email: "test@example.com", - password: "StrongPass123!", - profileCreateError: errors.New("profile creation failed"), - expectedError: "profile creation failed", - }, - { - name: "Account creation fails", - email: "test@example.com", - password: "StrongPass123!", - createAccountError: errors.New("account creation failed"), - expectedError: "account creation failed", - }, - } - - for _, tt := range tests { - suite.Run(tt.name, func() { - // Reset mock expectations - suite.mockAccountRepo.ExpectedCalls = nil - suite.mockCache.ExpectedCalls = nil - suite.mockProfileRepo.ExpectedCalls = nil - suite.mockBlocklistRepo.ExpectedCalls = nil - suite.mockMailer.ExpectedCalls = nil - suite.mockIDGenerator.ExpectedCalls = nil - - // Mock GetAccountByEmail (switch to satisfy lint ifElseChain) - switch { - case tt.getAccountError != nil: - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), tt.email).Return(nil, tt.getAccountError) - case tt.existingAccount != nil: - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), tt.email).Return(tt.existingAccount, nil) - default: - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), tt.email).Return(nil, dbErrors.ErrAccountNotFound) - } - - if tt.expectSuccess || tt.profileCreateError != nil || tt.createAccountError != nil { - // Mock profile creation dependencies - if tt.profileCreateError != nil { - suite.mockProfileRepo.On("GetProfilesByAccountId", context.Background(), mock.AnythingOfType("string")).Return([]model.Profile{}, nil) - suite.mockIDGenerator.On("Generate").Return("", tt.profileCreateError) - } else if tt.expectSuccess || tt.createAccountError != nil { - suite.mockProfileRepo.On("GetProfilesByAccountId", context.Background(), mock.AnythingOfType("string")).Return([]model.Profile{}, nil) - suite.mockIDGenerator.On("Generate").Return("profile123", nil) - - // Mock default blocklists for profile creation - defaultBlocklists := []*model.Blocklist{ - { - Name: "Default Blocklist", - Default: true, - }, - } - suite.mockBlocklistRepo.On("Get", context.Background(), map[string]any{"default": true}, "updated").Return(defaultBlocklists, nil) - - // Mock profile creation in repository - suite.mockProfileRepo.On("CreateProfile", context.Background(), mock.AnythingOfType("*model.Profile")).Return(nil) - - // Mock cache settings creation - suite.mockCache.On("CreateOrUpdateProfileSettings", context.Background(), mock.AnythingOfType("*model.ProfileSettings"), true).Return(nil) - - // Mock account creation - if tt.createAccountError != nil { - suite.mockAccountRepo.On("CreateAccount", context.Background(), tt.email, mock.AnythingOfType("string"), mock.AnythingOfType("string"), "profile123", mock.Anything).Return(nil, tt.createAccountError) - } else { - expectedAccount := &model.Account{ - ID: primitive.NewObjectID(), - Email: tt.email, - } - suite.mockAccountRepo.On("CreateAccount", context.Background(), tt.email, mock.AnythingOfType("string"), mock.AnythingOfType("string"), "profile123", mock.Anything).Return(expectedAccount, nil) - - // Single insert path; no UpdateAccount expected when password provided (pre-hashed before CreateAccount) - - // Mock verify + welcome email - suite.mockMailer.On("Verify", tt.email).Return(nil) - suite.mockMailer.On("SendWelcomeEmail", context.Background(), tt.email, mock.AnythingOfType("string")).Return(nil) - } - } - } - - // Execute the method - // Common expectation: cache provides subscription activeUntil - suite.mockCache.On("GetSubscription", context.Background(), subID).Return(activeUntilStr, nil) - // Expect subscription creation only on success path (no earlier errors) - if tt.expectSuccess && tt.profileCreateError == nil && tt.createAccountError == nil && tt.getAccountError == nil && tt.existingAccount == nil { - suite.mockSubscriptionRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(nil) - // Expect removal of subscription cache marker - suite.mockCache.On("RemoveSubscription", context.Background(), subID).Return(nil) - } - result, err := suite.service.RegisterAccount(context.Background(), tt.email, tt.password, subID) - - // Verify results - if tt.expectedError != "" { - suite.Error(err) - suite.Contains(err.Error(), tt.expectedError) - suite.Nil(result) - } else { - suite.NoError(err) - suite.NotNil(result) - suite.Equal(tt.email, result.Email) - } - }) - } -} - // TestGetUnfinishedSignupOrPostAccount covers registration reuse & creation scenarios +// including the PASession validation step added by the ZLA integration. func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { subID := "550e8400-e29b-41d4-a716-446655449999" - activeUntil := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) - password := "StrongPass123!" // valid password + // PASession validation helpers: compute token hash for mock preauth server + testToken := "test-pa-token-abc" + tokenHash := sha256.Sum256([]byte(testToken)) + tokenHashStr := base64.StdEncoding.EncodeToString(tokenHash[:]) + preauthID := "preauth-id-123" + sessionID := "pa-session-id-456" + activeUntil := time.Now().Add(2 * time.Hour).UTC() + + // Set up httptest server to mock the external preauth service + preauthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + preauth := model.Preauth{ + ID: preauthID, + TokenHash: tokenHashStr, + IsActive: true, + ActiveUntil: activeUntil, + Tier: "pro", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(preauth) + })) + defer preauthServer.Close() + + // Re-create subscription service and account service with the httptest server URL + httpClient := webhookClient.Http{Cfg: config.APIConfig{PreauthURL: preauthServer.URL}} + subSvc := subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{PreauthURL: preauthServer.URL}, httpClient) + + suite.service = account.NewAccountService( + suite.serviceConfig, + suite.mockAccountRepo, + suite.profileService, + suite.statisticsService, + subSvc, + nil, + suite.mockCache, + suite.mockMailer, + suite.mockIDGenerator, + suite.validator, + webhookClient.Http{}, + ) + type mocksConfig struct { - cacheErr error + paSessionErr bool // GetPASession returns error (no PA session) existingAccount *model.Account - subscriptionDuplicate bool // subscription UUID already exists (GetSubscriptionById returns object) subscriptionMissingForUnfinished bool // unfinished account has no subscription; will be created } @@ -276,13 +179,8 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { expectError string }{ { - name: "Cache key missing -> error", - cfg: mocksConfig{cacheErr: errors.New("redis: nil")}, - expectError: account.ErrUnableToCreateAccount.Error(), - }, - { - name: "Subscription duplicate pre-create -> error", - cfg: mocksConfig{subscriptionDuplicate: true}, + name: "PA session missing -> error", + cfg: mocksConfig{paSessionErr: true}, expectError: account.ErrUnableToCreateAccount.Error(), }, { @@ -307,79 +205,80 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { suite.Run(tt.name, func() { // Reset expectations suite.mockCache.ExpectedCalls = nil + suite.mockCache.Calls = nil suite.mockAccountRepo.ExpectedCalls = nil + suite.mockAccountRepo.Calls = nil suite.mockSubscriptionRepo.ExpectedCalls = nil + suite.mockSubscriptionRepo.Calls = nil suite.mockProfileRepo.ExpectedCalls = nil + suite.mockProfileRepo.Calls = nil suite.mockBlocklistRepo.ExpectedCalls = nil + suite.mockBlocklistRepo.Calls = nil suite.mockMailer.ExpectedCalls = nil + suite.mockMailer.Calls = nil suite.mockIDGenerator.ExpectedCalls = nil + suite.mockIDGenerator.Calls = nil - // Cache GetSubscription behavior - if tt.cfg.cacheErr != nil { - suite.mockCache.On("GetSubscription", context.Background(), subID).Return("", tt.cfg.cacheErr) + // Mock PA session validation + if tt.cfg.paSessionErr { + suite.mockCache.On("GetPASession", context.Background(), sessionID).Return(nil, errors.New("session not found")) } else { - suite.mockCache.On("GetSubscription", context.Background(), subID).Return(activeUntil, nil) + suite.mockCache.On("GetPASession", context.Background(), sessionID).Return(&model.PASession{ + ID: sessionID, + Token: testToken, + PreauthID: preauthID, + }, nil) } email := "user@example.com" - if tt.cfg.existingAccount != nil { - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), tt.cfg.existingAccount.Email).Return(tt.cfg.existingAccount, nil) - } else if tt.cfg.subscriptionDuplicate { - // New account path; email not found - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), email).Return(nil, dbErrors.ErrAccountNotFound) - } else if tt.cfg.cacheErr == nil { // normal new account path - suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), email).Return(nil, dbErrors.ErrAccountNotFound) - } + needsSuccessPath := !tt.cfg.paSessionErr - // Duplicate subscription detection (pre-create) only for new account path - if tt.cfg.existingAccount == nil { - if tt.cfg.subscriptionDuplicate { - // GetSubscriptionById returns existing subscription object - suite.mockSubscriptionRepo.On("GetSubscriptionById", context.Background(), subID).Return(&model.Subscription{ID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")}, nil) - } else if tt.cfg.cacheErr == nil { // default non-duplicate - suite.mockSubscriptionRepo.On("GetSubscriptionById", context.Background(), subID).Return(nil, dbErrors.ErrSubscriptionNotFound) + if needsSuccessPath { + if tt.cfg.existingAccount != nil { + suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), tt.cfg.existingAccount.Email).Return(tt.cfg.existingAccount, nil) + } else { + suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), email).Return(nil, dbErrors.ErrAccountNotFound) } } - // Unfinished reuse path: ensure subscription creation if missing (finished accounts return early; no subscription calls) - if tt.cfg.existingAccount != nil && tt.cfg.existingAccount.Password == nil { - suite.mockAccountRepo.On("UpdateAccount", context.Background(), mock.MatchedBy(func(a *model.Account) bool { - if a.Email != tt.cfg.existingAccount.Email || a.Password == nil { - return false - } - return bcrypt.CompareHashAndPassword([]byte(*a.Password), []byte(password)) == nil - })).Return(tt.cfg.existingAccount, nil).Once() + // Unfinished reuse path + if needsSuccessPath && tt.cfg.existingAccount != nil && tt.cfg.existingAccount.Password == nil { + // GetSubscription for existing account if tt.cfg.subscriptionMissingForUnfinished { suite.mockSubscriptionRepo.On("GetSubscriptionByAccountId", context.Background(), tt.cfg.existingAccount.ID.Hex()).Return(nil, dbErrors.ErrSubscriptionNotFound) suite.mockSubscriptionRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(nil) } else { suite.mockSubscriptionRepo.On("GetSubscriptionByAccountId", context.Background(), tt.cfg.existingAccount.ID.Hex()).Return(&model.Subscription{}, nil) } + suite.mockAccountRepo.On("UpdateAccount", context.Background(), mock.MatchedBy(func(a *model.Account) bool { + if a.Email != tt.cfg.existingAccount.Email || a.Password == nil { + return false + } + return bcrypt.CompareHashAndPassword([]byte(*a.Password), []byte(password)) == nil + })).Return(tt.cfg.existingAccount, nil).Once() + // CompleteRegistration mocks (SignupWebhook no-op, RemovePASession, welcome email) + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(nil) suite.mockMailer.On("Verify", tt.cfg.existingAccount.Email).Return(nil) suite.mockMailer.On("SendWelcomeEmail", context.Background(), tt.cfg.existingAccount.Email, mock.AnythingOfType("string")).Return(nil) } // New account creation path expectations - if tt.cfg.existingAccount == nil && tt.cfg.cacheErr == nil && !tt.cfg.subscriptionDuplicate { - // Profile service path: list existing profiles returns empty + if needsSuccessPath && tt.cfg.existingAccount == nil { + // RegisterAccountWithPreauth: re-checks email, creates profile, subscription, account + suite.mockAccountRepo.On("GetAccountByEmail", context.Background(), email).Return(nil, dbErrors.ErrAccountNotFound) suite.mockProfileRepo.On("GetProfilesByAccountId", context.Background(), mock.AnythingOfType("string")).Return([]model.Profile{}, nil) - // ID generator for profile suite.mockIDGenerator.On("Generate").Return("profile123", nil) suite.mockBlocklistRepo.On("Get", context.Background(), map[string]any{"default": true}, "updated").Return([]*model.Blocklist{{Name: "Default Blocklist", Default: true}}, nil) suite.mockProfileRepo.On("CreateProfile", context.Background(), mock.AnythingOfType("*model.Profile")).Return(nil) suite.mockCache.On("CreateOrUpdateProfileSettings", context.Background(), mock.AnythingOfType("*model.ProfileSettings"), true).Return(nil) - suite.mockAccountRepo.On("CreateAccount", context.Background(), email, password, mock.AnythingOfType("string"), "profile123", mock.Anything).Return(&model.Account{ID: primitive.NewObjectID(), Email: email, Password: &password}, nil) suite.mockSubscriptionRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(nil) - suite.mockCache.On("RemoveSubscription", context.Background(), subID).Return(nil) + suite.mockAccountRepo.On("CreateAccount", context.Background(), email, password, mock.AnythingOfType("string"), "profile123").Return(&model.Account{ID: primitive.NewObjectID(), Email: email, Password: &password}, nil) + // CompleteRegistration mocks + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(nil) suite.mockMailer.On("Verify", email).Return(nil) suite.mockMailer.On("SendWelcomeEmail", context.Background(), email, mock.AnythingOfType("string")).Return(nil) } - // For reuse unfinished: cache removal - if tt.cfg.existingAccount != nil && tt.cfg.cacheErr == nil { - suite.mockCache.On("RemoveSubscription", context.Background(), subID).Return(nil) - } - // Execute var targetEmail string if tt.cfg.existingAccount != nil { @@ -387,7 +286,7 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { } else { targetEmail = email } - result, err := suite.service.GetUnfinishedSignupOrPostAccount(context.Background(), targetEmail, password, subID) + result, err := suite.service.GetUnfinishedSignupOrPostAccount(context.Background(), targetEmail, password, subID, sessionID) if tt.expectError != "" { suite.Error(err) @@ -398,20 +297,6 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { suite.NotNil(result) suite.Equal(targetEmail, result.Email) } - - // Cleanup expectations to avoid leaking into other suite tests - suite.mockSubscriptionRepo.AssertExpectations(suite.T()) - suite.mockSubscriptionRepo.ExpectedCalls = nil - suite.mockSubscriptionRepo.Calls = nil - suite.mockAccountRepo.AssertExpectations(suite.T()) - suite.mockAccountRepo.ExpectedCalls = nil - suite.mockAccountRepo.Calls = nil - suite.mockProfileRepo.ExpectedCalls = nil - suite.mockProfileRepo.Calls = nil - suite.mockBlocklistRepo.ExpectedCalls = nil - suite.mockBlocklistRepo.Calls = nil - suite.mockMailer.ExpectedCalls = nil - suite.mockMailer.Calls = nil }) } } diff --git a/api/service/account/verify_test.go b/api/service/account/verify_test.go index e0bc7461..737b38c9 100644 --- a/api/service/account/verify_test.go +++ b/api/service/account/verify_test.go @@ -47,7 +47,7 @@ func (suite *EmailVerificationOTPSuite) SetupSuite() { // Minimal dependencies; other repos not needed for OTP operations val := validatorv10.New() mockSubRepo := mocks.NewSubscriptionRepository(suite.T()) - subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}) + subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) suite.service = account.NewAccountService( *cfg.Service, suite.mockAccountRepo, From 5111c5f957b6c9f713f07e81b8d5b1798c9c2965 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:54:35 +0200 Subject: [PATCH 04/54] feat(app): support ZLA signup flow Signed-off-by: Maciek --- api/docs/docs.go | 206 +++++++---- api/docs/swagger.json | 206 +++++++---- api/docs/swagger.yaml | 141 +++++--- app/src/App.tsx | 3 +- app/src/api/api.ts | 1 + app/src/api/client/.gitignore | 0 app/src/api/client/.npmignore | 0 app/src/api/client/.openapi-generator-ignore | 3 - app/src/api/client/.openapi-generator/FILES | 2 + app/src/api/client/.openapi-generator/VERSION | 0 app/src/api/client/api.ts | 327 +++++++++++++----- app/src/api/client/base.ts | 0 app/src/api/client/common.ts | 0 app/src/api/client/configuration.ts | 4 + app/src/api/client/git_push.sh | 0 app/src/api/client/index.ts | 0 app/src/pages/auth/Signup.tsx | 35 +- 17 files changed, 646 insertions(+), 282 deletions(-) mode change 100755 => 100644 app/src/api/client/.gitignore mode change 100755 => 100644 app/src/api/client/.npmignore mode change 100755 => 100644 app/src/api/client/.openapi-generator-ignore mode change 100755 => 100644 app/src/api/client/.openapi-generator/FILES mode change 100755 => 100644 app/src/api/client/.openapi-generator/VERSION mode change 100755 => 100644 app/src/api/client/api.ts mode change 100755 => 100644 app/src/api/client/base.ts mode change 100755 => 100644 app/src/api/client/common.ts mode change 100755 => 100644 app/src/api/client/configuration.ts mode change 100755 => 100644 app/src/api/client/git_push.sh mode change 100755 => 100644 app/src/api/client/index.ts diff --git a/api/docs/docs.go b/api/docs/docs.go index 92206c61..eecb5866 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -703,6 +703,94 @@ const docTemplate = `{ } } }, + "/api/v1/pasession/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a pre-auth session to cache (called by preauth service)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PASession" + ], + "summary": "Add pre-auth session", + "parameters": [ + { + "description": "Pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/fiber.Map" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, + "/api/v1/pasession/rotate": { + "put": { + "description": "Rotate pre-auth session ID and set new ID as cookie", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PASession" + ], + "summary": "Rotate pre-auth session ID", + "parameters": [ + { + "description": "Rotate pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.RotatePASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles": { "get": { "security": [ @@ -1761,57 +1849,6 @@ const docTemplate = `{ } } }, - "/api/v1/subscription/add": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add subscription and cache its presence", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Add subscription", - "parameters": [ - { - "description": "Subscription request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/fiber.Map" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.ErrResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.ErrResponse" - } - } - } - } - }, "/api/v1/verify/email/otp/confirm": { "post": { "description": "Verifies the 6-digit OTP provided by the authenticated user", @@ -2540,7 +2577,10 @@ const docTemplate = `{ }, "intensity": { "description": "basic, comprehensive, restrictive", - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "kind": { "description": "general, category, security", @@ -2895,20 +2935,38 @@ const docTemplate = `{ "active_until": { "type": "string" }, - "type": { - "$ref": "#/definitions/model.SubscriptionType" + "outage": { + "type": "boolean" + }, + "status": { + "description": "Computed fields (not persisted)", + "allOf": [ + { + "$ref": "#/definitions/model.SubscriptionStatus" + } + ] + }, + "tier": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "model.SubscriptionType": { + "model.SubscriptionStatus": { "type": "string", "enum": [ - "Free", - "Managed" + "active", + "grace_period", + "limited_access", + "pending_delete" ], "x-enum-varnames": [ - "Free", - "Managed" + "StatusActive", + "StatusGracePeriod", + "StatusLimitedAccess", + "StatusPendingDelete" ] }, "model.TOTPBackup": { @@ -3444,6 +3502,25 @@ const docTemplate = `{ } } }, + "requests.PASessionReq": { + "type": "object", + "required": [ + "id", + "preauth_id", + "token" + ], + "properties": { + "id": { + "type": "string" + }, + "preauth_id": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, "requests.ProfileUpdates": { "type": "object", "required": [ @@ -3469,18 +3546,13 @@ const docTemplate = `{ } } }, - "requests.SubscriptionReq": { + "requests.RotatePASessionReq": { "type": "object", "required": [ - "active_until", - "id" + "sessionid" ], "properties": { - "active_until": { - "type": "string" - }, - "id": { - "description": "ID is the external Subscription ID (UUIDv4)", + "sessionid": { "type": "string" } } diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 68f76bdc..d3044c01 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -695,6 +695,94 @@ } } }, + "/api/v1/pasession/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a pre-auth session to cache (called by preauth service)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PASession" + ], + "summary": "Add pre-auth session", + "parameters": [ + { + "description": "Pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.PASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/fiber.Map" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, + "/api/v1/pasession/rotate": { + "put": { + "description": "Rotate pre-auth session ID and set new ID as cookie", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PASession" + ], + "summary": "Rotate pre-auth session ID", + "parameters": [ + { + "description": "Rotate pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.RotatePASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles": { "get": { "security": [ @@ -1753,57 +1841,6 @@ } } }, - "/api/v1/subscription/add": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Add subscription and cache its presence", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Add subscription", - "parameters": [ - { - "description": "Subscription request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/fiber.Map" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.ErrResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.ErrResponse" - } - } - } - } - }, "/api/v1/verify/email/otp/confirm": { "post": { "description": "Verifies the 6-digit OTP provided by the authenticated user", @@ -2532,7 +2569,10 @@ }, "intensity": { "description": "basic, comprehensive, restrictive", - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "kind": { "description": "general, category, security", @@ -2887,20 +2927,38 @@ "active_until": { "type": "string" }, - "type": { - "$ref": "#/definitions/model.SubscriptionType" + "outage": { + "type": "boolean" + }, + "status": { + "description": "Computed fields (not persisted)", + "allOf": [ + { + "$ref": "#/definitions/model.SubscriptionStatus" + } + ] + }, + "tier": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "model.SubscriptionType": { + "model.SubscriptionStatus": { "type": "string", "enum": [ - "Free", - "Managed" + "active", + "grace_period", + "limited_access", + "pending_delete" ], "x-enum-varnames": [ - "Free", - "Managed" + "StatusActive", + "StatusGracePeriod", + "StatusLimitedAccess", + "StatusPendingDelete" ] }, "model.TOTPBackup": { @@ -3436,6 +3494,25 @@ } } }, + "requests.PASessionReq": { + "type": "object", + "required": [ + "id", + "preauth_id", + "token" + ], + "properties": { + "id": { + "type": "string" + }, + "preauth_id": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, "requests.ProfileUpdates": { "type": "object", "required": [ @@ -3461,18 +3538,13 @@ } } }, - "requests.SubscriptionReq": { + "requests.RotatePASessionReq": { "type": "object", "required": [ - "active_until", - "id" + "sessionid" ], "properties": { - "active_until": { - "type": "string" - }, - "id": { - "description": "ID is the external Subscription ID (UUIDv4)", + "sessionid": { "type": "string" } } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 3cf259c0..c215efd6 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -154,7 +154,9 @@ definitions: type: string intensity: description: basic, comprehensive, restrictive - type: string + items: + type: string + type: array kind: description: general, category, security type: string @@ -407,17 +409,29 @@ definitions: properties: active_until: type: string - type: - $ref: '#/definitions/model.SubscriptionType' + outage: + type: boolean + status: + allOf: + - $ref: '#/definitions/model.SubscriptionStatus' + description: Computed fields (not persisted) + tier: + type: string + updated_at: + type: string type: object - model.SubscriptionType: + model.SubscriptionStatus: enum: - - Free - - Managed + - active + - grace_period + - limited_access + - pending_delete type: string x-enum-varnames: - - Free - - Managed + - StatusActive + - StatusGracePeriod + - StatusLimitedAccess + - StatusPendingDelete model.TOTPBackup: properties: backup_codes: @@ -825,6 +839,19 @@ definitions: required: - profile_id type: object + requests.PASessionReq: + properties: + id: + type: string + preauth_id: + type: string + token: + type: string + required: + - id + - preauth_id + - token + type: object requests.ProfileUpdates: properties: updates: @@ -841,16 +868,12 @@ definitions: required: - email type: object - requests.SubscriptionReq: + requests.RotatePASessionReq: properties: - active_until: - type: string - id: - description: ID is the external Subscription ID (UUIDv4) + sessionid: type: string required: - - active_until - - id + - sessionid type: object requests.TotpReq: properties: @@ -1426,6 +1449,62 @@ paths: summary: Generate short link for configuration profile (Apple devices) tags: - Apple mobileconfig + /api/v1/pasession/add: + post: + consumes: + - application/json + description: Add a pre-auth session to cache (called by preauth service) + parameters: + - description: Pre-auth session request + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.PASessionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/fiber.Map' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Add pre-auth session + tags: + - PASession + /api/v1/pasession/rotate: + put: + consumes: + - application/json + description: Rotate pre-auth session ID and set new ID as cookie + parameters: + - description: Rotate pre-auth session request + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.RotatePASessionReq' + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + summary: Rotate pre-auth session ID + tags: + - PASession /api/v1/profiles: get: description: Get profiles data @@ -2106,38 +2185,6 @@ paths: summary: Get subscription data tags: - Subscription - /api/v1/subscription/add: - post: - consumes: - - application/json - description: Add subscription and cache its presence - parameters: - - description: Subscription request - in: body - name: body - required: true - schema: - $ref: '#/definitions/requests.SubscriptionReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/fiber.Map' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.ErrResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.ErrResponse' - security: - - ApiKeyAuth: [] - summary: Add subscription - tags: - - Subscription /api/v1/verify/email/otp/confirm: post: consumes: diff --git a/app/src/App.tsx b/app/src/App.tsx index bc8da336..0f59eb9d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -75,7 +75,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Public route predicate (keep in sync with router public section) const isPublicPath = (p: string) => ( p === '/login' || - // Dynamic signup route requires subid; plain /signup should not be treated as public and will fall through to 404 + p === '/signup' || p.startsWith('/signup/') || p === '/tos' || p === '/privacy' || @@ -555,6 +555,7 @@ const router = createBrowserRouter([ element: , children: [ { path: "login", element: }, + { path: "signup", element: }> }, { path: "signup/:subid", element: }> }, { path: "tos", element: }> }, { path: "privacy", element: }> }, diff --git a/app/src/api/api.ts b/app/src/api/api.ts index 5694d9eb..a66f43cf 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -77,6 +77,7 @@ const Client = { appleMobileconfigApi: new client.AppleMobileconfigApi(config), sessionsApi: new client.SessionsApi(config), subscriptionApi: new client.SubscriptionApi(config), + paSessionApi: new client.PASessionApi(config), }; function clearSession() { diff --git a/app/src/api/client/.gitignore b/app/src/api/client/.gitignore old mode 100755 new mode 100644 diff --git a/app/src/api/client/.npmignore b/app/src/api/client/.npmignore old mode 100755 new mode 100644 diff --git a/app/src/api/client/.openapi-generator-ignore b/app/src/api/client/.openapi-generator-ignore old mode 100755 new mode 100644 index 087b8717..7484ee59 --- a/app/src/api/client/.openapi-generator-ignore +++ b/app/src/api/client/.openapi-generator-ignore @@ -21,6 +21,3 @@ #docs/*.md # Then explicitly reverse the ignore rule for a single file: #!docs/README.md - -# Prevent overwriting customized configuration (removed browser-unsafe User-Agent header) -configuration.ts diff --git a/app/src/api/client/.openapi-generator/FILES b/app/src/api/client/.openapi-generator/FILES old mode 100755 new mode 100644 index 3b2df1d8..16b445ee --- a/app/src/api/client/.openapi-generator/FILES +++ b/app/src/api/client/.openapi-generator/FILES @@ -1,7 +1,9 @@ .gitignore .npmignore +.openapi-generator-ignore api.ts base.ts common.ts +configuration.ts git_push.sh index.ts diff --git a/app/src/api/client/.openapi-generator/VERSION b/app/src/api/client/.openapi-generator/VERSION old mode 100755 new mode 100644 diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts old mode 100755 new mode 100644 index f561e420..28a1e0a6 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -314,10 +314,10 @@ export interface ModelBlocklist { 'id'?: string; /** * basic, comprehensive, restrictive - * @type {string} + * @type {Array} * @memberof ModelBlocklist */ - 'intensity'?: string; + 'intensity'?: Array; /** * general, category, security * @type {string} @@ -821,10 +821,28 @@ export interface ModelSubscription { 'active_until'?: string; /** * - * @type {ModelSubscriptionType} + * @type {boolean} + * @memberof ModelSubscription + */ + 'outage'?: boolean; + /** + * Computed fields (not persisted) + * @type {ModelSubscriptionStatus} + * @memberof ModelSubscription + */ + 'status'?: ModelSubscriptionStatus; + /** + * + * @type {string} * @memberof ModelSubscription */ - 'type'?: ModelSubscriptionType; + 'tier'?: string; + /** + * + * @type {string} + * @memberof ModelSubscription + */ + 'updated_at'?: string; } @@ -834,12 +852,14 @@ export interface ModelSubscription { * @enum {string} */ -export const ModelSubscriptionType = { - Free: 'Free', - Managed: 'Managed' +export const ModelSubscriptionStatus = { + StatusActive: 'active', + StatusGracePeriod: 'grace_period', + StatusLimitedAccess: 'limited_access', + StatusPendingDelete: 'pending_delete' } as const; -export type ModelSubscriptionType = typeof ModelSubscriptionType[keyof typeof ModelSubscriptionType]; +export type ModelSubscriptionStatus = typeof ModelSubscriptionStatus[keyof typeof ModelSubscriptionStatus]; /** @@ -1526,6 +1546,31 @@ export interface RequestsMobileConfigReq { */ 'profile_id': string; } +/** + * + * @export + * @interface RequestsPASessionReq + */ +export interface RequestsPASessionReq { + /** + * + * @type {string} + * @memberof RequestsPASessionReq + */ + 'id': string; + /** + * + * @type {string} + * @memberof RequestsPASessionReq + */ + 'preauth_id': string; + /** + * + * @type {string} + * @memberof RequestsPASessionReq + */ + 'token': string; +} /** * * @export @@ -1555,21 +1600,15 @@ export interface RequestsResetPasswordBody { /** * * @export - * @interface RequestsSubscriptionReq + * @interface RequestsRotatePASessionReq */ -export interface RequestsSubscriptionReq { +export interface RequestsRotatePASessionReq { /** * * @type {string} - * @memberof RequestsSubscriptionReq - */ - 'active_until': string; - /** - * ID is the external Subscription ID (UUIDv4) - * @type {string} - * @memberof RequestsSubscriptionReq + * @memberof RequestsRotatePASessionReq */ - 'id': string; + 'sessionid': string; } /** * @@ -3702,6 +3741,187 @@ export const ApiV1BlocklistsGetSortByEnum = { export type ApiV1BlocklistsGetSortByEnum = typeof ApiV1BlocklistsGetSortByEnum[keyof typeof ApiV1BlocklistsGetSortByEnum]; +/** + * PASessionApi - axios parameter creator + * @export + */ +export const PASessionApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Add a pre-auth session to cache (called by preauth service) + * @summary Add pre-auth session + * @param {RequestsPASessionReq} body Pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1PasessionAddPost: async (body: RequestsPASessionReq, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1PasessionAddPost', 'body', body) + const localVarPath = `/api/v1/pasession/add`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Rotate pre-auth session ID and set new ID as cookie + * @summary Rotate pre-auth session ID + * @param {RequestsRotatePASessionReq} body Rotate pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1PasessionRotatePut: async (body: RequestsRotatePASessionReq, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1PasessionRotatePut', 'body', body) + const localVarPath = `/api/v1/pasession/rotate`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * PASessionApi - functional programming interface + * @export + */ +export const PASessionApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = PASessionApiAxiosParamCreator(configuration) + return { + /** + * Add a pre-auth session to cache (called by preauth service) + * @summary Add pre-auth session + * @param {RequestsPASessionReq} body Pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1PasessionAddPost(body: RequestsPASessionReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1PasessionAddPost(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PASessionApi.apiV1PasessionAddPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Rotate pre-auth session ID and set new ID as cookie + * @summary Rotate pre-auth session ID + * @param {RequestsRotatePASessionReq} body Rotate pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1PasessionRotatePut(body: RequestsRotatePASessionReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1PasessionRotatePut(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PASessionApi.apiV1PasessionRotatePut']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * PASessionApi - factory interface + * @export + */ +export const PASessionApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = PASessionApiFp(configuration) + return { + /** + * Add a pre-auth session to cache (called by preauth service) + * @summary Add pre-auth session + * @param {RequestsPASessionReq} body Pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1PasessionAddPost(body: RequestsPASessionReq, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { + return localVarFp.apiV1PasessionAddPost(body, options).then((request) => request(axios, basePath)); + }, + /** + * Rotate pre-auth session ID and set new ID as cookie + * @summary Rotate pre-auth session ID + * @param {RequestsRotatePASessionReq} body Rotate pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1PasessionRotatePut(body: RequestsRotatePASessionReq, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiV1PasessionRotatePut(body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * PASessionApi - object-oriented interface + * @export + * @class PASessionApi + * @extends {BaseAPI} + */ +export class PASessionApi extends BaseAPI { + /** + * Add a pre-auth session to cache (called by preauth service) + * @summary Add pre-auth session + * @param {RequestsPASessionReq} body Pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PASessionApi + */ + public apiV1PasessionAddPost(body: RequestsPASessionReq, options?: RawAxiosRequestConfig) { + return PASessionApiFp(this.configuration).apiV1PasessionAddPost(body, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Rotate pre-auth session ID and set new ID as cookie + * @summary Rotate pre-auth session ID + * @param {RequestsRotatePASessionReq} body Rotate pre-auth session request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PASessionApi + */ + public apiV1PasessionRotatePut(body: RequestsRotatePASessionReq, options?: RawAxiosRequestConfig) { + return PASessionApiFp(this.configuration).apiV1PasessionRotatePut(body, options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * ProfileApi - axios parameter creator * @export @@ -5294,42 +5514,6 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - apiV1SubscriptionAddPost: async (body: RequestsSubscriptionReq, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'body' is not null or undefined - assertParamExists('apiV1SubscriptionAddPost', 'body', body) - const localVarPath = `/api/v1/subscription/add`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) - return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -5357,19 +5541,6 @@ export const SubscriptionApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubGet']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async apiV1SubscriptionAddPost(body: RequestsSubscriptionReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1SubscriptionAddPost(body, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubscriptionAddPost']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, } }; @@ -5389,16 +5560,6 @@ export const SubscriptionApiFactory = function (configuration?: Configuration, b apiV1SubGet(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1SubGet(options).then((request) => request(axios, basePath)); }, - /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - apiV1SubscriptionAddPost(body: RequestsSubscriptionReq, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { - return localVarFp.apiV1SubscriptionAddPost(body, options).then((request) => request(axios, basePath)); - }, }; }; @@ -5419,18 +5580,6 @@ export class SubscriptionApi extends BaseAPI { public apiV1SubGet(options?: RawAxiosRequestConfig) { return SubscriptionApiFp(this.configuration).apiV1SubGet(options).then((request) => request(this.axios, this.basePath)); } - - /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SubscriptionApi - */ - public apiV1SubscriptionAddPost(body: RequestsSubscriptionReq, options?: RawAxiosRequestConfig) { - return SubscriptionApiFp(this.configuration).apiV1SubscriptionAddPost(body, options).then((request) => request(this.axios, this.basePath)); - } } diff --git a/app/src/api/client/base.ts b/app/src/api/client/base.ts old mode 100755 new mode 100644 diff --git a/app/src/api/client/common.ts b/app/src/api/client/common.ts old mode 100755 new mode 100644 diff --git a/app/src/api/client/configuration.ts b/app/src/api/client/configuration.ts old mode 100755 new mode 100644 index 7eeac9a8..c8b7b07f --- a/app/src/api/client/configuration.ts +++ b/app/src/api/client/configuration.ts @@ -90,6 +90,10 @@ export class Configuration { this.basePath = param.basePath; this.serverIndex = param.serverIndex; this.baseOptions = { + headers: { + ...param.baseOptions?.headers, + 'User-Agent': "OpenAPI-Generator/typescript-axios" + }, ...param.baseOptions }; this.formDataCtor = param.formDataCtor; diff --git a/app/src/api/client/git_push.sh b/app/src/api/client/git_push.sh old mode 100755 new mode 100644 diff --git a/app/src/api/client/index.ts b/app/src/api/client/index.ts old mode 100755 new mode 100644 diff --git a/app/src/pages/auth/Signup.tsx b/app/src/pages/auth/Signup.tsx index a1fb6db4..da8abc49 100644 --- a/app/src/pages/auth/Signup.tsx +++ b/app/src/pages/auth/Signup.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useAuth } from "@/App"; const isUUIDv4 = (id: string): boolean => { @@ -16,11 +16,18 @@ import NotFound from "@/pages/NotFound"; export default function Signup() { const navigate = useNavigate(); - const { subid } = useParams(); + const params = useParams(); + const [searchParams] = useSearchParams(); const { isAuthenticated } = useAuth(); - const validSubId = (subid && isUUIDv4(subid)); + + // Support both query param (?subid=X&sessionid=Y) and path param (/signup/:subid) + const subid = searchParams.get("subid") || params.subid || ""; + const sessionid = searchParams.get("sessionid") || ""; + + const validSubId = subid !== "" && isUUIDv4(subid); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); useEffect(() => { if (isAuthenticated) { @@ -28,8 +35,23 @@ export default function Signup() { } }, [isAuthenticated, navigate]); + // Rotate PASession on mount when sessionid is present + useEffect(() => { + if (!sessionid || !isUUIDv4(sessionid)) return; + + setSyncing(true); + api.Client.paSessionApi + .apiV1PasessionRotatePut({ sessionid }) + .then(() => { + setSyncing(false); + }) + .catch(() => { + setError("This signup link has expired. Please request a new one from your IVPN account."); + setSyncing(false); + }); + }, [sessionid]); + if (!validSubId) { - // Missing or invalid subscription id -> 404 return ; } @@ -46,7 +68,6 @@ export default function Signup() { if (response.status === 201) { navigate("/login", { replace: true }); - // Use unified toast helper authToasts.accountCreatedSuccess(); } } catch (err) { @@ -75,8 +96,6 @@ export default function Signup() { setError(null); try { - // Register the passkey first - supply subscription id to backend flow if supported - // Passkey registration currently does not accept subscription id; will be linked after OpenAPI update await registerPasskey(email, subid!); navigate("/login", { replace: true }); authToasts.accountCreatedSuccess(); @@ -106,7 +125,7 @@ export default function Signup() { From bf38085c5041794bddb796b2647231b66547ea9b Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:55:21 +0200 Subject: [PATCH 05/54] chore: Improve Makefile Signed-off-by: Maciek --- Makefile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 808ae564..f9c20c98 100644 --- a/Makefile +++ b/Makefile @@ -156,14 +156,12 @@ dev_check: ## Starts the development dnscheck service. docker exec -it dnscheck make gow gen_python_client: ## Generates the python client from swagger spec (renamed to moddns_client, package moddns). - sudo rm -r tests/moddns_client/ || true - docker run -v ${CWD}:/app -w /app/api/docs --rm -it openapitools/openapi-generator-cli generate --package-name moddns -i swagger.yaml -g python -o /app/tests/moddns_client --skip-validate-spec - sudo chmod -R 777 tests/moddns_client/ + rm -rf tests/moddns_client/ || true + docker run -v ${CWD}:/app -w /app/api/docs --user $$(id -u):$$(id -g) --rm openapitools/openapi-generator-cli generate --package-name moddns -i swagger.yaml -g python -o /app/tests/moddns_client --skip-validate-spec gen_ts_client: ## Generates the typescript client from swagger spec. - sudo rm -r app/src/api/client/ || true - docker run -v ${CWD}:/app -w /app/api/docs --rm -it openapitools/openapi-generator-cli generate --package-name idns -i swagger.yaml -g typescript-axios -o /app/app/src/api/client --skip-validate-spec - sudo chmod -R 777 app/src/api/client/ + rm -rf app/src/api/client/ || true + docker run -v ${CWD}:/app -w /app/api/docs --user $$(id -u):$$(id -g) --rm openapitools/openapi-generator-cli generate --package-name idns -i swagger.yaml -g typescript-axios -o /app/app/src/api/client --skip-validate-spec build_tests_image: ## Builds the smoke / integration tests image. docker build -f tests/Dockerfile -t dns_tests:latest . From a1ecf9954b2de413ea48198e4cfb71b45da97e1c Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:55:48 +0200 Subject: [PATCH 06/54] chore(test): Update Python modDNS API client Signed-off-by: Maciek --- .../.github/workflows/python.yml | 0 tests/moddns_client/.gitignore | 0 tests/moddns_client/.gitlab-ci.yml | 0 tests/moddns_client/.openapi-generator-ignore | 0 tests/moddns_client/.openapi-generator/FILES | 99 ++- .../moddns_client/.openapi-generator/VERSION | 0 tests/moddns_client/.travis.yml | 0 tests/moddns_client/README.md | 8 +- tests/moddns_client/docs/AccountApi.md | 0 .../docs/ApiBlocklistsUpdates.md | 0 .../docs/ApiCreateProfileBody.md | 0 tests/moddns_client/docs/ApiErrResponse.md | 0 .../docs/ApiRegisterAccountBody.md | 0 .../moddns_client/docs/ApiServicesUpdates.md | 0 .../docs/ApiVerifyEmailOTPBody.md | 0 .../docs/ApiWebAuthnLoginBeginRequest.md | 0 .../docs/ApiWebAuthnRegisterBeginRequest.md | 0 .../docs/AppleMobileconfigApi.md | 0 tests/moddns_client/docs/AuthenticationApi.md | 0 tests/moddns_client/docs/BlocklistsApi.md | 0 tests/moddns_client/docs/ModelAccount.md | 0 .../moddns_client/docs/ModelAccountUpdate.md | 0 tests/moddns_client/docs/ModelAdvanced.md | 0 tests/moddns_client/docs/ModelBlocklist.md | 2 +- tests/moddns_client/docs/ModelCredential.md | 0 tests/moddns_client/docs/ModelCustomRule.md | 0 tests/moddns_client/docs/ModelDNSRequest.md | 0 .../moddns_client/docs/ModelDNSSECSettings.md | 0 tests/moddns_client/docs/ModelLogsSettings.md | 0 tests/moddns_client/docs/ModelMFASettings.md | 0 tests/moddns_client/docs/ModelPrivacy.md | 0 tests/moddns_client/docs/ModelProfile.md | 0 .../docs/ModelProfileSettings.md | 0 .../moddns_client/docs/ModelProfileUpdate.md | 0 tests/moddns_client/docs/ModelQueryLog.md | 0 tests/moddns_client/docs/ModelRetention.md | 0 tests/moddns_client/docs/ModelSecurity.md | 0 .../docs/ModelServicesSettings.md | 29 - .../docs/ModelStatisticsAggregated.md | 0 .../docs/ModelStatisticsSettings.md | 0 tests/moddns_client/docs/ModelSubscription.md | 5 +- .../docs/ModelSubscriptionStatus.md | 16 + .../docs/ModelSubscriptionType.md | 12 - tests/moddns_client/docs/ModelTOTPBackup.md | 0 tests/moddns_client/docs/ModelTOTPNew.md | 0 tests/moddns_client/docs/ModelTotpSettings.md | 0 tests/moddns_client/docs/PASessionApi.md | 147 +++++ tests/moddns_client/docs/ProfileApi.md | 0 .../docs/ProtocolAttestationFormat.md | 0 .../docs/ProtocolAuthenticatorAttachment.md | 0 .../docs/ProtocolAuthenticatorSelection.md | 0 .../docs/ProtocolAuthenticatorTransport.md | 0 .../docs/ProtocolConveyancePreference.md | 0 .../docs/ProtocolCredentialAssertion.md | 0 .../docs/ProtocolCredentialCreation.md | 0 .../docs/ProtocolCredentialDescriptor.md | 0 .../ProtocolCredentialMediationRequirement.md | 0 .../docs/ProtocolCredentialParameter.md | 0 .../docs/ProtocolCredentialType.md | 0 ...tocolPublicKeyCredentialCreationOptions.md | 0 .../docs/ProtocolPublicKeyCredentialHints.md | 0 ...otocolPublicKeyCredentialRequestOptions.md | 0 .../docs/ProtocolRelyingPartyEntity.md | 0 .../docs/ProtocolResidentKeyRequirement.md | 0 .../moddns_client/docs/ProtocolUserEntity.md | 0 .../ProtocolUserVerificationRequirement.md | 0 tests/moddns_client/docs/QueryLogsApi.md | 0 .../docs/RequestsAccountDeletionRequest.md | 0 .../docs/RequestsAccountUpdates.md | 0 .../docs/RequestsAdvancedOptionsReq.md | 0 .../docs/RequestsConfirmResetPasswordBody.md | 0 .../RequestsCreateProfileCustomRuleBody.md | 0 ...questsCreateProfileCustomRulesBatchBody.md | 0 tests/moddns_client/docs/RequestsLoginBody.md | 0 .../docs/RequestsMobileConfigReq.md | 0 .../docs/RequestsPASessionReq.md | 31 + .../docs/RequestsProfileUpdates.md | 0 .../docs/RequestsResetPasswordBody.md | 0 .../docs/RequestsRotatePASessionReq.md | 29 + .../docs/RequestsSubscriptionReq.md | 30 - tests/moddns_client/docs/RequestsTotpReq.md | 0 .../RequestsWebAuthnReauthBeginRequest.md | 0 ...esCreateProfileCustomRulesBatchResponse.md | 0 .../docs/ResponsesCustomRuleBatchCreated.md | 0 .../docs/ResponsesCustomRuleBatchSkipped.md | 0 .../docs/ResponsesDeletionCodeResponse.md | 0 .../ResponsesRegistrationSuccessResponse.md | 0 .../docs/ResponsesShortLinkResponse.md | 0 .../ResponsesWebAuthnReauthFinishResponse.md | 0 tests/moddns_client/docs/ServicesApi.md | 0 .../docs/ServicescatalogCatalog.md | 0 .../docs/ServicescatalogService.md | 0 tests/moddns_client/docs/SessionsApi.md | 0 tests/moddns_client/docs/StatisticsApi.md | 0 tests/moddns_client/docs/SubscriptionApi.md | 71 --- tests/moddns_client/docs/VerificationApi.md | 0 .../WebauthncoseCOSEAlgorithmIdentifier.md | 0 tests/moddns_client/git_push.sh | 0 tests/moddns_client/moddns/__init__.py | 6 +- tests/moddns_client/moddns/api/__init__.py | 1 + tests/moddns_client/moddns/api/account_api.py | 0 .../moddns/api/apple_mobileconfig_api.py | 0 .../moddns/api/authentication_api.py | 0 .../moddns/api/blocklists_api.py | 0 .../moddns/api/pa_session_api.py | 595 ++++++++++++++++++ tests/moddns_client/moddns/api/profile_api.py | 0 .../moddns/api/query_logs_api.py | 0 .../moddns_client/moddns/api/services_api.py | 0 .../moddns_client/moddns/api/sessions_api.py | 0 .../moddns/api/statistics_api.py | 0 .../moddns/api/subscription_api.py | 283 --------- .../moddns/api/verification_api.py | 0 tests/moddns_client/moddns/api_client.py | 0 tests/moddns_client/moddns/api_response.py | 0 tests/moddns_client/moddns/configuration.py | 0 tests/moddns_client/moddns/exceptions.py | 0 tests/moddns_client/moddns/models/__init__.py | 5 +- .../moddns/models/api_blocklists_updates.py | 0 .../moddns/models/api_create_profile_body.py | 0 .../moddns/models/api_err_response.py | 0 .../models/api_register_account_body.py | 0 .../moddns/models/api_services_updates.py | 0 .../models/api_verify_email_otp_body.py | 0 .../api_web_authn_login_begin_request.py | 0 .../api_web_authn_register_begin_request.py | 0 .../moddns/models/model_account.py | 0 .../moddns/models/model_account_update.py | 0 .../moddns/models/model_advanced.py | 0 .../moddns/models/model_blocklist.py | 2 +- .../moddns/models/model_credential.py | 0 .../moddns/models/model_custom_rule.py | 0 .../moddns/models/model_dns_request.py | 0 .../moddns/models/model_dnssec_settings.py | 0 .../moddns/models/model_logs_settings.py | 0 .../moddns/models/model_mfa_settings.py | 0 .../moddns/models/model_privacy.py | 0 .../moddns/models/model_profile.py | 0 .../moddns/models/model_profile_settings.py | 0 .../moddns/models/model_profile_update.py | 0 .../moddns/models/model_query_log.py | 0 .../moddns/models/model_retention.py | 0 .../moddns/models/model_security.py | 0 .../models/model_statistics_aggregated.py | 0 .../models/model_statistics_settings.py | 0 .../moddns/models/model_subscription.py | 16 +- ...n_type.py => model_subscription_status.py} | 12 +- .../moddns/models/model_totp_backup.py | 0 .../moddns/models/model_totp_new.py | 0 .../moddns/models/model_totp_settings.py | 0 .../models/protocol_attestation_format.py | 0 .../protocol_authenticator_attachment.py | 0 .../protocol_authenticator_selection.py | 0 .../protocol_authenticator_transport.py | 0 .../models/protocol_conveyance_preference.py | 0 .../models/protocol_credential_assertion.py | 0 .../models/protocol_credential_creation.py | 0 .../models/protocol_credential_descriptor.py | 0 ...otocol_credential_mediation_requirement.py | 0 .../models/protocol_credential_parameter.py | 0 .../moddns/models/protocol_credential_type.py | 0 ..._public_key_credential_creation_options.py | 0 .../protocol_public_key_credential_hints.py | 0 ...l_public_key_credential_request_options.py | 0 .../models/protocol_relying_party_entity.py | 0 .../protocol_resident_key_requirement.py | 0 .../moddns/models/protocol_user_entity.py | 0 .../protocol_user_verification_requirement.py | 0 .../requests_account_deletion_request.py | 0 .../moddns/models/requests_account_updates.py | 0 .../models/requests_advanced_options_req.py | 0 .../requests_confirm_reset_password_body.py | 0 ...equests_create_profile_custom_rule_body.py | 0 ..._create_profile_custom_rules_batch_body.py | 0 .../moddns/models/requests_login_body.py | 0 .../models/requests_mobile_config_req.py | 0 ...tion_req.py => requests_pa_session_req.py} | 22 +- .../moddns/models/requests_profile_updates.py | 0 .../models/requests_reset_password_body.py | 0 ...s.py => requests_rotate_pa_session_req.py} | 16 +- .../moddns/models/requests_totp_req.py | 0 ...requests_web_authn_reauth_begin_request.py | 0 ...ate_profile_custom_rules_batch_response.py | 0 .../responses_custom_rule_batch_created.py | 0 .../responses_custom_rule_batch_skipped.py | 0 .../responses_deletion_code_response.py | 0 ...responses_registration_success_response.py | 0 .../models/responses_short_link_response.py | 0 ...ponses_web_authn_reauth_finish_response.py | 0 .../moddns/models/servicescatalog_catalog.py | 0 .../moddns/models/servicescatalog_service.py | 0 .../webauthncose_cose_algorithm_identifier.py | 0 tests/moddns_client/moddns/py.typed | 0 tests/moddns_client/moddns/rest.py | 0 tests/moddns_client/pyproject.toml | 0 tests/moddns_client/requirements.txt | 0 tests/moddns_client/setup.cfg | 0 tests/moddns_client/setup.py | 0 tests/moddns_client/test-requirements.txt | 0 tests/moddns_client/test/__init__.py | 0 tests/moddns_client/test/test_account_api.py | 0 .../test/test_api_blocklists_updates.py | 0 .../test/test_api_create_profile_body.py | 0 .../test/test_api_err_response.py | 0 .../test/test_api_register_account_body.py | 0 .../test/test_api_services_updates.py | 0 .../test/test_api_verify_email_otp_body.py | 0 .../test_api_web_authn_login_begin_request.py | 0 ...st_api_web_authn_register_begin_request.py | 0 .../test/test_apple_mobileconfig_api.py | 0 .../test/test_authentication_api.py | 0 .../moddns_client/test/test_blocklists_api.py | 0 .../moddns_client/test/test_model_account.py | 0 .../test/test_model_account_update.py | 0 .../moddns_client/test/test_model_advanced.py | 0 .../test/test_model_blocklist.py | 5 + .../test/test_model_credential.py | 0 .../test/test_model_custom_rule.py | 0 .../test/test_model_dns_request.py | 0 .../test/test_model_dnssec_settings.py | 0 .../test/test_model_logs_settings.py | 0 .../test/test_model_mfa_settings.py | 0 .../moddns_client/test/test_model_privacy.py | 7 +- .../moddns_client/test/test_model_profile.py | 14 +- .../test/test_model_profile_settings.py | 14 +- .../test/test_model_profile_update.py | 0 .../test/test_model_query_log.py | 0 .../test/test_model_retention.py | 0 .../moddns_client/test/test_model_security.py | 0 .../test/test_model_statistics_aggregated.py | 0 .../test/test_model_statistics_settings.py | 0 .../test/test_model_subscription.py | 5 +- ...e.py => test_model_subscription_status.py} | 12 +- .../test/test_model_totp_backup.py | 0 .../moddns_client/test/test_model_totp_new.py | 0 .../test/test_model_totp_settings.py | 0 .../moddns_client/test/test_pa_session_api.py | 45 ++ tests/moddns_client/test/test_profile_api.py | 0 .../test/test_protocol_attestation_format.py | 0 .../test_protocol_authenticator_attachment.py | 0 .../test_protocol_authenticator_selection.py | 0 .../test_protocol_authenticator_transport.py | 0 .../test_protocol_conveyance_preference.py | 0 .../test_protocol_credential_assertion.py | 0 .../test/test_protocol_credential_creation.py | 0 .../test_protocol_credential_descriptor.py | 0 ...otocol_credential_mediation_requirement.py | 0 .../test_protocol_credential_parameter.py | 0 .../test/test_protocol_credential_type.py | 0 ..._public_key_credential_creation_options.py | 0 ...st_protocol_public_key_credential_hints.py | 0 ...l_public_key_credential_request_options.py | 0 .../test_protocol_relying_party_entity.py | 0 .../test_protocol_resident_key_requirement.py | 0 .../test/test_protocol_user_entity.py | 0 ..._protocol_user_verification_requirement.py | 0 .../moddns_client/test/test_query_logs_api.py | 0 .../test_requests_account_deletion_request.py | 0 .../test/test_requests_account_updates.py | 0 .../test_requests_advanced_options_req.py | 0 ...st_requests_confirm_reset_password_body.py | 0 ...equests_create_profile_custom_rule_body.py | 0 ..._create_profile_custom_rules_batch_body.py | 0 .../test/test_requests_login_body.py | 0 .../test/test_requests_mobile_config_req.py | 0 ...req.py => test_requests_pa_session_req.py} | 30 +- .../test/test_requests_profile_updates.py | 0 .../test/test_requests_reset_password_body.py | 0 ...=> test_requests_rotate_pa_session_req.py} | 27 +- .../test/test_requests_totp_req.py | 0 ...requests_web_authn_reauth_begin_request.py | 0 ...ate_profile_custom_rules_batch_response.py | 0 ...est_responses_custom_rule_batch_created.py | 0 ...est_responses_custom_rule_batch_skipped.py | 0 .../test_responses_deletion_code_response.py | 0 ...responses_registration_success_response.py | 0 .../test_responses_short_link_response.py | 0 ...ponses_web_authn_reauth_finish_response.py | 0 tests/moddns_client/test/test_services_api.py | 0 .../test/test_servicescatalog_catalog.py | 0 .../test/test_servicescatalog_service.py | 0 tests/moddns_client/test/test_sessions_api.py | 0 .../moddns_client/test/test_statistics_api.py | 0 .../test/test_subscription_api.py | 7 - .../test/test_verification_api.py | 0 ..._webauthncose_cose_algorithm_identifier.py | 0 tests/moddns_client/tox.ini | 0 286 files changed, 1074 insertions(+), 529 deletions(-) mode change 100755 => 100644 tests/moddns_client/.github/workflows/python.yml mode change 100755 => 100644 tests/moddns_client/.gitignore mode change 100755 => 100644 tests/moddns_client/.gitlab-ci.yml mode change 100755 => 100644 tests/moddns_client/.openapi-generator-ignore mode change 100755 => 100644 tests/moddns_client/.openapi-generator/FILES mode change 100755 => 100644 tests/moddns_client/.openapi-generator/VERSION mode change 100755 => 100644 tests/moddns_client/.travis.yml mode change 100755 => 100644 tests/moddns_client/README.md mode change 100755 => 100644 tests/moddns_client/docs/AccountApi.md mode change 100755 => 100644 tests/moddns_client/docs/ApiBlocklistsUpdates.md mode change 100755 => 100644 tests/moddns_client/docs/ApiCreateProfileBody.md mode change 100755 => 100644 tests/moddns_client/docs/ApiErrResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ApiRegisterAccountBody.md mode change 100755 => 100644 tests/moddns_client/docs/ApiServicesUpdates.md mode change 100755 => 100644 tests/moddns_client/docs/ApiVerifyEmailOTPBody.md mode change 100755 => 100644 tests/moddns_client/docs/ApiWebAuthnLoginBeginRequest.md mode change 100755 => 100644 tests/moddns_client/docs/ApiWebAuthnRegisterBeginRequest.md mode change 100755 => 100644 tests/moddns_client/docs/AppleMobileconfigApi.md mode change 100755 => 100644 tests/moddns_client/docs/AuthenticationApi.md mode change 100755 => 100644 tests/moddns_client/docs/BlocklistsApi.md mode change 100755 => 100644 tests/moddns_client/docs/ModelAccount.md mode change 100755 => 100644 tests/moddns_client/docs/ModelAccountUpdate.md mode change 100755 => 100644 tests/moddns_client/docs/ModelAdvanced.md mode change 100755 => 100644 tests/moddns_client/docs/ModelBlocklist.md mode change 100755 => 100644 tests/moddns_client/docs/ModelCredential.md mode change 100755 => 100644 tests/moddns_client/docs/ModelCustomRule.md mode change 100755 => 100644 tests/moddns_client/docs/ModelDNSRequest.md mode change 100755 => 100644 tests/moddns_client/docs/ModelDNSSECSettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelLogsSettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelMFASettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelPrivacy.md mode change 100755 => 100644 tests/moddns_client/docs/ModelProfile.md mode change 100755 => 100644 tests/moddns_client/docs/ModelProfileSettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelProfileUpdate.md mode change 100755 => 100644 tests/moddns_client/docs/ModelQueryLog.md mode change 100755 => 100644 tests/moddns_client/docs/ModelRetention.md mode change 100755 => 100644 tests/moddns_client/docs/ModelSecurity.md delete mode 100755 tests/moddns_client/docs/ModelServicesSettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelStatisticsAggregated.md mode change 100755 => 100644 tests/moddns_client/docs/ModelStatisticsSettings.md mode change 100755 => 100644 tests/moddns_client/docs/ModelSubscription.md create mode 100644 tests/moddns_client/docs/ModelSubscriptionStatus.md delete mode 100755 tests/moddns_client/docs/ModelSubscriptionType.md mode change 100755 => 100644 tests/moddns_client/docs/ModelTOTPBackup.md mode change 100755 => 100644 tests/moddns_client/docs/ModelTOTPNew.md mode change 100755 => 100644 tests/moddns_client/docs/ModelTotpSettings.md create mode 100644 tests/moddns_client/docs/PASessionApi.md mode change 100755 => 100644 tests/moddns_client/docs/ProfileApi.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolAttestationFormat.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolAuthenticatorAttachment.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolAuthenticatorSelection.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolAuthenticatorTransport.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolConveyancePreference.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialAssertion.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialCreation.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialDescriptor.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialMediationRequirement.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialParameter.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolCredentialType.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolPublicKeyCredentialCreationOptions.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolPublicKeyCredentialHints.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolPublicKeyCredentialRequestOptions.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolRelyingPartyEntity.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolResidentKeyRequirement.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolUserEntity.md mode change 100755 => 100644 tests/moddns_client/docs/ProtocolUserVerificationRequirement.md mode change 100755 => 100644 tests/moddns_client/docs/QueryLogsApi.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsAccountDeletionRequest.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsAccountUpdates.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsAdvancedOptionsReq.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsConfirmResetPasswordBody.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsCreateProfileCustomRuleBody.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsCreateProfileCustomRulesBatchBody.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsLoginBody.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsMobileConfigReq.md create mode 100644 tests/moddns_client/docs/RequestsPASessionReq.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsProfileUpdates.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsResetPasswordBody.md create mode 100644 tests/moddns_client/docs/RequestsRotatePASessionReq.md delete mode 100755 tests/moddns_client/docs/RequestsSubscriptionReq.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsTotpReq.md mode change 100755 => 100644 tests/moddns_client/docs/RequestsWebAuthnReauthBeginRequest.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesCreateProfileCustomRulesBatchResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesCustomRuleBatchCreated.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesCustomRuleBatchSkipped.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesDeletionCodeResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesRegistrationSuccessResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesShortLinkResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ResponsesWebAuthnReauthFinishResponse.md mode change 100755 => 100644 tests/moddns_client/docs/ServicesApi.md mode change 100755 => 100644 tests/moddns_client/docs/ServicescatalogCatalog.md mode change 100755 => 100644 tests/moddns_client/docs/ServicescatalogService.md mode change 100755 => 100644 tests/moddns_client/docs/SessionsApi.md mode change 100755 => 100644 tests/moddns_client/docs/StatisticsApi.md mode change 100755 => 100644 tests/moddns_client/docs/SubscriptionApi.md mode change 100755 => 100644 tests/moddns_client/docs/VerificationApi.md mode change 100755 => 100644 tests/moddns_client/docs/WebauthncoseCOSEAlgorithmIdentifier.md mode change 100755 => 100644 tests/moddns_client/git_push.sh mode change 100755 => 100644 tests/moddns_client/moddns/__init__.py mode change 100755 => 100644 tests/moddns_client/moddns/api/__init__.py mode change 100755 => 100644 tests/moddns_client/moddns/api/account_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/apple_mobileconfig_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/authentication_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/blocklists_api.py create mode 100644 tests/moddns_client/moddns/api/pa_session_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/profile_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/query_logs_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/services_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/sessions_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/statistics_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/subscription_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api/verification_api.py mode change 100755 => 100644 tests/moddns_client/moddns/api_client.py mode change 100755 => 100644 tests/moddns_client/moddns/api_response.py mode change 100755 => 100644 tests/moddns_client/moddns/configuration.py mode change 100755 => 100644 tests/moddns_client/moddns/exceptions.py mode change 100755 => 100644 tests/moddns_client/moddns/models/__init__.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_blocklists_updates.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_create_profile_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_err_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_register_account_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_services_updates.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_verify_email_otp_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_web_authn_login_begin_request.py mode change 100755 => 100644 tests/moddns_client/moddns/models/api_web_authn_register_begin_request.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_account.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_account_update.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_advanced.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_blocklist.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_credential.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_custom_rule.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_dns_request.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_dnssec_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_logs_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_mfa_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_privacy.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_profile.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_profile_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_profile_update.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_query_log.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_retention.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_security.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_statistics_aggregated.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_statistics_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_subscription.py rename tests/moddns_client/moddns/models/{model_subscription_type.py => model_subscription_status.py} (64%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/moddns/models/model_totp_backup.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_totp_new.py mode change 100755 => 100644 tests/moddns_client/moddns/models/model_totp_settings.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_attestation_format.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_authenticator_attachment.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_authenticator_selection.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_authenticator_transport.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_conveyance_preference.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_assertion.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_creation.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_descriptor.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_mediation_requirement.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_parameter.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_credential_type.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_public_key_credential_creation_options.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_public_key_credential_hints.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_public_key_credential_request_options.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_relying_party_entity.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_resident_key_requirement.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_user_entity.py mode change 100755 => 100644 tests/moddns_client/moddns/models/protocol_user_verification_requirement.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_account_deletion_request.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_account_updates.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_advanced_options_req.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_confirm_reset_password_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_create_profile_custom_rule_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_create_profile_custom_rules_batch_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_login_body.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_mobile_config_req.py rename tests/moddns_client/moddns/models/{requests_subscription_req.py => requests_pa_session_req.py} (79%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_profile_updates.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_reset_password_body.py rename tests/moddns_client/moddns/models/{model_services_settings.py => requests_rotate_pa_session_req.py} (83%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_totp_req.py mode change 100755 => 100644 tests/moddns_client/moddns/models/requests_web_authn_reauth_begin_request.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_create_profile_custom_rules_batch_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_custom_rule_batch_created.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_custom_rule_batch_skipped.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_deletion_code_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_registration_success_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_short_link_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/responses_web_authn_reauth_finish_response.py mode change 100755 => 100644 tests/moddns_client/moddns/models/servicescatalog_catalog.py mode change 100755 => 100644 tests/moddns_client/moddns/models/servicescatalog_service.py mode change 100755 => 100644 tests/moddns_client/moddns/models/webauthncose_cose_algorithm_identifier.py mode change 100755 => 100644 tests/moddns_client/moddns/py.typed mode change 100755 => 100644 tests/moddns_client/moddns/rest.py mode change 100755 => 100644 tests/moddns_client/pyproject.toml mode change 100755 => 100644 tests/moddns_client/requirements.txt mode change 100755 => 100644 tests/moddns_client/setup.cfg mode change 100755 => 100644 tests/moddns_client/setup.py mode change 100755 => 100644 tests/moddns_client/test-requirements.txt mode change 100755 => 100644 tests/moddns_client/test/__init__.py mode change 100755 => 100644 tests/moddns_client/test/test_account_api.py mode change 100755 => 100644 tests/moddns_client/test/test_api_blocklists_updates.py mode change 100755 => 100644 tests/moddns_client/test/test_api_create_profile_body.py mode change 100755 => 100644 tests/moddns_client/test/test_api_err_response.py mode change 100755 => 100644 tests/moddns_client/test/test_api_register_account_body.py mode change 100755 => 100644 tests/moddns_client/test/test_api_services_updates.py mode change 100755 => 100644 tests/moddns_client/test/test_api_verify_email_otp_body.py mode change 100755 => 100644 tests/moddns_client/test/test_api_web_authn_login_begin_request.py mode change 100755 => 100644 tests/moddns_client/test/test_api_web_authn_register_begin_request.py mode change 100755 => 100644 tests/moddns_client/test/test_apple_mobileconfig_api.py mode change 100755 => 100644 tests/moddns_client/test/test_authentication_api.py mode change 100755 => 100644 tests/moddns_client/test/test_blocklists_api.py mode change 100755 => 100644 tests/moddns_client/test/test_model_account.py mode change 100755 => 100644 tests/moddns_client/test/test_model_account_update.py mode change 100755 => 100644 tests/moddns_client/test/test_model_advanced.py mode change 100755 => 100644 tests/moddns_client/test/test_model_blocklist.py mode change 100755 => 100644 tests/moddns_client/test/test_model_credential.py mode change 100755 => 100644 tests/moddns_client/test/test_model_custom_rule.py mode change 100755 => 100644 tests/moddns_client/test/test_model_dns_request.py mode change 100755 => 100644 tests/moddns_client/test/test_model_dnssec_settings.py mode change 100755 => 100644 tests/moddns_client/test/test_model_logs_settings.py mode change 100755 => 100644 tests/moddns_client/test/test_model_mfa_settings.py mode change 100755 => 100644 tests/moddns_client/test/test_model_privacy.py mode change 100755 => 100644 tests/moddns_client/test/test_model_profile.py mode change 100755 => 100644 tests/moddns_client/test/test_model_profile_settings.py mode change 100755 => 100644 tests/moddns_client/test/test_model_profile_update.py mode change 100755 => 100644 tests/moddns_client/test/test_model_query_log.py mode change 100755 => 100644 tests/moddns_client/test/test_model_retention.py mode change 100755 => 100644 tests/moddns_client/test/test_model_security.py mode change 100755 => 100644 tests/moddns_client/test/test_model_statistics_aggregated.py mode change 100755 => 100644 tests/moddns_client/test/test_model_statistics_settings.py mode change 100755 => 100644 tests/moddns_client/test/test_model_subscription.py rename tests/moddns_client/test/{test_model_subscription_type.py => test_model_subscription_status.py} (54%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/test/test_model_totp_backup.py mode change 100755 => 100644 tests/moddns_client/test/test_model_totp_new.py mode change 100755 => 100644 tests/moddns_client/test/test_model_totp_settings.py create mode 100644 tests/moddns_client/test/test_pa_session_api.py mode change 100755 => 100644 tests/moddns_client/test/test_profile_api.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_attestation_format.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_authenticator_attachment.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_authenticator_selection.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_authenticator_transport.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_conveyance_preference.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_assertion.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_creation.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_descriptor.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_mediation_requirement.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_parameter.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_credential_type.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_public_key_credential_creation_options.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_public_key_credential_hints.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_public_key_credential_request_options.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_relying_party_entity.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_resident_key_requirement.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_user_entity.py mode change 100755 => 100644 tests/moddns_client/test/test_protocol_user_verification_requirement.py mode change 100755 => 100644 tests/moddns_client/test/test_query_logs_api.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_account_deletion_request.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_account_updates.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_advanced_options_req.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_confirm_reset_password_body.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_create_profile_custom_rule_body.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_create_profile_custom_rules_batch_body.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_login_body.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_mobile_config_req.py rename tests/moddns_client/test/{test_requests_subscription_req.py => test_requests_pa_session_req.py} (53%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/test/test_requests_profile_updates.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_reset_password_body.py rename tests/moddns_client/test/{test_model_services_settings.py => test_requests_rotate_pa_session_req.py} (53%) mode change 100755 => 100644 mode change 100755 => 100644 tests/moddns_client/test/test_requests_totp_req.py mode change 100755 => 100644 tests/moddns_client/test/test_requests_web_authn_reauth_begin_request.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_create_profile_custom_rules_batch_response.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_custom_rule_batch_created.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_custom_rule_batch_skipped.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_deletion_code_response.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_registration_success_response.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_short_link_response.py mode change 100755 => 100644 tests/moddns_client/test/test_responses_web_authn_reauth_finish_response.py mode change 100755 => 100644 tests/moddns_client/test/test_services_api.py mode change 100755 => 100644 tests/moddns_client/test/test_servicescatalog_catalog.py mode change 100755 => 100644 tests/moddns_client/test/test_servicescatalog_service.py mode change 100755 => 100644 tests/moddns_client/test/test_sessions_api.py mode change 100755 => 100644 tests/moddns_client/test/test_statistics_api.py mode change 100755 => 100644 tests/moddns_client/test/test_subscription_api.py mode change 100755 => 100644 tests/moddns_client/test/test_verification_api.py mode change 100755 => 100644 tests/moddns_client/test/test_webauthncose_cose_algorithm_identifier.py mode change 100755 => 100644 tests/moddns_client/tox.ini diff --git a/tests/moddns_client/.github/workflows/python.yml b/tests/moddns_client/.github/workflows/python.yml old mode 100755 new mode 100644 diff --git a/tests/moddns_client/.gitignore b/tests/moddns_client/.gitignore old mode 100755 new mode 100644 diff --git a/tests/moddns_client/.gitlab-ci.yml b/tests/moddns_client/.gitlab-ci.yml old mode 100755 new mode 100644 diff --git a/tests/moddns_client/.openapi-generator-ignore b/tests/moddns_client/.openapi-generator-ignore old mode 100755 new mode 100644 diff --git a/tests/moddns_client/.openapi-generator/FILES b/tests/moddns_client/.openapi-generator/FILES old mode 100755 new mode 100644 index 21ebfffd..5d1e39d8 --- a/tests/moddns_client/.openapi-generator/FILES +++ b/tests/moddns_client/.openapi-generator/FILES @@ -1,6 +1,7 @@ .github/workflows/python.yml .gitignore .gitlab-ci.yml +.openapi-generator-ignore .travis.yml README.md docs/AccountApi.md @@ -35,10 +36,11 @@ docs/ModelSecurity.md docs/ModelStatisticsAggregated.md docs/ModelStatisticsSettings.md docs/ModelSubscription.md -docs/ModelSubscriptionType.md +docs/ModelSubscriptionStatus.md docs/ModelTOTPBackup.md docs/ModelTOTPNew.md docs/ModelTotpSettings.md +docs/PASessionApi.md docs/ProfileApi.md docs/ProtocolAttestationFormat.md docs/ProtocolAuthenticatorAttachment.md @@ -67,9 +69,10 @@ docs/RequestsCreateProfileCustomRuleBody.md docs/RequestsCreateProfileCustomRulesBatchBody.md docs/RequestsLoginBody.md docs/RequestsMobileConfigReq.md +docs/RequestsPASessionReq.md docs/RequestsProfileUpdates.md docs/RequestsResetPasswordBody.md -docs/RequestsSubscriptionReq.md +docs/RequestsRotatePASessionReq.md docs/RequestsTotpReq.md docs/RequestsWebAuthnReauthBeginRequest.md docs/ResponsesCreateProfileCustomRulesBatchResponse.md @@ -94,6 +97,7 @@ moddns/api/account_api.py moddns/api/apple_mobileconfig_api.py moddns/api/authentication_api.py moddns/api/blocklists_api.py +moddns/api/pa_session_api.py moddns/api/profile_api.py moddns/api/query_logs_api.py moddns/api/services_api.py @@ -134,7 +138,7 @@ moddns/models/model_security.py moddns/models/model_statistics_aggregated.py moddns/models/model_statistics_settings.py moddns/models/model_subscription.py -moddns/models/model_subscription_type.py +moddns/models/model_subscription_status.py moddns/models/model_totp_backup.py moddns/models/model_totp_new.py moddns/models/model_totp_settings.py @@ -164,9 +168,10 @@ moddns/models/requests_create_profile_custom_rule_body.py moddns/models/requests_create_profile_custom_rules_batch_body.py moddns/models/requests_login_body.py moddns/models/requests_mobile_config_req.py +moddns/models/requests_pa_session_req.py moddns/models/requests_profile_updates.py moddns/models/requests_reset_password_body.py -moddns/models/requests_subscription_req.py +moddns/models/requests_rotate_pa_session_req.py moddns/models/requests_totp_req.py moddns/models/requests_web_authn_reauth_begin_request.py moddns/models/responses_create_profile_custom_rules_batch_response.py @@ -187,4 +192,90 @@ setup.cfg setup.py test-requirements.txt test/__init__.py +test/test_account_api.py +test/test_api_blocklists_updates.py +test/test_api_create_profile_body.py +test/test_api_err_response.py +test/test_api_register_account_body.py +test/test_api_services_updates.py +test/test_api_verify_email_otp_body.py +test/test_api_web_authn_login_begin_request.py +test/test_api_web_authn_register_begin_request.py +test/test_apple_mobileconfig_api.py +test/test_authentication_api.py +test/test_blocklists_api.py +test/test_model_account.py +test/test_model_account_update.py +test/test_model_advanced.py +test/test_model_blocklist.py +test/test_model_credential.py +test/test_model_custom_rule.py +test/test_model_dns_request.py +test/test_model_dnssec_settings.py +test/test_model_logs_settings.py +test/test_model_mfa_settings.py +test/test_model_privacy.py +test/test_model_profile.py +test/test_model_profile_settings.py +test/test_model_profile_update.py +test/test_model_query_log.py +test/test_model_retention.py +test/test_model_security.py +test/test_model_statistics_aggregated.py +test/test_model_statistics_settings.py +test/test_model_subscription.py +test/test_model_subscription_status.py +test/test_model_totp_backup.py +test/test_model_totp_new.py +test/test_model_totp_settings.py +test/test_pa_session_api.py +test/test_profile_api.py +test/test_protocol_attestation_format.py +test/test_protocol_authenticator_attachment.py +test/test_protocol_authenticator_selection.py +test/test_protocol_authenticator_transport.py +test/test_protocol_conveyance_preference.py +test/test_protocol_credential_assertion.py +test/test_protocol_credential_creation.py +test/test_protocol_credential_descriptor.py +test/test_protocol_credential_mediation_requirement.py +test/test_protocol_credential_parameter.py +test/test_protocol_credential_type.py +test/test_protocol_public_key_credential_creation_options.py +test/test_protocol_public_key_credential_hints.py +test/test_protocol_public_key_credential_request_options.py +test/test_protocol_relying_party_entity.py +test/test_protocol_resident_key_requirement.py +test/test_protocol_user_entity.py +test/test_protocol_user_verification_requirement.py +test/test_query_logs_api.py +test/test_requests_account_deletion_request.py +test/test_requests_account_updates.py +test/test_requests_advanced_options_req.py +test/test_requests_confirm_reset_password_body.py +test/test_requests_create_profile_custom_rule_body.py +test/test_requests_create_profile_custom_rules_batch_body.py +test/test_requests_login_body.py +test/test_requests_mobile_config_req.py +test/test_requests_pa_session_req.py +test/test_requests_profile_updates.py +test/test_requests_reset_password_body.py +test/test_requests_rotate_pa_session_req.py +test/test_requests_totp_req.py +test/test_requests_web_authn_reauth_begin_request.py +test/test_responses_create_profile_custom_rules_batch_response.py +test/test_responses_custom_rule_batch_created.py +test/test_responses_custom_rule_batch_skipped.py +test/test_responses_deletion_code_response.py +test/test_responses_registration_success_response.py +test/test_responses_short_link_response.py +test/test_responses_web_authn_reauth_finish_response.py +test/test_services_api.py +test/test_servicescatalog_catalog.py +test/test_servicescatalog_service.py +test/test_sessions_api.py +test/test_statistics_api.py +test/test_subscription_api.py +test/test_verification_api.py +test/test_webauthncose_cose_algorithm_identifier.py tox.ini diff --git a/tests/moddns_client/.openapi-generator/VERSION b/tests/moddns_client/.openapi-generator/VERSION old mode 100755 new mode 100644 diff --git a/tests/moddns_client/.travis.yml b/tests/moddns_client/.travis.yml old mode 100755 new mode 100644 diff --git a/tests/moddns_client/README.md b/tests/moddns_client/README.md old mode 100755 new mode 100644 index 954f6565..ef278838 --- a/tests/moddns_client/README.md +++ b/tests/moddns_client/README.md @@ -108,6 +108,8 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**api_v1_webauthn_register_begin_post**](docs/AuthenticationApi.md#api_v1_webauthn_register_begin_post) | **POST** /api/v1/webauthn/register/begin | Begin passkey registration *AuthenticationApi* | [**api_v1_webauthn_register_finish_post**](docs/AuthenticationApi.md#api_v1_webauthn_register_finish_post) | **POST** /api/v1/webauthn/register/finish | Finish passkey registration *BlocklistsApi* | [**api_v1_blocklists_get**](docs/BlocklistsApi.md#api_v1_blocklists_get) | **GET** /api/v1/blocklists | Get blocklists data +*PASessionApi* | [**api_v1_pasession_add_post**](docs/PASessionApi.md#api_v1_pasession_add_post) | **POST** /api/v1/pasession/add | Add pre-auth session +*PASessionApi* | [**api_v1_pasession_rotate_put**](docs/PASessionApi.md#api_v1_pasession_rotate_put) | **PUT** /api/v1/pasession/rotate | Rotate pre-auth session ID *ProfileApi* | [**api_v1_profiles_get**](docs/ProfileApi.md#api_v1_profiles_get) | **GET** /api/v1/profiles | Get profiles data *ProfileApi* | [**api_v1_profiles_id_blocklists_delete**](docs/ProfileApi.md#api_v1_profiles_id_blocklists_delete) | **DELETE** /api/v1/profiles/{id}/blocklists | Disable blocklists *ProfileApi* | [**api_v1_profiles_id_blocklists_post**](docs/ProfileApi.md#api_v1_profiles_id_blocklists_post) | **POST** /api/v1/profiles/{id}/blocklists | Enable blocklists @@ -127,7 +129,6 @@ Class | Method | HTTP request | Description *SessionsApi* | [**api_v1_sessions_delete**](docs/SessionsApi.md#api_v1_sessions_delete) | **DELETE** /api/v1/sessions | Delete all other sessions *StatisticsApi* | [**api_v1_profiles_id_statistics_get**](docs/StatisticsApi.md#api_v1_profiles_id_statistics_get) | **GET** /api/v1/profiles/{id}/statistics | Get statistics data for a profile *SubscriptionApi* | [**api_v1_sub_get**](docs/SubscriptionApi.md#api_v1_sub_get) | **GET** /api/v1/sub | Get subscription data -*SubscriptionApi* | [**api_v1_subscription_add_post**](docs/SubscriptionApi.md#api_v1_subscription_add_post) | **POST** /api/v1/subscription/add | Add subscription *VerificationApi* | [**api_v1_verify_email_otp_confirm_post**](docs/VerificationApi.md#api_v1_verify_email_otp_confirm_post) | **POST** /api/v1/verify/email/otp/confirm | Confirm email verification OTP *VerificationApi* | [**api_v1_verify_email_otp_request_post**](docs/VerificationApi.md#api_v1_verify_email_otp_request_post) | **POST** /api/v1/verify/email/otp/request | Request email verification OTP *VerificationApi* | [**api_v1_verify_reset_password_post**](docs/VerificationApi.md#api_v1_verify_reset_password_post) | **POST** /api/v1/verify/reset-password | Confirm password reset @@ -163,7 +164,7 @@ Class | Method | HTTP request | Description - [ModelStatisticsAggregated](docs/ModelStatisticsAggregated.md) - [ModelStatisticsSettings](docs/ModelStatisticsSettings.md) - [ModelSubscription](docs/ModelSubscription.md) - - [ModelSubscriptionType](docs/ModelSubscriptionType.md) + - [ModelSubscriptionStatus](docs/ModelSubscriptionStatus.md) - [ModelTOTPBackup](docs/ModelTOTPBackup.md) - [ModelTOTPNew](docs/ModelTOTPNew.md) - [ModelTotpSettings](docs/ModelTotpSettings.md) @@ -193,9 +194,10 @@ Class | Method | HTTP request | Description - [RequestsCreateProfileCustomRulesBatchBody](docs/RequestsCreateProfileCustomRulesBatchBody.md) - [RequestsLoginBody](docs/RequestsLoginBody.md) - [RequestsMobileConfigReq](docs/RequestsMobileConfigReq.md) + - [RequestsPASessionReq](docs/RequestsPASessionReq.md) - [RequestsProfileUpdates](docs/RequestsProfileUpdates.md) - [RequestsResetPasswordBody](docs/RequestsResetPasswordBody.md) - - [RequestsSubscriptionReq](docs/RequestsSubscriptionReq.md) + - [RequestsRotatePASessionReq](docs/RequestsRotatePASessionReq.md) - [RequestsTotpReq](docs/RequestsTotpReq.md) - [RequestsWebAuthnReauthBeginRequest](docs/RequestsWebAuthnReauthBeginRequest.md) - [ResponsesCreateProfileCustomRulesBatchResponse](docs/ResponsesCreateProfileCustomRulesBatchResponse.md) diff --git a/tests/moddns_client/docs/AccountApi.md b/tests/moddns_client/docs/AccountApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiBlocklistsUpdates.md b/tests/moddns_client/docs/ApiBlocklistsUpdates.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiCreateProfileBody.md b/tests/moddns_client/docs/ApiCreateProfileBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiErrResponse.md b/tests/moddns_client/docs/ApiErrResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiRegisterAccountBody.md b/tests/moddns_client/docs/ApiRegisterAccountBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiServicesUpdates.md b/tests/moddns_client/docs/ApiServicesUpdates.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiVerifyEmailOTPBody.md b/tests/moddns_client/docs/ApiVerifyEmailOTPBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiWebAuthnLoginBeginRequest.md b/tests/moddns_client/docs/ApiWebAuthnLoginBeginRequest.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ApiWebAuthnRegisterBeginRequest.md b/tests/moddns_client/docs/ApiWebAuthnRegisterBeginRequest.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/AppleMobileconfigApi.md b/tests/moddns_client/docs/AppleMobileconfigApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/AuthenticationApi.md b/tests/moddns_client/docs/AuthenticationApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/BlocklistsApi.md b/tests/moddns_client/docs/BlocklistsApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelAccount.md b/tests/moddns_client/docs/ModelAccount.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelAccountUpdate.md b/tests/moddns_client/docs/ModelAccountUpdate.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelAdvanced.md b/tests/moddns_client/docs/ModelAdvanced.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelBlocklist.md b/tests/moddns_client/docs/ModelBlocklist.md old mode 100755 new mode 100644 index 9173d573..3f9f9b22 --- a/tests/moddns_client/docs/ModelBlocklist.md +++ b/tests/moddns_client/docs/ModelBlocklist.md @@ -12,7 +12,7 @@ Name | Type | Description | Notes **entries** | **int** | | [optional] **homepage** | **str** | | [optional] **id** | **str** | | [optional] -**intensity** | **str** | basic, comprehensive, restrictive | [optional] +**intensity** | **List[str]** | basic, comprehensive, restrictive | [optional] **kind** | **str** | general, category, security | [optional] **last_modified** | **str** | | [optional] **name** | **str** | conventional blocklist name, displayed to the user | diff --git a/tests/moddns_client/docs/ModelCredential.md b/tests/moddns_client/docs/ModelCredential.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelCustomRule.md b/tests/moddns_client/docs/ModelCustomRule.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelDNSRequest.md b/tests/moddns_client/docs/ModelDNSRequest.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelDNSSECSettings.md b/tests/moddns_client/docs/ModelDNSSECSettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelLogsSettings.md b/tests/moddns_client/docs/ModelLogsSettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelMFASettings.md b/tests/moddns_client/docs/ModelMFASettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelPrivacy.md b/tests/moddns_client/docs/ModelPrivacy.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelProfile.md b/tests/moddns_client/docs/ModelProfile.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelProfileSettings.md b/tests/moddns_client/docs/ModelProfileSettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelProfileUpdate.md b/tests/moddns_client/docs/ModelProfileUpdate.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelQueryLog.md b/tests/moddns_client/docs/ModelQueryLog.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelRetention.md b/tests/moddns_client/docs/ModelRetention.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelSecurity.md b/tests/moddns_client/docs/ModelSecurity.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelServicesSettings.md b/tests/moddns_client/docs/ModelServicesSettings.md deleted file mode 100755 index 1cd21f47..00000000 --- a/tests/moddns_client/docs/ModelServicesSettings.md +++ /dev/null @@ -1,29 +0,0 @@ -# ModelServicesSettings - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**blocked** | **List[str]** | | [optional] - -## Example - -```python -from moddns.models.model_services_settings import ModelServicesSettings - -# TODO update the JSON string below -json = "{}" -# create an instance of ModelServicesSettings from a JSON string -model_services_settings_instance = ModelServicesSettings.from_json(json) -# print the JSON string representation of the object -print(ModelServicesSettings.to_json()) - -# convert the object into a dict -model_services_settings_dict = model_services_settings_instance.to_dict() -# create an instance of ModelServicesSettings from a dict -model_services_settings_from_dict = ModelServicesSettings.from_dict(model_services_settings_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/tests/moddns_client/docs/ModelStatisticsAggregated.md b/tests/moddns_client/docs/ModelStatisticsAggregated.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelStatisticsSettings.md b/tests/moddns_client/docs/ModelStatisticsSettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelSubscription.md b/tests/moddns_client/docs/ModelSubscription.md old mode 100755 new mode 100644 index 657f6e42..68ae3631 --- a/tests/moddns_client/docs/ModelSubscription.md +++ b/tests/moddns_client/docs/ModelSubscription.md @@ -6,7 +6,10 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **active_until** | **str** | | [optional] -**type** | [**ModelSubscriptionType**](ModelSubscriptionType.md) | | [optional] +**outage** | **bool** | | [optional] +**status** | [**ModelSubscriptionStatus**](ModelSubscriptionStatus.md) | Computed fields (not persisted) | [optional] +**tier** | **str** | | [optional] +**updated_at** | **str** | | [optional] ## Example diff --git a/tests/moddns_client/docs/ModelSubscriptionStatus.md b/tests/moddns_client/docs/ModelSubscriptionStatus.md new file mode 100644 index 00000000..db501a0e --- /dev/null +++ b/tests/moddns_client/docs/ModelSubscriptionStatus.md @@ -0,0 +1,16 @@ +# ModelSubscriptionStatus + + +## Enum + +* `ACTIVE` (value: `'active'`) + +* `GRACE_PERIOD` (value: `'grace_period'`) + +* `LIMITED_ACCESS` (value: `'limited_access'`) + +* `PENDING_DELETE` (value: `'pending_delete'`) + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/tests/moddns_client/docs/ModelSubscriptionType.md b/tests/moddns_client/docs/ModelSubscriptionType.md deleted file mode 100755 index 56ef91ac..00000000 --- a/tests/moddns_client/docs/ModelSubscriptionType.md +++ /dev/null @@ -1,12 +0,0 @@ -# ModelSubscriptionType - - -## Enum - -* `FREE` (value: `'Free'`) - -* `MANAGED` (value: `'Managed'`) - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/tests/moddns_client/docs/ModelTOTPBackup.md b/tests/moddns_client/docs/ModelTOTPBackup.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelTOTPNew.md b/tests/moddns_client/docs/ModelTOTPNew.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ModelTotpSettings.md b/tests/moddns_client/docs/ModelTotpSettings.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/PASessionApi.md b/tests/moddns_client/docs/PASessionApi.md new file mode 100644 index 00000000..9683c1b9 --- /dev/null +++ b/tests/moddns_client/docs/PASessionApi.md @@ -0,0 +1,147 @@ +# moddns.PASessionApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**api_v1_pasession_add_post**](PASessionApi.md#api_v1_pasession_add_post) | **POST** /api/v1/pasession/add | Add pre-auth session +[**api_v1_pasession_rotate_put**](PASessionApi.md#api_v1_pasession_rotate_put) | **PUT** /api/v1/pasession/rotate | Rotate pre-auth session ID + + +# **api_v1_pasession_add_post** +> Dict[str, object] api_v1_pasession_add_post(body) + +Add pre-auth session + +Add a pre-auth session to cache (called by preauth service) + +### Example + + +```python +import moddns +from moddns.models.requests_pa_session_req import RequestsPASessionReq +from moddns.rest import ApiException +from pprint import pprint + +# Defining the host is optional and defaults to http://localhost +# See configuration.py for a list of all supported configuration parameters. +configuration = moddns.Configuration( + host = "http://localhost" +) + + +# Enter a context with an instance of the API client +with moddns.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = moddns.PASessionApi(api_client) + body = moddns.RequestsPASessionReq() # RequestsPASessionReq | Pre-auth session request + + try: + # Add pre-auth session + api_response = api_instance.api_v1_pasession_add_post(body) + print("The response of PASessionApi->api_v1_pasession_add_post:\n") + pprint(api_response) + except Exception as e: + print("Exception when calling PASessionApi->api_v1_pasession_add_post: %s\n" % e) +``` + + + +### Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **body** | [**RequestsPASessionReq**](RequestsPASessionReq.md)| Pre-auth session request | + +### Return type + +**Dict[str, object]** + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | OK | - | +**400** | Bad Request | - | +**401** | Unauthorized | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **api_v1_pasession_rotate_put** +> api_v1_pasession_rotate_put(body) + +Rotate pre-auth session ID + +Rotate pre-auth session ID and set new ID as cookie + +### Example + + +```python +import moddns +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq +from moddns.rest import ApiException +from pprint import pprint + +# Defining the host is optional and defaults to http://localhost +# See configuration.py for a list of all supported configuration parameters. +configuration = moddns.Configuration( + host = "http://localhost" +) + + +# Enter a context with an instance of the API client +with moddns.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = moddns.PASessionApi(api_client) + body = moddns.RequestsRotatePASessionReq() # RequestsRotatePASessionReq | Rotate pre-auth session request + + try: + # Rotate pre-auth session ID + api_instance.api_v1_pasession_rotate_put(body) + except Exception as e: + print("Exception when calling PASessionApi->api_v1_pasession_rotate_put: %s\n" % e) +``` + + + +### Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **body** | [**RequestsRotatePASessionReq**](RequestsRotatePASessionReq.md)| Rotate pre-auth session request | + +### Return type + +void (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | OK | - | +**400** | Bad Request | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/tests/moddns_client/docs/ProfileApi.md b/tests/moddns_client/docs/ProfileApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolAttestationFormat.md b/tests/moddns_client/docs/ProtocolAttestationFormat.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolAuthenticatorAttachment.md b/tests/moddns_client/docs/ProtocolAuthenticatorAttachment.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolAuthenticatorSelection.md b/tests/moddns_client/docs/ProtocolAuthenticatorSelection.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolAuthenticatorTransport.md b/tests/moddns_client/docs/ProtocolAuthenticatorTransport.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolConveyancePreference.md b/tests/moddns_client/docs/ProtocolConveyancePreference.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialAssertion.md b/tests/moddns_client/docs/ProtocolCredentialAssertion.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialCreation.md b/tests/moddns_client/docs/ProtocolCredentialCreation.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialDescriptor.md b/tests/moddns_client/docs/ProtocolCredentialDescriptor.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialMediationRequirement.md b/tests/moddns_client/docs/ProtocolCredentialMediationRequirement.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialParameter.md b/tests/moddns_client/docs/ProtocolCredentialParameter.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolCredentialType.md b/tests/moddns_client/docs/ProtocolCredentialType.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolPublicKeyCredentialCreationOptions.md b/tests/moddns_client/docs/ProtocolPublicKeyCredentialCreationOptions.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolPublicKeyCredentialHints.md b/tests/moddns_client/docs/ProtocolPublicKeyCredentialHints.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolPublicKeyCredentialRequestOptions.md b/tests/moddns_client/docs/ProtocolPublicKeyCredentialRequestOptions.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolRelyingPartyEntity.md b/tests/moddns_client/docs/ProtocolRelyingPartyEntity.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolResidentKeyRequirement.md b/tests/moddns_client/docs/ProtocolResidentKeyRequirement.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolUserEntity.md b/tests/moddns_client/docs/ProtocolUserEntity.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ProtocolUserVerificationRequirement.md b/tests/moddns_client/docs/ProtocolUserVerificationRequirement.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/QueryLogsApi.md b/tests/moddns_client/docs/QueryLogsApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsAccountDeletionRequest.md b/tests/moddns_client/docs/RequestsAccountDeletionRequest.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsAccountUpdates.md b/tests/moddns_client/docs/RequestsAccountUpdates.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsAdvancedOptionsReq.md b/tests/moddns_client/docs/RequestsAdvancedOptionsReq.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsConfirmResetPasswordBody.md b/tests/moddns_client/docs/RequestsConfirmResetPasswordBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsCreateProfileCustomRuleBody.md b/tests/moddns_client/docs/RequestsCreateProfileCustomRuleBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsCreateProfileCustomRulesBatchBody.md b/tests/moddns_client/docs/RequestsCreateProfileCustomRulesBatchBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsLoginBody.md b/tests/moddns_client/docs/RequestsLoginBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsMobileConfigReq.md b/tests/moddns_client/docs/RequestsMobileConfigReq.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsPASessionReq.md b/tests/moddns_client/docs/RequestsPASessionReq.md new file mode 100644 index 00000000..dd92574f --- /dev/null +++ b/tests/moddns_client/docs/RequestsPASessionReq.md @@ -0,0 +1,31 @@ +# RequestsPASessionReq + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**id** | **str** | | +**preauth_id** | **str** | | +**token** | **str** | | + +## Example + +```python +from moddns.models.requests_pa_session_req import RequestsPASessionReq + +# TODO update the JSON string below +json = "{}" +# create an instance of RequestsPASessionReq from a JSON string +requests_pa_session_req_instance = RequestsPASessionReq.from_json(json) +# print the JSON string representation of the object +print(RequestsPASessionReq.to_json()) + +# convert the object into a dict +requests_pa_session_req_dict = requests_pa_session_req_instance.to_dict() +# create an instance of RequestsPASessionReq from a dict +requests_pa_session_req_from_dict = RequestsPASessionReq.from_dict(requests_pa_session_req_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/tests/moddns_client/docs/RequestsProfileUpdates.md b/tests/moddns_client/docs/RequestsProfileUpdates.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsResetPasswordBody.md b/tests/moddns_client/docs/RequestsResetPasswordBody.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsRotatePASessionReq.md b/tests/moddns_client/docs/RequestsRotatePASessionReq.md new file mode 100644 index 00000000..3e3a9737 --- /dev/null +++ b/tests/moddns_client/docs/RequestsRotatePASessionReq.md @@ -0,0 +1,29 @@ +# RequestsRotatePASessionReq + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**sessionid** | **str** | | + +## Example + +```python +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq + +# TODO update the JSON string below +json = "{}" +# create an instance of RequestsRotatePASessionReq from a JSON string +requests_rotate_pa_session_req_instance = RequestsRotatePASessionReq.from_json(json) +# print the JSON string representation of the object +print(RequestsRotatePASessionReq.to_json()) + +# convert the object into a dict +requests_rotate_pa_session_req_dict = requests_rotate_pa_session_req_instance.to_dict() +# create an instance of RequestsRotatePASessionReq from a dict +requests_rotate_pa_session_req_from_dict = RequestsRotatePASessionReq.from_dict(requests_rotate_pa_session_req_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/tests/moddns_client/docs/RequestsSubscriptionReq.md b/tests/moddns_client/docs/RequestsSubscriptionReq.md deleted file mode 100755 index fa296916..00000000 --- a/tests/moddns_client/docs/RequestsSubscriptionReq.md +++ /dev/null @@ -1,30 +0,0 @@ -# RequestsSubscriptionReq - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**active_until** | **str** | | -**id** | **str** | ID is the external Subscription ID (UUIDv4) | - -## Example - -```python -from moddns.models.requests_subscription_req import RequestsSubscriptionReq - -# TODO update the JSON string below -json = "{}" -# create an instance of RequestsSubscriptionReq from a JSON string -requests_subscription_req_instance = RequestsSubscriptionReq.from_json(json) -# print the JSON string representation of the object -print(RequestsSubscriptionReq.to_json()) - -# convert the object into a dict -requests_subscription_req_dict = requests_subscription_req_instance.to_dict() -# create an instance of RequestsSubscriptionReq from a dict -requests_subscription_req_from_dict = RequestsSubscriptionReq.from_dict(requests_subscription_req_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/tests/moddns_client/docs/RequestsTotpReq.md b/tests/moddns_client/docs/RequestsTotpReq.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/RequestsWebAuthnReauthBeginRequest.md b/tests/moddns_client/docs/RequestsWebAuthnReauthBeginRequest.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesCreateProfileCustomRulesBatchResponse.md b/tests/moddns_client/docs/ResponsesCreateProfileCustomRulesBatchResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesCustomRuleBatchCreated.md b/tests/moddns_client/docs/ResponsesCustomRuleBatchCreated.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesCustomRuleBatchSkipped.md b/tests/moddns_client/docs/ResponsesCustomRuleBatchSkipped.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesDeletionCodeResponse.md b/tests/moddns_client/docs/ResponsesDeletionCodeResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesRegistrationSuccessResponse.md b/tests/moddns_client/docs/ResponsesRegistrationSuccessResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesShortLinkResponse.md b/tests/moddns_client/docs/ResponsesShortLinkResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ResponsesWebAuthnReauthFinishResponse.md b/tests/moddns_client/docs/ResponsesWebAuthnReauthFinishResponse.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ServicesApi.md b/tests/moddns_client/docs/ServicesApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ServicescatalogCatalog.md b/tests/moddns_client/docs/ServicescatalogCatalog.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/ServicescatalogService.md b/tests/moddns_client/docs/ServicescatalogService.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/SessionsApi.md b/tests/moddns_client/docs/SessionsApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/StatisticsApi.md b/tests/moddns_client/docs/StatisticsApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/SubscriptionApi.md b/tests/moddns_client/docs/SubscriptionApi.md old mode 100755 new mode 100644 index 4091e62a..d96561e4 --- a/tests/moddns_client/docs/SubscriptionApi.md +++ b/tests/moddns_client/docs/SubscriptionApi.md @@ -5,7 +5,6 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- [**api_v1_sub_get**](SubscriptionApi.md#api_v1_sub_get) | **GET** /api/v1/sub | Get subscription data -[**api_v1_subscription_add_post**](SubscriptionApi.md#api_v1_subscription_add_post) | **POST** /api/v1/subscription/add | Add subscription # **api_v1_sub_get** @@ -75,73 +74,3 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **api_v1_subscription_add_post** -> Dict[str, object] api_v1_subscription_add_post(body) - -Add subscription - -Add subscription and cache its presence - -### Example - - -```python -import moddns -from moddns.models.requests_subscription_req import RequestsSubscriptionReq -from moddns.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = moddns.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -with moddns.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = moddns.SubscriptionApi(api_client) - body = moddns.RequestsSubscriptionReq() # RequestsSubscriptionReq | Subscription request - - try: - # Add subscription - api_response = api_instance.api_v1_subscription_add_post(body) - print("The response of SubscriptionApi->api_v1_subscription_add_post:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling SubscriptionApi->api_v1_subscription_add_post: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **body** | [**RequestsSubscriptionReq**](RequestsSubscriptionReq.md)| Subscription request | - -### Return type - -**Dict[str, object]** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | OK | - | -**400** | Bad Request | - | -**500** | Internal Server Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/tests/moddns_client/docs/VerificationApi.md b/tests/moddns_client/docs/VerificationApi.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/docs/WebauthncoseCOSEAlgorithmIdentifier.md b/tests/moddns_client/docs/WebauthncoseCOSEAlgorithmIdentifier.md old mode 100755 new mode 100644 diff --git a/tests/moddns_client/git_push.sh b/tests/moddns_client/git_push.sh old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/__init__.py b/tests/moddns_client/moddns/__init__.py old mode 100755 new mode 100644 index 2461443c..319d00f0 --- a/tests/moddns_client/moddns/__init__.py +++ b/tests/moddns_client/moddns/__init__.py @@ -21,6 +21,7 @@ from moddns.api.apple_mobileconfig_api import AppleMobileconfigApi from moddns.api.authentication_api import AuthenticationApi from moddns.api.blocklists_api import BlocklistsApi +from moddns.api.pa_session_api import PASessionApi from moddns.api.profile_api import ProfileApi from moddns.api.query_logs_api import QueryLogsApi from moddns.api.services_api import ServicesApi @@ -69,7 +70,7 @@ from moddns.models.model_statistics_aggregated import ModelStatisticsAggregated from moddns.models.model_statistics_settings import ModelStatisticsSettings from moddns.models.model_subscription import ModelSubscription -from moddns.models.model_subscription_type import ModelSubscriptionType +from moddns.models.model_subscription_status import ModelSubscriptionStatus from moddns.models.model_totp_backup import ModelTOTPBackup from moddns.models.model_totp_new import ModelTOTPNew from moddns.models.model_totp_settings import ModelTotpSettings @@ -99,9 +100,10 @@ from moddns.models.requests_create_profile_custom_rules_batch_body import RequestsCreateProfileCustomRulesBatchBody from moddns.models.requests_login_body import RequestsLoginBody from moddns.models.requests_mobile_config_req import RequestsMobileConfigReq +from moddns.models.requests_pa_session_req import RequestsPASessionReq from moddns.models.requests_profile_updates import RequestsProfileUpdates from moddns.models.requests_reset_password_body import RequestsResetPasswordBody -from moddns.models.requests_subscription_req import RequestsSubscriptionReq +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq from moddns.models.requests_totp_req import RequestsTotpReq from moddns.models.requests_web_authn_reauth_begin_request import RequestsWebAuthnReauthBeginRequest from moddns.models.responses_create_profile_custom_rules_batch_response import ResponsesCreateProfileCustomRulesBatchResponse diff --git a/tests/moddns_client/moddns/api/__init__.py b/tests/moddns_client/moddns/api/__init__.py old mode 100755 new mode 100644 index b53022cb..be3e99b9 --- a/tests/moddns_client/moddns/api/__init__.py +++ b/tests/moddns_client/moddns/api/__init__.py @@ -5,6 +5,7 @@ from moddns.api.apple_mobileconfig_api import AppleMobileconfigApi from moddns.api.authentication_api import AuthenticationApi from moddns.api.blocklists_api import BlocklistsApi +from moddns.api.pa_session_api import PASessionApi from moddns.api.profile_api import ProfileApi from moddns.api.query_logs_api import QueryLogsApi from moddns.api.services_api import ServicesApi diff --git a/tests/moddns_client/moddns/api/account_api.py b/tests/moddns_client/moddns/api/account_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/apple_mobileconfig_api.py b/tests/moddns_client/moddns/api/apple_mobileconfig_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/authentication_api.py b/tests/moddns_client/moddns/api/authentication_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/blocklists_api.py b/tests/moddns_client/moddns/api/blocklists_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/pa_session_api.py b/tests/moddns_client/moddns/api/pa_session_api.py new file mode 100644 index 00000000..3aa80299 --- /dev/null +++ b/tests/moddns_client/moddns/api/pa_session_api.py @@ -0,0 +1,595 @@ +# coding: utf-8 + +""" + modDNS REST API + + modDNS REST API + + The version of the OpenAPI document: 1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import Field +from typing import Any, Dict +from typing_extensions import Annotated +from moddns.models.requests_pa_session_req import RequestsPASessionReq +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq + +from moddns.api_client import ApiClient, RequestSerialized +from moddns.api_response import ApiResponse +from moddns.rest import RESTResponseType + + +class PASessionApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + def api_v1_pasession_add_post( + self, + body: Annotated[RequestsPASessionReq, Field(description="Pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> Dict[str, object]: + """Add pre-auth session + + Add a pre-auth session to cache (called by preauth service) + + :param body: Pre-auth session request (required) + :type body: RequestsPASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_add_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def api_v1_pasession_add_post_with_http_info( + self, + body: Annotated[RequestsPASessionReq, Field(description="Pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[Dict[str, object]]: + """Add pre-auth session + + Add a pre-auth session to cache (called by preauth service) + + :param body: Pre-auth session request (required) + :type body: RequestsPASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_add_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def api_v1_pasession_add_post_without_preload_content( + self, + body: Annotated[RequestsPASessionReq, Field(description="Pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Add pre-auth session + + Add a pre-auth session to cache (called by preauth service) + + :param body: Pre-auth session request (required) + :type body: RequestsPASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_add_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _api_v1_pasession_add_post_serialize( + self, + body, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if body is not None: + _body_params = body + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/api/v1/pasession/add', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + def api_v1_pasession_rotate_put( + self, + body: Annotated[RequestsRotatePASessionReq, Field(description="Rotate pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """Rotate pre-auth session ID + + Rotate pre-auth session ID and set new ID as cookie + + :param body: Rotate pre-auth session request (required) + :type body: RequestsRotatePASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_rotate_put_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + '400': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def api_v1_pasession_rotate_put_with_http_info( + self, + body: Annotated[RequestsRotatePASessionReq, Field(description="Rotate pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """Rotate pre-auth session ID + + Rotate pre-auth session ID and set new ID as cookie + + :param body: Rotate pre-auth session request (required) + :type body: RequestsRotatePASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_rotate_put_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + '400': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def api_v1_pasession_rotate_put_without_preload_content( + self, + body: Annotated[RequestsRotatePASessionReq, Field(description="Rotate pre-auth session request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Rotate pre-auth session ID + + Rotate pre-auth session ID and set new ID as cookie + + :param body: Rotate pre-auth session request (required) + :type body: RequestsRotatePASessionReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_pasession_rotate_put_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + '400': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _api_v1_pasession_rotate_put_serialize( + self, + body, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if body is not None: + _body_params = body + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='PUT', + resource_path='/api/v1/pasession/rotate', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/tests/moddns_client/moddns/api/profile_api.py b/tests/moddns_client/moddns/api/profile_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/query_logs_api.py b/tests/moddns_client/moddns/api/query_logs_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/services_api.py b/tests/moddns_client/moddns/api/services_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/sessions_api.py b/tests/moddns_client/moddns/api/sessions_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/statistics_api.py b/tests/moddns_client/moddns/api/statistics_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api/subscription_api.py b/tests/moddns_client/moddns/api/subscription_api.py old mode 100755 new mode 100644 index 62fc9912..328ffd8d --- a/tests/moddns_client/moddns/api/subscription_api.py +++ b/tests/moddns_client/moddns/api/subscription_api.py @@ -16,11 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import Field -from typing import Any, Dict -from typing_extensions import Annotated from moddns.models.model_subscription import ModelSubscription -from moddns.models.requests_subscription_req import RequestsSubscriptionReq from moddns.api_client import ApiClient, RequestSerialized from moddns.api_response import ApiResponse @@ -292,282 +288,3 @@ def _api_v1_sub_get_serialize( ) - - - @validate_call - def api_v1_subscription_add_post( - self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> Dict[str, object]: - """Add subscription - - Add subscription and cache its presence - - :param body: Subscription request (required) - :type body: RequestsSubscriptionReq - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._api_v1_subscription_add_post_serialize( - body=body, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "Dict[str, object]", - '400': "ApiErrResponse", - '500': "ApiErrResponse", - } - response_data = self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ).data - - - @validate_call - def api_v1_subscription_add_post_with_http_info( - self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[Dict[str, object]]: - """Add subscription - - Add subscription and cache its presence - - :param body: Subscription request (required) - :type body: RequestsSubscriptionReq - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._api_v1_subscription_add_post_serialize( - body=body, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "Dict[str, object]", - '400': "ApiErrResponse", - '500': "ApiErrResponse", - } - response_data = self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ) - - - @validate_call - def api_v1_subscription_add_post_without_preload_content( - self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """Add subscription - - Add subscription and cache its presence - - :param body: Subscription request (required) - :type body: RequestsSubscriptionReq - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._api_v1_subscription_add_post_serialize( - body=body, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "Dict[str, object]", - '400': "ApiErrResponse", - '500': "ApiErrResponse", - } - response_data = self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _api_v1_subscription_add_post_serialize( - self, - body, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - # process the query parameters - # process the header parameters - # process the form parameters - # process the body parameter - if body is not None: - _body_params = body - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - # set the HTTP header `Content-Type` - if _content_type: - _header_params['Content-Type'] = _content_type - else: - _default_content_type = ( - self.api_client.select_header_content_type( - [ - 'application/json' - ] - ) - ) - if _default_content_type is not None: - _header_params['Content-Type'] = _default_content_type - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='POST', - resource_path='/api/v1/subscription/add', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - diff --git a/tests/moddns_client/moddns/api/verification_api.py b/tests/moddns_client/moddns/api/verification_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api_client.py b/tests/moddns_client/moddns/api_client.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/api_response.py b/tests/moddns_client/moddns/api_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/configuration.py b/tests/moddns_client/moddns/configuration.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/exceptions.py b/tests/moddns_client/moddns/exceptions.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/__init__.py b/tests/moddns_client/moddns/models/__init__.py old mode 100755 new mode 100644 index 739cd5ea..62e2972e --- a/tests/moddns_client/moddns/models/__init__.py +++ b/tests/moddns_client/moddns/models/__init__.py @@ -42,7 +42,7 @@ from moddns.models.model_statistics_aggregated import ModelStatisticsAggregated from moddns.models.model_statistics_settings import ModelStatisticsSettings from moddns.models.model_subscription import ModelSubscription -from moddns.models.model_subscription_type import ModelSubscriptionType +from moddns.models.model_subscription_status import ModelSubscriptionStatus from moddns.models.model_totp_backup import ModelTOTPBackup from moddns.models.model_totp_new import ModelTOTPNew from moddns.models.model_totp_settings import ModelTotpSettings @@ -72,9 +72,10 @@ from moddns.models.requests_create_profile_custom_rules_batch_body import RequestsCreateProfileCustomRulesBatchBody from moddns.models.requests_login_body import RequestsLoginBody from moddns.models.requests_mobile_config_req import RequestsMobileConfigReq +from moddns.models.requests_pa_session_req import RequestsPASessionReq from moddns.models.requests_profile_updates import RequestsProfileUpdates from moddns.models.requests_reset_password_body import RequestsResetPasswordBody -from moddns.models.requests_subscription_req import RequestsSubscriptionReq +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq from moddns.models.requests_totp_req import RequestsTotpReq from moddns.models.requests_web_authn_reauth_begin_request import RequestsWebAuthnReauthBeginRequest from moddns.models.responses_create_profile_custom_rules_batch_response import ResponsesCreateProfileCustomRulesBatchResponse diff --git a/tests/moddns_client/moddns/models/api_blocklists_updates.py b/tests/moddns_client/moddns/models/api_blocklists_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_create_profile_body.py b/tests/moddns_client/moddns/models/api_create_profile_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_err_response.py b/tests/moddns_client/moddns/models/api_err_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_register_account_body.py b/tests/moddns_client/moddns/models/api_register_account_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_services_updates.py b/tests/moddns_client/moddns/models/api_services_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_verify_email_otp_body.py b/tests/moddns_client/moddns/models/api_verify_email_otp_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_web_authn_login_begin_request.py b/tests/moddns_client/moddns/models/api_web_authn_login_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/api_web_authn_register_begin_request.py b/tests/moddns_client/moddns/models/api_web_authn_register_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_account.py b/tests/moddns_client/moddns/models/model_account.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_account_update.py b/tests/moddns_client/moddns/models/model_account_update.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_advanced.py b/tests/moddns_client/moddns/models/model_advanced.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_blocklist.py b/tests/moddns_client/moddns/models/model_blocklist.py old mode 100755 new mode 100644 index 5f75accb..5c6c2780 --- a/tests/moddns_client/moddns/models/model_blocklist.py +++ b/tests/moddns_client/moddns/models/model_blocklist.py @@ -33,7 +33,7 @@ class ModelBlocklist(BaseModel): entries: Optional[StrictInt] = None homepage: Optional[StrictStr] = None id: Optional[StrictStr] = None - intensity: Optional[StrictStr] = Field(default=None, description="basic, comprehensive, restrictive") + intensity: Optional[List[StrictStr]] = Field(default=None, description="basic, comprehensive, restrictive") kind: Optional[StrictStr] = Field(default=None, description="general, category, security") last_modified: Optional[StrictStr] = None name: StrictStr = Field(description="conventional blocklist name, displayed to the user") diff --git a/tests/moddns_client/moddns/models/model_credential.py b/tests/moddns_client/moddns/models/model_credential.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_custom_rule.py b/tests/moddns_client/moddns/models/model_custom_rule.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_dns_request.py b/tests/moddns_client/moddns/models/model_dns_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_dnssec_settings.py b/tests/moddns_client/moddns/models/model_dnssec_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_logs_settings.py b/tests/moddns_client/moddns/models/model_logs_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_mfa_settings.py b/tests/moddns_client/moddns/models/model_mfa_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_privacy.py b/tests/moddns_client/moddns/models/model_privacy.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_profile.py b/tests/moddns_client/moddns/models/model_profile.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_profile_settings.py b/tests/moddns_client/moddns/models/model_profile_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_profile_update.py b/tests/moddns_client/moddns/models/model_profile_update.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_query_log.py b/tests/moddns_client/moddns/models/model_query_log.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_retention.py b/tests/moddns_client/moddns/models/model_retention.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_security.py b/tests/moddns_client/moddns/models/model_security.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_statistics_aggregated.py b/tests/moddns_client/moddns/models/model_statistics_aggregated.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_statistics_settings.py b/tests/moddns_client/moddns/models/model_statistics_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_subscription.py b/tests/moddns_client/moddns/models/model_subscription.py old mode 100755 new mode 100644 index a62f9b0d..9d78b245 --- a/tests/moddns_client/moddns/models/model_subscription.py +++ b/tests/moddns_client/moddns/models/model_subscription.py @@ -17,9 +17,9 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr from typing import Any, ClassVar, Dict, List, Optional -from moddns.models.model_subscription_type import ModelSubscriptionType +from moddns.models.model_subscription_status import ModelSubscriptionStatus from typing import Optional, Set from typing_extensions import Self @@ -28,8 +28,11 @@ class ModelSubscription(BaseModel): ModelSubscription """ # noqa: E501 active_until: Optional[StrictStr] = None - type: Optional[ModelSubscriptionType] = None - __properties: ClassVar[List[str]] = ["active_until", "type"] + outage: Optional[StrictBool] = None + status: Optional[ModelSubscriptionStatus] = Field(default=None, description="Computed fields (not persisted)") + tier: Optional[StrictStr] = None + updated_at: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["active_until", "outage", "status", "tier", "updated_at"] model_config = ConfigDict( populate_by_name=True, @@ -83,7 +86,10 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "active_until": obj.get("active_until"), - "type": obj.get("type") + "outage": obj.get("outage"), + "status": obj.get("status"), + "tier": obj.get("tier"), + "updated_at": obj.get("updated_at") }) return _obj diff --git a/tests/moddns_client/moddns/models/model_subscription_type.py b/tests/moddns_client/moddns/models/model_subscription_status.py old mode 100755 new mode 100644 similarity index 64% rename from tests/moddns_client/moddns/models/model_subscription_type.py rename to tests/moddns_client/moddns/models/model_subscription_status.py index 015b49f2..e9bdeca0 --- a/tests/moddns_client/moddns/models/model_subscription_type.py +++ b/tests/moddns_client/moddns/models/model_subscription_status.py @@ -18,20 +18,22 @@ from typing_extensions import Self -class ModelSubscriptionType(str, Enum): +class ModelSubscriptionStatus(str, Enum): """ - ModelSubscriptionType + ModelSubscriptionStatus """ """ allowed enum values """ - FREE = 'Free' - MANAGED = 'Managed' + ACTIVE = 'active' + GRACE_PERIOD = 'grace_period' + LIMITED_ACCESS = 'limited_access' + PENDING_DELETE = 'pending_delete' @classmethod def from_json(cls, json_str: str) -> Self: - """Create an instance of ModelSubscriptionType from a JSON string""" + """Create an instance of ModelSubscriptionStatus from a JSON string""" return cls(json.loads(json_str)) diff --git a/tests/moddns_client/moddns/models/model_totp_backup.py b/tests/moddns_client/moddns/models/model_totp_backup.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_totp_new.py b/tests/moddns_client/moddns/models/model_totp_new.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_totp_settings.py b/tests/moddns_client/moddns/models/model_totp_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_attestation_format.py b/tests/moddns_client/moddns/models/protocol_attestation_format.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_authenticator_attachment.py b/tests/moddns_client/moddns/models/protocol_authenticator_attachment.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_authenticator_selection.py b/tests/moddns_client/moddns/models/protocol_authenticator_selection.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_authenticator_transport.py b/tests/moddns_client/moddns/models/protocol_authenticator_transport.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_conveyance_preference.py b/tests/moddns_client/moddns/models/protocol_conveyance_preference.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_assertion.py b/tests/moddns_client/moddns/models/protocol_credential_assertion.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_creation.py b/tests/moddns_client/moddns/models/protocol_credential_creation.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_descriptor.py b/tests/moddns_client/moddns/models/protocol_credential_descriptor.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_mediation_requirement.py b/tests/moddns_client/moddns/models/protocol_credential_mediation_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_parameter.py b/tests/moddns_client/moddns/models/protocol_credential_parameter.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_credential_type.py b/tests/moddns_client/moddns/models/protocol_credential_type.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_public_key_credential_creation_options.py b/tests/moddns_client/moddns/models/protocol_public_key_credential_creation_options.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_public_key_credential_hints.py b/tests/moddns_client/moddns/models/protocol_public_key_credential_hints.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_public_key_credential_request_options.py b/tests/moddns_client/moddns/models/protocol_public_key_credential_request_options.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_relying_party_entity.py b/tests/moddns_client/moddns/models/protocol_relying_party_entity.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_resident_key_requirement.py b/tests/moddns_client/moddns/models/protocol_resident_key_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_user_entity.py b/tests/moddns_client/moddns/models/protocol_user_entity.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/protocol_user_verification_requirement.py b/tests/moddns_client/moddns/models/protocol_user_verification_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_account_deletion_request.py b/tests/moddns_client/moddns/models/requests_account_deletion_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_account_updates.py b/tests/moddns_client/moddns/models/requests_account_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_advanced_options_req.py b/tests/moddns_client/moddns/models/requests_advanced_options_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_confirm_reset_password_body.py b/tests/moddns_client/moddns/models/requests_confirm_reset_password_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_create_profile_custom_rule_body.py b/tests/moddns_client/moddns/models/requests_create_profile_custom_rule_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_create_profile_custom_rules_batch_body.py b/tests/moddns_client/moddns/models/requests_create_profile_custom_rules_batch_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_login_body.py b/tests/moddns_client/moddns/models/requests_login_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_mobile_config_req.py b/tests/moddns_client/moddns/models/requests_mobile_config_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_subscription_req.py b/tests/moddns_client/moddns/models/requests_pa_session_req.py old mode 100755 new mode 100644 similarity index 79% rename from tests/moddns_client/moddns/models/requests_subscription_req.py rename to tests/moddns_client/moddns/models/requests_pa_session_req.py index 50c83099..32b9d5ba --- a/tests/moddns_client/moddns/models/requests_subscription_req.py +++ b/tests/moddns_client/moddns/models/requests_pa_session_req.py @@ -17,18 +17,19 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, StrictStr from typing import Any, ClassVar, Dict, List from typing import Optional, Set from typing_extensions import Self -class RequestsSubscriptionReq(BaseModel): +class RequestsPASessionReq(BaseModel): """ - RequestsSubscriptionReq + RequestsPASessionReq """ # noqa: E501 - active_until: StrictStr - id: StrictStr = Field(description="ID is the external Subscription ID (UUIDv4)") - __properties: ClassVar[List[str]] = ["active_until", "id"] + id: StrictStr + preauth_id: StrictStr + token: StrictStr + __properties: ClassVar[List[str]] = ["id", "preauth_id", "token"] model_config = ConfigDict( populate_by_name=True, @@ -48,7 +49,7 @@ def to_json(self) -> str: @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of RequestsSubscriptionReq from a JSON string""" + """Create an instance of RequestsPASessionReq from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -73,7 +74,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RequestsSubscriptionReq from a dict""" + """Create an instance of RequestsPASessionReq from a dict""" if obj is None: return None @@ -81,8 +82,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "active_until": obj.get("active_until"), - "id": obj.get("id") + "id": obj.get("id"), + "preauth_id": obj.get("preauth_id"), + "token": obj.get("token") }) return _obj diff --git a/tests/moddns_client/moddns/models/requests_profile_updates.py b/tests/moddns_client/moddns/models/requests_profile_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_reset_password_body.py b/tests/moddns_client/moddns/models/requests_reset_password_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/model_services_settings.py b/tests/moddns_client/moddns/models/requests_rotate_pa_session_req.py old mode 100755 new mode 100644 similarity index 83% rename from tests/moddns_client/moddns/models/model_services_settings.py rename to tests/moddns_client/moddns/models/requests_rotate_pa_session_req.py index cbc9a72d..995e70bd --- a/tests/moddns_client/moddns/models/model_services_settings.py +++ b/tests/moddns_client/moddns/models/requests_rotate_pa_session_req.py @@ -18,16 +18,16 @@ import json from pydantic import BaseModel, ConfigDict, StrictStr -from typing import Any, ClassVar, Dict, List, Optional +from typing import Any, ClassVar, Dict, List from typing import Optional, Set from typing_extensions import Self -class ModelServicesSettings(BaseModel): +class RequestsRotatePASessionReq(BaseModel): """ - ModelServicesSettings + RequestsRotatePASessionReq """ # noqa: E501 - blocked: Optional[List[StrictStr]] = None - __properties: ClassVar[List[str]] = ["blocked"] + sessionid: StrictStr + __properties: ClassVar[List[str]] = ["sessionid"] model_config = ConfigDict( populate_by_name=True, @@ -47,7 +47,7 @@ def to_json(self) -> str: @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of ModelServicesSettings from a JSON string""" + """Create an instance of RequestsRotatePASessionReq from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -72,7 +72,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ModelServicesSettings from a dict""" + """Create an instance of RequestsRotatePASessionReq from a dict""" if obj is None: return None @@ -80,7 +80,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "blocked": obj.get("blocked") + "sessionid": obj.get("sessionid") }) return _obj diff --git a/tests/moddns_client/moddns/models/requests_totp_req.py b/tests/moddns_client/moddns/models/requests_totp_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/requests_web_authn_reauth_begin_request.py b/tests/moddns_client/moddns/models/requests_web_authn_reauth_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_create_profile_custom_rules_batch_response.py b/tests/moddns_client/moddns/models/responses_create_profile_custom_rules_batch_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_custom_rule_batch_created.py b/tests/moddns_client/moddns/models/responses_custom_rule_batch_created.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_custom_rule_batch_skipped.py b/tests/moddns_client/moddns/models/responses_custom_rule_batch_skipped.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_deletion_code_response.py b/tests/moddns_client/moddns/models/responses_deletion_code_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_registration_success_response.py b/tests/moddns_client/moddns/models/responses_registration_success_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_short_link_response.py b/tests/moddns_client/moddns/models/responses_short_link_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/responses_web_authn_reauth_finish_response.py b/tests/moddns_client/moddns/models/responses_web_authn_reauth_finish_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/servicescatalog_catalog.py b/tests/moddns_client/moddns/models/servicescatalog_catalog.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/servicescatalog_service.py b/tests/moddns_client/moddns/models/servicescatalog_service.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/models/webauthncose_cose_algorithm_identifier.py b/tests/moddns_client/moddns/models/webauthncose_cose_algorithm_identifier.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/py.typed b/tests/moddns_client/moddns/py.typed old mode 100755 new mode 100644 diff --git a/tests/moddns_client/moddns/rest.py b/tests/moddns_client/moddns/rest.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/pyproject.toml b/tests/moddns_client/pyproject.toml old mode 100755 new mode 100644 diff --git a/tests/moddns_client/requirements.txt b/tests/moddns_client/requirements.txt old mode 100755 new mode 100644 diff --git a/tests/moddns_client/setup.cfg b/tests/moddns_client/setup.cfg old mode 100755 new mode 100644 diff --git a/tests/moddns_client/setup.py b/tests/moddns_client/setup.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test-requirements.txt b/tests/moddns_client/test-requirements.txt old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/__init__.py b/tests/moddns_client/test/__init__.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_account_api.py b/tests/moddns_client/test/test_account_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_blocklists_updates.py b/tests/moddns_client/test/test_api_blocklists_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_create_profile_body.py b/tests/moddns_client/test/test_api_create_profile_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_err_response.py b/tests/moddns_client/test/test_api_err_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_register_account_body.py b/tests/moddns_client/test/test_api_register_account_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_services_updates.py b/tests/moddns_client/test/test_api_services_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_verify_email_otp_body.py b/tests/moddns_client/test/test_api_verify_email_otp_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_web_authn_login_begin_request.py b/tests/moddns_client/test/test_api_web_authn_login_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_api_web_authn_register_begin_request.py b/tests/moddns_client/test/test_api_web_authn_register_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_apple_mobileconfig_api.py b/tests/moddns_client/test/test_apple_mobileconfig_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_authentication_api.py b/tests/moddns_client/test/test_authentication_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_blocklists_api.py b/tests/moddns_client/test/test_blocklists_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_account.py b/tests/moddns_client/test/test_model_account.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_account_update.py b/tests/moddns_client/test/test_model_account_update.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_advanced.py b/tests/moddns_client/test/test_model_advanced.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_blocklist.py b/tests/moddns_client/test/test_model_blocklist.py old mode 100755 new mode 100644 index 9a20f334..77143763 --- a/tests/moddns_client/test/test_model_blocklist.py +++ b/tests/moddns_client/test/test_model_blocklist.py @@ -36,11 +36,16 @@ def make_instance(self, include_optional) -> ModelBlocklist: if include_optional: return ModelBlocklist( blocklist_id = '', + category = '', default = True, description = '', entries = 56, homepage = '', id = '', + intensity = [ + '' + ], + kind = '', last_modified = '', name = '', source_url = '', diff --git a/tests/moddns_client/test/test_model_credential.py b/tests/moddns_client/test/test_model_credential.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_custom_rule.py b/tests/moddns_client/test/test_model_custom_rule.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_dns_request.py b/tests/moddns_client/test/test_model_dns_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_dnssec_settings.py b/tests/moddns_client/test/test_model_dnssec_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_logs_settings.py b/tests/moddns_client/test/test_model_logs_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_mfa_settings.py b/tests/moddns_client/test/test_model_mfa_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_privacy.py b/tests/moddns_client/test/test_model_privacy.py old mode 100755 new mode 100644 index 6f6c1f81..9398ca19 --- a/tests/moddns_client/test/test_model_privacy.py +++ b/tests/moddns_client/test/test_model_privacy.py @@ -41,10 +41,9 @@ def make_instance(self, include_optional) -> ModelPrivacy: blocklists_subdomains_rule = 'block', custom_rules_subdomains_rule = 'include', default_rule = 'block', - services = moddns.models.model/services_settings.model.ServicesSettings( - blocked = [ - '' - ], ) + services = [ + '' + ] ) else: return ModelPrivacy( diff --git a/tests/moddns_client/test/test_model_profile.py b/tests/moddns_client/test/test_model_profile.py old mode 100755 new mode 100644 index dee96b84..58ff0e70 --- a/tests/moddns_client/test/test_model_profile.py +++ b/tests/moddns_client/test/test_model_profile.py @@ -60,10 +60,9 @@ def make_instance(self, include_optional) -> ModelProfile: blocklists_subdomains_rule = 'block', custom_rules_subdomains_rule = 'include', default_rule = 'block', - services = moddns.models.model/services_settings.model.ServicesSettings( - blocked = [ - '' - ], ), ), + services = [ + '' + ], ), profile_id = '', security = moddns.models.model/security.model.Security( dnssec = moddns.models.model/dnssec_settings.model.DNSSECSettings( @@ -99,10 +98,9 @@ def make_instance(self, include_optional) -> ModelProfile: blocklists_subdomains_rule = 'block', custom_rules_subdomains_rule = 'include', default_rule = 'block', - services = moddns.models.model/services_settings.model.ServicesSettings( - blocked = [ - '' - ], ), ), + services = [ + '' + ], ), profile_id = '', security = moddns.models.model/security.model.Security( dnssec = moddns.models.model/dnssec_settings.model.DNSSECSettings( diff --git a/tests/moddns_client/test/test_model_profile_settings.py b/tests/moddns_client/test/test_model_profile_settings.py old mode 100755 new mode 100644 index 22da18b2..ed6697eb --- a/tests/moddns_client/test/test_model_profile_settings.py +++ b/tests/moddns_client/test/test_model_profile_settings.py @@ -55,10 +55,9 @@ def make_instance(self, include_optional) -> ModelProfileSettings: blocklists_subdomains_rule = 'block', custom_rules_subdomains_rule = 'include', default_rule = 'block', - services = moddns.models.model/services_settings.model.ServicesSettings( - blocked = [ - '' - ], ), ), + services = [ + '' + ], ), profile_id = '', security = moddns.models.model/security.model.Security( dnssec = moddns.models.model/dnssec_settings.model.DNSSECSettings( @@ -83,10 +82,9 @@ def make_instance(self, include_optional) -> ModelProfileSettings: blocklists_subdomains_rule = 'block', custom_rules_subdomains_rule = 'include', default_rule = 'block', - services = moddns.models.model/services_settings.model.ServicesSettings( - blocked = [ - '' - ], ), ), + services = [ + '' + ], ), profile_id = '', security = moddns.models.model/security.model.Security( dnssec = moddns.models.model/dnssec_settings.model.DNSSECSettings( diff --git a/tests/moddns_client/test/test_model_profile_update.py b/tests/moddns_client/test/test_model_profile_update.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_query_log.py b/tests/moddns_client/test/test_model_query_log.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_retention.py b/tests/moddns_client/test/test_model_retention.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_security.py b/tests/moddns_client/test/test_model_security.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_statistics_aggregated.py b/tests/moddns_client/test/test_model_statistics_aggregated.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_statistics_settings.py b/tests/moddns_client/test/test_model_statistics_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_subscription.py b/tests/moddns_client/test/test_model_subscription.py old mode 100755 new mode 100644 index e0fedcd3..103689ef --- a/tests/moddns_client/test/test_model_subscription.py +++ b/tests/moddns_client/test/test_model_subscription.py @@ -36,7 +36,10 @@ def make_instance(self, include_optional) -> ModelSubscription: if include_optional: return ModelSubscription( active_until = '', - type = 'Free' + outage = True, + status = 'active', + tier = '', + updated_at = '' ) else: return ModelSubscription( diff --git a/tests/moddns_client/test/test_model_subscription_type.py b/tests/moddns_client/test/test_model_subscription_status.py old mode 100755 new mode 100644 similarity index 54% rename from tests/moddns_client/test/test_model_subscription_type.py rename to tests/moddns_client/test/test_model_subscription_status.py index 69c0d5cc..62429f3a --- a/tests/moddns_client/test/test_model_subscription_type.py +++ b/tests/moddns_client/test/test_model_subscription_status.py @@ -14,10 +14,10 @@ import unittest -from moddns.models.model_subscription_type import ModelSubscriptionType +from moddns.models.model_subscription_status import ModelSubscriptionStatus -class TestModelSubscriptionType(unittest.TestCase): - """ModelSubscriptionType unit test stubs""" +class TestModelSubscriptionStatus(unittest.TestCase): + """ModelSubscriptionStatus unit test stubs""" def setUp(self): pass @@ -25,9 +25,9 @@ def setUp(self): def tearDown(self): pass - def testModelSubscriptionType(self): - """Test ModelSubscriptionType""" - # inst = ModelSubscriptionType() + def testModelSubscriptionStatus(self): + """Test ModelSubscriptionStatus""" + # inst = ModelSubscriptionStatus() if __name__ == '__main__': unittest.main() diff --git a/tests/moddns_client/test/test_model_totp_backup.py b/tests/moddns_client/test/test_model_totp_backup.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_totp_new.py b/tests/moddns_client/test/test_model_totp_new.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_totp_settings.py b/tests/moddns_client/test/test_model_totp_settings.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_pa_session_api.py b/tests/moddns_client/test/test_pa_session_api.py new file mode 100644 index 00000000..d8a0c32a --- /dev/null +++ b/tests/moddns_client/test/test_pa_session_api.py @@ -0,0 +1,45 @@ +# coding: utf-8 + +""" + modDNS REST API + + modDNS REST API + + The version of the OpenAPI document: 1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +import unittest + +from moddns.api.pa_session_api import PASessionApi + + +class TestPASessionApi(unittest.TestCase): + """PASessionApi unit test stubs""" + + def setUp(self) -> None: + self.api = PASessionApi() + + def tearDown(self) -> None: + pass + + def test_api_v1_pasession_add_post(self) -> None: + """Test case for api_v1_pasession_add_post + + Add pre-auth session + """ + pass + + def test_api_v1_pasession_rotate_put(self) -> None: + """Test case for api_v1_pasession_rotate_put + + Rotate pre-auth session ID + """ + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/moddns_client/test/test_profile_api.py b/tests/moddns_client/test/test_profile_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_attestation_format.py b/tests/moddns_client/test/test_protocol_attestation_format.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_authenticator_attachment.py b/tests/moddns_client/test/test_protocol_authenticator_attachment.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_authenticator_selection.py b/tests/moddns_client/test/test_protocol_authenticator_selection.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_authenticator_transport.py b/tests/moddns_client/test/test_protocol_authenticator_transport.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_conveyance_preference.py b/tests/moddns_client/test/test_protocol_conveyance_preference.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_assertion.py b/tests/moddns_client/test/test_protocol_credential_assertion.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_creation.py b/tests/moddns_client/test/test_protocol_credential_creation.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_descriptor.py b/tests/moddns_client/test/test_protocol_credential_descriptor.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_mediation_requirement.py b/tests/moddns_client/test/test_protocol_credential_mediation_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_parameter.py b/tests/moddns_client/test/test_protocol_credential_parameter.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_credential_type.py b/tests/moddns_client/test/test_protocol_credential_type.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_public_key_credential_creation_options.py b/tests/moddns_client/test/test_protocol_public_key_credential_creation_options.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_public_key_credential_hints.py b/tests/moddns_client/test/test_protocol_public_key_credential_hints.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_public_key_credential_request_options.py b/tests/moddns_client/test/test_protocol_public_key_credential_request_options.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_relying_party_entity.py b/tests/moddns_client/test/test_protocol_relying_party_entity.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_resident_key_requirement.py b/tests/moddns_client/test/test_protocol_resident_key_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_user_entity.py b/tests/moddns_client/test/test_protocol_user_entity.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_protocol_user_verification_requirement.py b/tests/moddns_client/test/test_protocol_user_verification_requirement.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_query_logs_api.py b/tests/moddns_client/test/test_query_logs_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_account_deletion_request.py b/tests/moddns_client/test/test_requests_account_deletion_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_account_updates.py b/tests/moddns_client/test/test_requests_account_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_advanced_options_req.py b/tests/moddns_client/test/test_requests_advanced_options_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_confirm_reset_password_body.py b/tests/moddns_client/test/test_requests_confirm_reset_password_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_create_profile_custom_rule_body.py b/tests/moddns_client/test/test_requests_create_profile_custom_rule_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_create_profile_custom_rules_batch_body.py b/tests/moddns_client/test/test_requests_create_profile_custom_rules_batch_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_login_body.py b/tests/moddns_client/test/test_requests_login_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_mobile_config_req.py b/tests/moddns_client/test/test_requests_mobile_config_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_subscription_req.py b/tests/moddns_client/test/test_requests_pa_session_req.py old mode 100755 new mode 100644 similarity index 53% rename from tests/moddns_client/test/test_requests_subscription_req.py rename to tests/moddns_client/test/test_requests_pa_session_req.py index e84193aa..39d49f6b --- a/tests/moddns_client/test/test_requests_subscription_req.py +++ b/tests/moddns_client/test/test_requests_pa_session_req.py @@ -14,10 +14,10 @@ import unittest -from moddns.models.requests_subscription_req import RequestsSubscriptionReq +from moddns.models.requests_pa_session_req import RequestsPASessionReq -class TestRequestsSubscriptionReq(unittest.TestCase): - """RequestsSubscriptionReq unit test stubs""" +class TestRequestsPASessionReq(unittest.TestCase): + """RequestsPASessionReq unit test stubs""" def setUp(self): pass @@ -25,28 +25,30 @@ def setUp(self): def tearDown(self): pass - def make_instance(self, include_optional) -> RequestsSubscriptionReq: - """Test RequestsSubscriptionReq + def make_instance(self, include_optional) -> RequestsPASessionReq: + """Test RequestsPASessionReq include_optional is a boolean, when False only required params are included, when True both required and optional params are included """ - # uncomment below to create an instance of `RequestsSubscriptionReq` + # uncomment below to create an instance of `RequestsPASessionReq` """ - model = RequestsSubscriptionReq() + model = RequestsPASessionReq() if include_optional: - return RequestsSubscriptionReq( - active_until = '', - id = '' + return RequestsPASessionReq( + id = '', + preauth_id = '', + token = '' ) else: - return RequestsSubscriptionReq( - active_until = '', + return RequestsPASessionReq( id = '', + preauth_id = '', + token = '', ) """ - def testRequestsSubscriptionReq(self): - """Test RequestsSubscriptionReq""" + def testRequestsPASessionReq(self): + """Test RequestsPASessionReq""" # inst_req_only = self.make_instance(include_optional=False) # inst_req_and_optional = self.make_instance(include_optional=True) diff --git a/tests/moddns_client/test/test_requests_profile_updates.py b/tests/moddns_client/test/test_requests_profile_updates.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_reset_password_body.py b/tests/moddns_client/test/test_requests_reset_password_body.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_model_services_settings.py b/tests/moddns_client/test/test_requests_rotate_pa_session_req.py old mode 100755 new mode 100644 similarity index 53% rename from tests/moddns_client/test/test_model_services_settings.py rename to tests/moddns_client/test/test_requests_rotate_pa_session_req.py index f1a3e354..b8ad1fb8 --- a/tests/moddns_client/test/test_model_services_settings.py +++ b/tests/moddns_client/test/test_requests_rotate_pa_session_req.py @@ -14,10 +14,10 @@ import unittest -from moddns.models.model_services_settings import ModelServicesSettings +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq -class TestModelServicesSettings(unittest.TestCase): - """ModelServicesSettings unit test stubs""" +class TestRequestsRotatePASessionReq(unittest.TestCase): + """RequestsRotatePASessionReq unit test stubs""" def setUp(self): pass @@ -25,27 +25,26 @@ def setUp(self): def tearDown(self): pass - def make_instance(self, include_optional) -> ModelServicesSettings: - """Test ModelServicesSettings + def make_instance(self, include_optional) -> RequestsRotatePASessionReq: + """Test RequestsRotatePASessionReq include_optional is a boolean, when False only required params are included, when True both required and optional params are included """ - # uncomment below to create an instance of `ModelServicesSettings` + # uncomment below to create an instance of `RequestsRotatePASessionReq` """ - model = ModelServicesSettings() + model = RequestsRotatePASessionReq() if include_optional: - return ModelServicesSettings( - blocked = [ - '' - ] + return RequestsRotatePASessionReq( + sessionid = '' ) else: - return ModelServicesSettings( + return RequestsRotatePASessionReq( + sessionid = '', ) """ - def testModelServicesSettings(self): - """Test ModelServicesSettings""" + def testRequestsRotatePASessionReq(self): + """Test RequestsRotatePASessionReq""" # inst_req_only = self.make_instance(include_optional=False) # inst_req_and_optional = self.make_instance(include_optional=True) diff --git a/tests/moddns_client/test/test_requests_totp_req.py b/tests/moddns_client/test/test_requests_totp_req.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_requests_web_authn_reauth_begin_request.py b/tests/moddns_client/test/test_requests_web_authn_reauth_begin_request.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_create_profile_custom_rules_batch_response.py b/tests/moddns_client/test/test_responses_create_profile_custom_rules_batch_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_custom_rule_batch_created.py b/tests/moddns_client/test/test_responses_custom_rule_batch_created.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_custom_rule_batch_skipped.py b/tests/moddns_client/test/test_responses_custom_rule_batch_skipped.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_deletion_code_response.py b/tests/moddns_client/test/test_responses_deletion_code_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_registration_success_response.py b/tests/moddns_client/test/test_responses_registration_success_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_short_link_response.py b/tests/moddns_client/test/test_responses_short_link_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_responses_web_authn_reauth_finish_response.py b/tests/moddns_client/test/test_responses_web_authn_reauth_finish_response.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_services_api.py b/tests/moddns_client/test/test_services_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_servicescatalog_catalog.py b/tests/moddns_client/test/test_servicescatalog_catalog.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_servicescatalog_service.py b/tests/moddns_client/test/test_servicescatalog_service.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_sessions_api.py b/tests/moddns_client/test/test_sessions_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_statistics_api.py b/tests/moddns_client/test/test_statistics_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_subscription_api.py b/tests/moddns_client/test/test_subscription_api.py old mode 100755 new mode 100644 index 6953158a..ccc01306 --- a/tests/moddns_client/test/test_subscription_api.py +++ b/tests/moddns_client/test/test_subscription_api.py @@ -33,13 +33,6 @@ def test_api_v1_sub_get(self) -> None: """ pass - def test_api_v1_subscription_add_post(self) -> None: - """Test case for api_v1_subscription_add_post - - Add subscription - """ - pass - if __name__ == '__main__': unittest.main() diff --git a/tests/moddns_client/test/test_verification_api.py b/tests/moddns_client/test/test_verification_api.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/test/test_webauthncose_cose_algorithm_identifier.py b/tests/moddns_client/test/test_webauthncose_cose_algorithm_identifier.py old mode 100755 new mode 100644 diff --git a/tests/moddns_client/tox.ini b/tests/moddns_client/tox.ini old mode 100755 new mode 100644 From d838d6509664d7923820fba0b394ded58954963e Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 13:56:22 +0200 Subject: [PATCH 07/54] chore(api): Update subscription unit test Signed-off-by: Maciek --- api/service/subscription/service_test.go | 156 ----------------------- 1 file changed, 156 deletions(-) diff --git a/api/service/subscription/service_test.go b/api/service/subscription/service_test.go index 1c890701..0e1dae29 100644 --- a/api/service/subscription/service_test.go +++ b/api/service/subscription/service_test.go @@ -1,157 +1 @@ package subscription - -import ( - "context" - "testing" - "time" - - "github.com/ivpn/dns/api/config" - dbErrors "github.com/ivpn/dns/api/db/errors" - "github.com/ivpn/dns/api/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func TestCreateSubscription(t *testing.T) { - mockRepo := mocks.NewSubscriptionRepository(t) - mockCache := mocks.NewCachecache(t) - serv := NewSubscriptionService(mockRepo, mockCache, config.ServiceConfig{SubscriptionCacheExpiration: 0}) - - accID := "507f1f77bcf86cd799439011" - subID := "550e8400-e29b-41d4-a716-446655440000" // valid UUIDv4 - activeUntilStr := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) - - tests := []struct { - name string - setup func() - accountID string - subscriptionID string - activeUntil string - wantErr error - }{ - { - name: "success", - setup: func() { - mockRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(nil).Once() - }, - accountID: accID, - subscriptionID: subID, - activeUntil: activeUntilStr, - wantErr: nil, - }, - { - name: "invalid account id", - setup: func() {}, - accountID: "not-a-hex-objectid", - subscriptionID: subID, - activeUntil: activeUntilStr, - wantErr: primitive.ErrInvalidHex, - }, - { - name: "invalid subscription id", - setup: func() {}, - accountID: accID, - subscriptionID: "not-a-uuid", - activeUntil: activeUntilStr, - wantErr: assert.AnError, // placeholder for assertion branch - }, - { - name: "duplicate subscription UUID", - setup: func() { - mockRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(dbErrors.ErrSubscriptionAlreadyExists).Once() - }, - accountID: accID, - subscriptionID: subID, - activeUntil: activeUntilStr, - wantErr: dbErrors.ErrSubscriptionAlreadyExists, - }, - { - name: "repository error", - setup: func() { - mockRepo.On("Create", context.Background(), mock.AnythingOfType("model.Subscription")).Return(assert.AnError).Once() - }, - accountID: accID, - subscriptionID: subID, - activeUntil: activeUntilStr, - wantErr: assert.AnError, - }, - } - - for _, tc := range tests { - mockRepo.ExpectedCalls = nil - mockRepo.Calls = nil - mockCache.ExpectedCalls = nil - mockCache.Calls = nil - if tc.setup != nil { - tc.setup() - } - err := serv.CreateSubscription(context.Background(), tc.accountID, tc.subscriptionID, tc.activeUntil) - if tc.wantErr == nil { - assert.NoError(t, err, tc.name) - } else { - assert.Error(t, err, tc.name) - if tc.name == "invalid subscription id" { - assert.Contains(t, err.Error(), "invalid", tc.name) - } else if tc.wantErr == primitive.ErrInvalidHex { - assert.Equal(t, tc.wantErr.Error(), err.Error(), tc.name) - } else if tc.wantErr == dbErrors.ErrSubscriptionAlreadyExists || tc.wantErr == assert.AnError { - assert.Equal(t, tc.wantErr.Error(), err.Error(), tc.name) - } - } - mockRepo.AssertExpectations(t) - } -} - -func TestAddSubscription(t *testing.T) { - mockRepo := mocks.NewSubscriptionRepository(t) - mockCache := mocks.NewCachecache(t) - cfg := config.ServiceConfig{SubscriptionCacheExpiration: time.Minute} - serv := NewSubscriptionService(mockRepo, mockCache, cfg) - - accID := "507f1f77bcf86cd799439011" - activeUntil := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) - - tests := []struct { - name string - setup func() - id string - ts string - wantErr error - }{ - { - name: "success", - setup: func() { - mockCache.On("AddSubscription", mock.Anything, accID, activeUntil, cfg.SubscriptionCacheExpiration).Return(nil).Once() - }, - id: accID, - ts: activeUntil, - wantErr: nil, - }, - { - name: "cache error", - setup: func() { - mockCache.On("AddSubscription", mock.Anything, accID, activeUntil, cfg.SubscriptionCacheExpiration).Return(assert.AnError).Once() - }, - id: accID, - ts: activeUntil, - wantErr: assert.AnError, - }, - } - - for _, tc := range tests { - mockCache.ExpectedCalls = nil - mockCache.Calls = nil - if tc.setup != nil { - tc.setup() - } - err := serv.AddSubscription(context.Background(), tc.id, tc.ts) - if tc.wantErr == nil { - assert.NoError(t, err, tc.name) - } else { - assert.Error(t, err, tc.name) - assert.Equal(t, tc.wantErr.Error(), err.Error(), tc.name) - } - mockCache.AssertExpectations(t) - } -} From 193f4d71445d3250304bd0f3d690807c5fac5069 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 14:02:51 +0200 Subject: [PATCH 08/54] chore(app): Deprecate signup/:subid route Signed-off-by: Maciek --- app/src/App.tsx | 2 -- app/src/pages/auth/Signup.tsx | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 0f59eb9d..47aafbc3 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -76,7 +76,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const isPublicPath = (p: string) => ( p === '/login' || p === '/signup' || - p.startsWith('/signup/') || p === '/tos' || p === '/privacy' || p === '/faq' || @@ -556,7 +555,6 @@ const router = createBrowserRouter([ children: [ { path: "login", element: }, { path: "signup", element: }> }, - { path: "signup/:subid", element: }> }, { path: "tos", element: }> }, { path: "privacy", element: }> }, { path: "faq", element: }> }, diff --git a/app/src/pages/auth/Signup.tsx b/app/src/pages/auth/Signup.tsx index da8abc49..b3febd5a 100644 --- a/app/src/pages/auth/Signup.tsx +++ b/app/src/pages/auth/Signup.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "@/App"; const isUUIDv4 = (id: string): boolean => { @@ -16,12 +16,10 @@ import NotFound from "@/pages/NotFound"; export default function Signup() { const navigate = useNavigate(); - const params = useParams(); const [searchParams] = useSearchParams(); const { isAuthenticated } = useAuth(); - // Support both query param (?subid=X&sessionid=Y) and path param (/signup/:subid) - const subid = searchParams.get("subid") || params.subid || ""; + const subid = searchParams.get("subid") || ""; const sessionid = searchParams.get("sessionid") || ""; const validSubId = subid !== "" && isUUIDv4(subid); From 54c7847c49d6d30111f0645e26a4166c46c489cc Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 14:26:01 +0200 Subject: [PATCH 09/54] test(e2e): Update E2E tests with updated sign up flow Signed-off-by: Maciek --- tests/bootstrap/mock-preauth/server.py | 55 ++++++++++++ tests/config/api.env | 5 ++ tests/conftest.py | 87 ++++++++++++++----- tests/dns_tests/test_basic.py | 3 +- tests/dns_tests/test_cross_phase_filtering.py | 2 +- tests/dns_tests/test_multiple_users.py | 3 +- tests/docker-compose.yml | 17 ++++ 7 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 tests/bootstrap/mock-preauth/server.py diff --git a/tests/bootstrap/mock-preauth/server.py b/tests/bootstrap/mock-preauth/server.py new file mode 100644 index 00000000..d260b0ed --- /dev/null +++ b/tests/bootstrap/mock-preauth/server.py @@ -0,0 +1,55 @@ +"""Minimal mock preauth server for integration tests. + +Stores preauth entries in memory. The test creates entries via POST /entry, +and the API service fetches them via GET /. + +Endpoints: + POST /entry - Create preauth entry (called by test setup) + Body: {"id": "...", "token_hash": "...", "is_active": true, "active_until": "...", "tier": "..."} + Returns: 201 + + GET / - Get preauth entry (called by API during registration) + Returns: 200 with preauth JSON, or 404 +""" + +import json +from http.server import HTTPServer, BaseHTTPRequestHandler + +entries: dict[str, dict] = {} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path == "/entry": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + entries[body["id"]] = body + self.send_response(201) + self.end_headers() + return + self.send_response(404) + self.end_headers() + + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.end_headers() + return + entry_id = self.path.lstrip("/") + if entry_id in entries: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(entries[entry_id]).encode()) + return + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # suppress logs + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 8080), Handler) + print("Mock preauth server listening on :8080") + server.serve_forever() diff --git a/tests/config/api.env b/tests/config/api.env index 766220d5..c5f11f26 100644 --- a/tests/config/api.env +++ b/tests/config/api.env @@ -9,6 +9,11 @@ OTP_EXPIRATION=5m MOBILECONFIG_PRIVATE_KEY_PATH="/certs/private_key.pem" MOBILECONFIG_CERT_PATH="/certs/certificate.pem" +# ## ZLA PRE-AUTH CONFIG +PREAUTH_URL=http://mock-preauth:8080 +PREAUTH_PSK= +PREAUTH_TTL=60m + # ## API CONFIG API_PORT=:3000 API_SESSION_EXPIRATION=8h diff --git a/tests/conftest.py b/tests/conftest.py index 5522dcc3..353802d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,12 +13,17 @@ from retry import retry from testcontainers.compose import DockerCompose +import hashlib +import base64 +import requests as http_requests + import moddns.api_client as client import moddns.api as api import moddns.configuration as api_config from moddns import RequestsLoginBody -from moddns.models.requests_subscription_req import RequestsSubscriptionReq -from moddns.api.subscription_api import SubscriptionApi +from moddns.api.pa_session_api import PASessionApi +from moddns.models.requests_pa_session_req import RequestsPASessionReq +from moddns.models.requests_rotate_pa_session_req import RequestsRotatePASessionReq from helpers import generate_complex_password from libs.settings import get_settings @@ -94,39 +99,73 @@ def create_account_and_login(): # print(f"Warning: Failed to delete test account {account.id}: {str(e)}") -def create_temp_subscription(validity_days: int = 30) -> str: - """Provision a temporary subscription using the public API instead of direct Redis write. +def create_temp_subscription(validity_days: int = 30) -> tuple[str, str]: + """Provision a pre-auth session (PASession) for the ZLA signup flow. Flow: - 1. Generate UUIDv4 subscription id - 2. Compute ActiveUntil (UTC RFC3339) - 3. Call POST /api/v1/subscription/add with PSK bearer token (API_PSK env var) - 4. Return subscription id - - The backend will cache the subscription presence with its configured TTL. + 1. Generate a random token and compute its SHA256 hash + 2. Create a preauth entry in the mock preauth service + 3. Call POST /api/v1/pasession/add with PSK to cache the PASession + 4. Call PUT /api/v1/pasession/rotate to get a rotated session cookie + 5. Return (subscription_id, pa_session_cookie) """ + config = get_settings() + subscription_id = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + preauth_id = str(uuid.uuid4()) + token = str(uuid.uuid4()) # random token + active_until_dt = datetime.utcnow().replace(tzinfo=timezone.utc) + timedelta( days=validity_days ) active_until = active_until_dt.isoformat().replace("+00:00", "Z") - config = get_settings() + # Compute token hash (SHA256, base64-encoded) matching what the API validates + token_hash = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode() + + # 1. Create preauth entry in mock preauth service + mock_preauth_url = _os.getenv("MOCK_PREAUTH_URL", "http://localhost:8080") + http_requests.post( + f"{mock_preauth_url}/entry", + json={ + "id": preauth_id, + "token_hash": token_hash, + "is_active": True, + "active_until": active_until, + "tier": "Tier 2", + }, + ).raise_for_status() + + # 2. Add PASession via API (PSK-protected endpoint) api_conf = api_config.Configuration(host=config.DNS_API_ADDR) - psk = "" # _os.getenv("API_PSK", "supersecretpsk") # empty PSK works fine if no PSK is set in API .env + psk = "" # empty PSK works if no PSK is set in API .env with client.ApiClient(api_conf) as api_client: - sub_api = SubscriptionApi(api_client) - # Provide PSK via Authorization header - sub_api.api_client.default_headers["Authorization"] = f"Bearer {psk}" - body = RequestsSubscriptionReq(id=subscription_id, active_until=active_until) - resp = sub_api.api_v1_subscription_add_post(body=body) - # Expect 200 with message + pa_api = PASessionApi(api_client) + pa_api.api_client.default_headers["Authorization"] = f"Bearer {psk}" + body = RequestsPASessionReq(id=session_id, preauth_id=preauth_id, token=token) + resp = pa_api.api_v1_pasession_add_post(body=body) assert ( - resp.get("message") == "subscription added" - ), f"Unexpected subscription add response: {resp}" + resp.get("message") == "pre-auth session added" + ), f"Unexpected PASession add response: {resp}" + + # 3. Rotate PASession to get cookie + with client.ApiClient(api_conf) as api_client: + pa_api = PASessionApi(api_client) + rotate_body = RequestsRotatePASessionReq(sessionid=session_id) + rotate_resp = pa_api.api_v1_pasession_rotate_put_with_http_info( + body=rotate_body + ) + assert rotate_resp.status_code == 200, ( + f"PASession rotation failed: {rotate_resp.status_code}" + ) + pa_cookie = rotate_resp.headers.get("Set-Cookie", "") + assert "pa_session=" in pa_cookie, ( + f"No pa_session cookie in rotation response: {pa_cookie}" + ) - return subscription_id + return subscription_id, pa_cookie def create_acc_and_login_func(): @@ -149,9 +188,11 @@ def create_acc_and_login_func(): ) password = generate_complex_password() - # Prepare subscription marker in cache - subscription_id = create_temp_subscription() + # Prepare PASession for ZLA signup flow + subscription_id, pa_cookie = create_temp_subscription() + # Set pa_session cookie for registration + account_api.api_client.default_headers["Cookie"] = pa_cookie reg_resp = account_api.api_v1_accounts_post_with_http_info( body={"email": email, "password": password, "subid": subscription_id} ) diff --git a/tests/dns_tests/test_basic.py b/tests/dns_tests/test_basic.py index dc120e45..743f8ce3 100644 --- a/tests/dns_tests/test_basic.py +++ b/tests/dns_tests/test_basic.py @@ -43,9 +43,10 @@ async def test_regular_account(self): api_instance = api.AccountApi(api_client) password = generate_complex_password() - subscription_id = create_temp_subscription() + subscription_id, pa_cookie = create_temp_subscription() email = f"test{''.join(random.choice(string.digits) for i in range(5))}@ivpn.net" + api_instance.api_client.default_headers["Cookie"] = pa_cookie reg_resp = api_instance.api_v1_accounts_post( body={"email": email, "password": password, "subid": subscription_id} ) diff --git a/tests/dns_tests/test_cross_phase_filtering.py b/tests/dns_tests/test_cross_phase_filtering.py index 28363f1f..ca65e1f6 100644 --- a/tests/dns_tests/test_cross_phase_filtering.py +++ b/tests/dns_tests/test_cross_phase_filtering.py @@ -2,7 +2,7 @@ Tests interactions between domain-phase (pre-resolve) and IP-phase (post-resolve) filters, covering scenarios from the behaviour table -in docs/proxy-filtering-behaviour.md. +in docs/specs/proxy-filtering-behaviour.md. Test domains (controlled via testhosts.txt -> sdns hostsfile): - test.com -> 104.18.74.230 (Cloudflare AS13335, NOT in catalog) diff --git a/tests/dns_tests/test_multiple_users.py b/tests/dns_tests/test_multiple_users.py index 1da4e9be..3720ae2f 100644 --- a/tests/dns_tests/test_multiple_users.py +++ b/tests/dns_tests/test_multiple_users.py @@ -38,11 +38,12 @@ async def test_multiple_temporary_accounts_sending_doh_requests(self): # Create multiple accounts with subscription markers profiles: list[str] = [] for idx in range(4): - subscription_id = create_temp_subscription() + subscription_id, pa_cookie = create_temp_subscription() email = f"test{''.join(random.choice(string.digits) for i in range(5))}@ivpn.net" password = generate_complex_password() # Register account (201 expected, no account object returned) + api_instance.api_client.default_headers["Cookie"] = pa_cookie api_instance.api_v1_accounts_post( body={ "email": email, diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 2f1cd9af..58dd1715 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -9,6 +9,7 @@ services: depends_on: - cache - mongodb + - mock-preauth ports: - 3000:3000 volumes: @@ -241,6 +242,22 @@ services: timeout: 5s retries: 5 + mock-preauth: + container_name: mock-preauth + image: python:3.12-slim + networks: + - dnsnetwork + ports: + - '8080:8080' + volumes: + - ./bootstrap/mock-preauth:/app:ro + command: ["python", "/app/server.py"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 5s + timeout: 3s + retries: 3 + email: container_name: email image: axllent/mailpit:v1.24.2 From 1485178b9902ee2814496a95bc6fa7abd316e609 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 8 Apr 2026 14:40:34 +0200 Subject: [PATCH 10/54] chore(api): Format files Signed-off-by: Maciek --- api/api/requests/subscription.go | 1 - api/cache/redis.go | 1 - api/config/config.go | 36 ++++++++++++++++---------------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/api/api/requests/subscription.go b/api/api/requests/subscription.go index 22d4a7cf..71832f83 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -5,4 +5,3 @@ import "github.com/ivpn/dns/api/model" type SubscriptionUpdates struct { Updates []model.SubscriptionUpdate `json:"updates" validate:"required,dive"` } - diff --git a/api/cache/redis.go b/api/cache/redis.go index a2663585..b8f3c7c6 100644 --- a/api/cache/redis.go +++ b/api/cache/redis.go @@ -281,7 +281,6 @@ func (c *RedisCache) AddCustomRule(ctx context.Context, profileId string, custom return nil } - func (c *RedisCache) RemoveCustomRule(ctx context.Context, profileId, customRuleId string) error { customRuleHash := fmt.Sprintf("settings:%s:custom_rule:%s", profileId, customRuleId) hashCmd := c.client.Del(ctx, customRuleHash) diff --git a/api/config/config.go b/api/config/config.go index 817d1933..8275481b 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -39,15 +39,15 @@ type Config struct { // ServiceConfig represents the service configuration type ServiceConfig struct { - OTPExpirationTime time.Duration - MobileConfigPrivateKeyPath string - MobileConfigCertPath string - IdLimiterMax int - IdLimiterExpiration time.Duration - MaxProfiles int - MaxCredentials int - ServicesCatalogPath string - ServicesCatalogReloadEvery time.Duration + OTPExpirationTime time.Duration + MobileConfigPrivateKeyPath string + MobileConfigCertPath string + IdLimiterMax int + IdLimiterExpiration time.Duration + MaxProfiles int + MaxCredentials int + ServicesCatalogPath string + ServicesCatalogReloadEvery time.Duration } // SentryConfig represents the Sentry configuration @@ -244,15 +244,15 @@ func New() (*Config, error) { AuthToken: os.Getenv("EMAIL_SENDER_AUTH_TOKEN"), }, Service: &ServiceConfig{ - OTPExpirationTime: otpExp, - MobileConfigPrivateKeyPath: os.Getenv("MOBILECONFIG_PRIVATE_KEY_PATH"), - MobileConfigCertPath: os.Getenv("MOBILECONFIG_CERT_PATH"), - IdLimiterMax: idLimiterMax, - IdLimiterExpiration: idLimiterExpiration, - MaxProfiles: maxProfiles, - MaxCredentials: maxCredentials, - ServicesCatalogPath: servicesCatalogPath, - ServicesCatalogReloadEvery: servicesCatalogReloadEvery, + OTPExpirationTime: otpExp, + MobileConfigPrivateKeyPath: os.Getenv("MOBILECONFIG_PRIVATE_KEY_PATH"), + MobileConfigCertPath: os.Getenv("MOBILECONFIG_CERT_PATH"), + IdLimiterMax: idLimiterMax, + IdLimiterExpiration: idLimiterExpiration, + MaxProfiles: maxProfiles, + MaxCredentials: maxCredentials, + ServicesCatalogPath: servicesCatalogPath, + ServicesCatalogReloadEvery: servicesCatalogReloadEvery, }, Sentry: &SentryConfig{ DSN: os.Getenv("SENTRY_DSN"), From 361afa467e6026478b6401dcb1b2875cf7df8549 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 9 Apr 2026 16:10:06 +0200 Subject: [PATCH 11/54] feat(api): add subscription sync endpoint, expiry cron, status lifecycle Signed-off-by: Maciek --- api/api/requests/subscription.go | 6 + api/api/server.go | 1 + api/api/subscription.go | 42 ++++++ api/db/mongodb/subscription.go | 47 +++++++ api/db/repository/subscription.go | 4 + api/go.mod | 6 +- api/go.sum | 15 ++- api/internal/cron/cron.go | 29 ++++ api/internal/cron/jobs.go | 57 ++++++++ api/internal/email/content/content.go | 9 ++ api/internal/email/mailer.go | 1 + api/internal/email/mailpit/mailpit.go | 12 ++ api/internal/email/mailtrap/mailtrap.go | 13 ++ api/internal/email/sendgrid/sendgrid.go | 6 + api/main.go | 4 + api/mocks/asn1_object.go | 89 ++++++++++++ api/mocks/db.go | 171 ++++++++++++++++++++++++ api/mocks/mailer_email.go | 57 ++++++++ api/mocks/servicer.go | 69 ++++++++++ api/mocks/subscription_repository.go | 171 ++++++++++++++++++++++++ api/mocks/subscription_servicer.go | 69 ++++++++++ api/model/subscription.go | 5 + api/service/service.go | 1 + api/service/subscription/service.go | 29 +++- 24 files changed, 903 insertions(+), 10 deletions(-) create mode 100644 api/internal/cron/cron.go create mode 100644 api/internal/cron/jobs.go create mode 100644 api/mocks/asn1_object.go diff --git a/api/api/requests/subscription.go b/api/api/requests/subscription.go index 71832f83..7e9edbed 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -5,3 +5,9 @@ import "github.com/ivpn/dns/api/model" type SubscriptionUpdates struct { Updates []model.SubscriptionUpdate `json:"updates" validate:"required,dive"` } + +// SubscriptionUpdateReq represents a request to resync a subscription via PASession. +type SubscriptionUpdateReq struct { + ID string `json:"id" validate:"required,uuid4"` + SubID string `json:"subid" validate:"required,uuid4"` +} diff --git a/api/api/server.go b/api/api/server.go index e736de70..f740a78c 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -148,6 +148,7 @@ func (s *APIServer) RegisterRoutes() { // Subscription (protected) endpoint (session auth only) sub.Get("", middleware.NewLimit(40, 1*time.Minute), s.getSubscription()) + sub.Put("/update", middleware.NewLimit(10, 1*time.Minute), s.updateSubscription()) // Email verification OTP (requires auth) verify.Post("/email/otp/request", middleware.NewLimit(10, 1*time.Minute), s.requestEmailVerificationOTP()) diff --git a/api/api/subscription.go b/api/api/subscription.go index 0abf4af5..24403d57 100644 --- a/api/api/subscription.go +++ b/api/api/subscription.go @@ -1,7 +1,10 @@ package api import ( + "strings" + "github.com/gofiber/fiber/v2" + "github.com/ivpn/dns/api/api/requests" "github.com/ivpn/dns/api/internal/auth" "github.com/ivpn/dns/api/model" ) @@ -30,3 +33,42 @@ func (s *APIServer) getSubscription() fiber.Handler { return c.Status(200).JSON(subscription) } } + +// @Summary Update subscription via PASession +// @Description Resync subscription using a pre-auth session +// @Tags Subscription +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body requests.SubscriptionUpdateReq true "Subscription update request" +// @Success 200 {object} fiber.Map +// @Failure 400 {object} ErrResponse +// @Failure 401 {object} ErrResponse +// @Router /api/v1/sub/update [put] +func (s *APIServer) updateSubscription() fiber.Handler { + return func(c *fiber.Ctx) error { + sessionID := c.Cookies(PASessionCookie) + accountId := auth.GetAccountID(c) + + req := new(requests.SubscriptionUpdateReq) + if err := c.BodyParser(req); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, req, ErrInvalidRequestBody.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) + } + + sub, err := s.Service.GetSubscription(c.Context(), accountId) + if err != nil { + return HandleError(c, err, ErrFailedToGetSubscription.Error()) + } + + if err := s.Service.UpdateSubscriptionFromPASession(c.Context(), sub, req.SubID, sessionID); err != nil { + return HandleError(c, err, "failed to update subscription") + } + + return c.Status(200).JSON(fiber.Map{"message": "Subscription updated successfully."}) + } +} diff --git a/api/db/mongodb/subscription.go b/api/db/mongodb/subscription.go index 123397ae..e0adb6e7 100644 --- a/api/db/mongodb/subscription.go +++ b/api/db/mongodb/subscription.go @@ -2,7 +2,9 @@ package mongodb import ( "context" + "time" + "github.com/google/uuid" "github.com/ivpn/dns/api/db/errors" "github.com/ivpn/dns/api/model" "github.com/rs/zerolog/log" @@ -86,3 +88,48 @@ func (r *SubscriptionRepository) Create(ctx context.Context, sub model.Subscript } return nil } + +// ResetNotifiedForActive sets notified=false for all subscriptions where active_until >= now. +func (r *SubscriptionRepository) ResetNotifiedForActive(ctx context.Context) error { + filter := bson.M{"active_until": bson.M{"$gte": time.Now()}} + update := bson.M{"$set": bson.M{"notified": false}} + _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) + if err != nil { + log.Error().Err(err).Msg("Failed to reset notified flag for active subscriptions") + } + return err +} + +// FindExpiredUnnotified returns subscriptions where notified=false and active_until < now - 24h. +func (r *SubscriptionRepository) FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) { + oneDayAgo := time.Now().Add(-24 * time.Hour) + filter := bson.M{ + "notified": false, + "active_until": bson.M{"$lt": oneDayAgo}, + } + cursor, err := r.subscriptionsCollection.Find(ctx, filter) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var subs []model.Subscription + if err := cursor.All(ctx, &subs); err != nil { + return nil, err + } + return subs, nil +} + +// MarkNotified sets notified=true for the given subscription IDs. +func (r *SubscriptionRepository) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + if len(subscriptionIDs) == 0 { + return nil + } + filter := bson.M{"_id": bson.M{"$in": subscriptionIDs}} + update := bson.M{"$set": bson.M{"notified": true}} + _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) + if err != nil { + log.Error().Err(err).Msg("Failed to mark subscriptions as notified") + } + return err +} diff --git a/api/db/repository/subscription.go b/api/db/repository/subscription.go index 97954aa7..808fa3a5 100644 --- a/api/db/repository/subscription.go +++ b/api/db/repository/subscription.go @@ -3,6 +3,7 @@ package repository import ( "context" + "github.com/google/uuid" "github.com/ivpn/dns/api/model" ) @@ -12,4 +13,7 @@ type SubscriptionRepository interface { GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) Upsert(ctx context.Context, subscription model.Subscription) error Create(ctx context.Context, subscription model.Subscription) error + ResetNotifiedForActive(ctx context.Context) error + FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) + MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error } diff --git a/api/go.mod b/api/go.mod index 9f66adf0..5b0870db 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,10 +4,10 @@ go 1.25.8 require ( github.com/AfterShip/email-verifier v1.4.0 - github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/getsentry/sentry-go v0.31.1 github.com/getsentry/sentry-go/fiber v0.31.1 github.com/getsentry/sentry-go/zerolog v0.31.1 + github.com/go-co-op/gocron/v2 v2.20.0 github.com/go-playground/validator/v10 v10.20.0 github.com/gofiber/fiber/v2 v2.52.12 github.com/ivpn/dns/libs v0.0.0 @@ -64,6 +64,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hbollon/go-edlib v1.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -85,6 +86,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -119,7 +121,7 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/pkg/errors v0.9.1 github.com/sqids/sqids-go v0.4.1 - github.com/stretchr/testify v1.11.0 + github.com/stretchr/testify v1.11.1 github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.57.0 github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/api/go.sum b/api/go.sum index 435984cf..104c7420 100644 --- a/api/go.sum +++ b/api/go.sum @@ -12,8 +12,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= -github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -70,6 +68,8 @@ github.com/getsentry/sentry-go/fiber v0.31.1 h1:SHEvAWaI36IscRMoQ1y3bDf5nueQ5RWs github.com/getsentry/sentry-go/fiber v0.31.1/go.mod h1:aR0gyrjUufVBKte4kw5cTGEWDu0Ef41fhjiybt3fePw= github.com/getsentry/sentry-go/zerolog v0.31.1 h1:i6aJFsWGL021KCPzB/WhBcyg8NcNsUM/AuFq1Y3dANc= github.com/getsentry/sentry-go/zerolog v0.31.1/go.mod h1:4bpkgp9HcbX/kZ9hHolRKKSRmgGMy1bqmMWJ4SO1iqI= +github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc= +github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -128,6 +128,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hbollon/go-edlib v1.6.0 h1:ga7AwwVIvP8mHm9GsPueC0d71cfRU/52hmPJ7Tprv4E= github.com/hbollon/go-edlib v1.6.0/go.mod h1:wnt6o6EIVEzUfgbUZY7BerzQ2uvzp354qmS2xaLkrhM= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -157,7 +159,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -201,16 +202,16 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA= @@ -233,8 +234,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= diff --git a/api/internal/cron/cron.go b/api/internal/cron/cron.go new file mode 100644 index 00000000..b37dc494 --- /dev/null +++ b/api/internal/cron/cron.go @@ -0,0 +1,29 @@ +package cron + +import ( + "github.com/go-co-op/gocron/v2" + "github.com/ivpn/dns/api/db/repository" + "github.com/ivpn/dns/api/internal/email" + "github.com/rs/zerolog/log" +) + +// Start initializes the gocron scheduler with all periodic jobs. +func Start(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, mailer email.Mailer) { + s, err := gocron.NewScheduler() + if err != nil { + log.Error().Err(err).Msg("Failed to create cron scheduler") + return + } + + _, err = s.NewJob( + gocron.CronJob("0 * * * *", false), // every hour at minute 0 + gocron.NewTask(NotifyExpiringSubscriptions, subRepo, accountRepo, mailer), + ) + if err != nil { + log.Error().Err(err).Msg("Failed to schedule subscription expiry notification job") + return + } + + s.Start() + log.Info().Msg("Cron scheduler started") +} diff --git a/api/internal/cron/jobs.go b/api/internal/cron/jobs.go new file mode 100644 index 00000000..7ece41ca --- /dev/null +++ b/api/internal/cron/jobs.go @@ -0,0 +1,57 @@ +package cron + +import ( + "context" + + "github.com/google/uuid" + "github.com/ivpn/dns/api/db/repository" + "github.com/ivpn/dns/api/internal/email" + "github.com/rs/zerolog/log" +) + +// NotifyExpiringSubscriptions resets the notified flag for active subscriptions, +// finds expired+unnotified ones, sends notification emails, and marks them as notified. +func NotifyExpiringSubscriptions(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, mailer email.Mailer) { + ctx := context.Background() + + // 1. Reset notified for active subscriptions + if err := subRepo.ResetNotifiedForActive(ctx); err != nil { + log.Error().Err(err).Msg("Cron: failed to reset notified flag for active subscriptions") + } + + // 2. Find expired+unnotified subscriptions + subs, err := subRepo.FindExpiredUnnotified(ctx) + if err != nil { + log.Error().Err(err).Msg("Cron: failed to find expired unnotified subscriptions") + return + } + + if len(subs) == 0 { + return + } + + log.Info().Int("count", len(subs)).Msg("Cron: notifying expiring subscriptions") + + // 3. Send notification emails + for _, sub := range subs { + account, err := accountRepo.GetAccountById(ctx, sub.AccountID.Hex()) + if err != nil { + log.Error().Err(err).Str("account_id", sub.AccountID.Hex()).Msg("Cron: failed to get account for expiry notification") + continue + } + + if err := mailer.SendSubscriptionExpiryEmail(ctx, account.Email); err != nil { + log.Error().Err(err).Str("email", account.Email).Msg("Cron: failed to send subscription expiry email") + continue + } + } + + // 4. Mark as notified + ids := make([]uuid.UUID, 0, len(subs)) + for _, sub := range subs { + ids = append(ids, sub.ID) + } + if err := subRepo.MarkNotified(ctx, ids); err != nil { + log.Error().Err(err).Msg("Cron: failed to mark subscriptions as notified") + } +} diff --git a/api/internal/email/content/content.go b/api/internal/email/content/content.go index 7057c540..3b50f2a6 100644 --- a/api/internal/email/content/content.go +++ b/api/internal/email/content/content.go @@ -29,6 +29,15 @@ func PasswordResetContent(resetLink string) EmailContent { } } +// SubscriptionExpiryContent returns the subscription expiry notification email content. +func SubscriptionExpiryContent() EmailContent { + return EmailContent{ + Subject: "Limited Access Mode", + Plain: "Hello,\n\nYour modDNS account is in limited access mode.\n\nTo regain full access with no restrictions, add time to your IVPN account.\n\nSent by modDNS", + Html: "

Hello,

Your modDNS account is in limited access mode.

To regain full access with no restrictions, add time to your IVPN account.

Sent by modDNS

", + } +} + // EmailVerificationOTPContent returns the email verification OTP content. func EmailVerificationOTPContent(otp string) EmailContent { return EmailContent{ diff --git a/api/internal/email/mailer.go b/api/internal/email/mailer.go index ec97e1d8..edbd823a 100644 --- a/api/internal/email/mailer.go +++ b/api/internal/email/mailer.go @@ -20,6 +20,7 @@ type Mailer interface { SendWelcomeEmail(ctx context.Context, sendTo, confirmationToken string) error SendPasswordResetEmail(ctx context.Context, sendTo, passwordResetToken string) error SendEmailVerificationOTP(ctx context.Context, sendTo, otp string) error + SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error Verify(email string) error } diff --git a/api/internal/email/mailpit/mailpit.go b/api/internal/email/mailpit/mailpit.go index e11ce709..95b75c1a 100644 --- a/api/internal/email/mailpit/mailpit.go +++ b/api/internal/email/mailpit/mailpit.go @@ -90,6 +90,18 @@ func (m *Mailpit) SendEmailVerificationOTP(ctx context.Context, sendTo, otp stri return m.sendEmail(ctx, sendTo, reqBody) } +// SendSubscriptionExpiryEmail notifies the user their subscription has expired. +func (m *Mailpit) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error { + c := content.SubscriptionExpiryContent() + reqBody := mailpitSendRequest{ + From: Email{Email: "info@moddns.net", Name: "modDNS"}, + To: []Email{{Email: sendTo, Name: "User"}}, + Subject: c.Subject, + Text: c.Plain, + } + return m.sendEmail(ctx, sendTo, reqBody) +} + // sendEmail sends an email using the Mailpit API func (m *Mailpit) sendEmail(ctx context.Context, email string, reqBody mailpitSendRequest) error { payload, err := json.Marshal(reqBody) diff --git a/api/internal/email/mailtrap/mailtrap.go b/api/internal/email/mailtrap/mailtrap.go index 22383589..d1b225e0 100644 --- a/api/internal/email/mailtrap/mailtrap.go +++ b/api/internal/email/mailtrap/mailtrap.go @@ -101,6 +101,19 @@ func (m *Mailtrap) SendEmailVerificationOTP(ctx context.Context, sendTo, otp str return nil } +// SendSubscriptionExpiryEmail notifies the user their subscription has expired. +func (m *Mailtrap) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error { + c := content.SubscriptionExpiryContent() + req := SendEmailRequest{ + From: From{Email: "moddns@demomailtrap.com", Name: "modDNS Team"}, + To: []To{{Email: sendTo}}, + Subject: c.Subject, + Text: c.Plain, + Html: c.Html, + } + return m.sendEmail(ctx, sendTo, req) +} + // Verify checks if email provided is valid func (m *Mailtrap) Verify(email string) error { initVerRes, err := m.verifier.Verify(email) diff --git a/api/internal/email/sendgrid/sendgrid.go b/api/internal/email/sendgrid/sendgrid.go index f22fb839..84b65c93 100644 --- a/api/internal/email/sendgrid/sendgrid.go +++ b/api/internal/email/sendgrid/sendgrid.go @@ -74,6 +74,12 @@ func (m *Mailer) SendPasswordResetEmail(ctx context.Context, sendTo, passwordRes return m.sendBasic(ctx, sendTo, c.Subject, c.Plain, c.Html) } +// SendSubscriptionExpiryEmail notifies the user their subscription has expired. +func (m *Mailer) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error { + c := content.SubscriptionExpiryContent() + return m.sendBasic(ctx, sendTo, c.Subject, c.Plain, c.Html) +} + // Verify performs basic syntax validation. Extend with more advanced service if needed. func (m *Mailer) Verify(email string) error { if email == "" { diff --git a/api/main.go b/api/main.go index 6cbef5a0..686a1e7b 100644 --- a/api/main.go +++ b/api/main.go @@ -9,6 +9,7 @@ import ( "github.com/ivpn/dns/api/cache" "github.com/ivpn/dns/api/config" "github.com/ivpn/dns/api/db/mongodb" + "github.com/ivpn/dns/api/internal/cron" "github.com/ivpn/dns/api/internal/email" "github.com/ivpn/dns/api/internal/idgen" "github.com/ivpn/dns/api/internal/middleware" @@ -143,6 +144,9 @@ func main() { log.Panic().Err(err).Msg("Failed to create API server") } server.RegisterRoutes() + + cron.Start(db, db, mailer) + err = server.App.Listen(appConfig.API.Port) log.Panic().Err(err).Msg("Failed to start REST API") } diff --git a/api/mocks/asn1_object.go b/api/mocks/asn1_object.go new file mode 100644 index 00000000..68e03866 --- /dev/null +++ b/api/mocks/asn1_object.go @@ -0,0 +1,89 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "bytes" + + mock "github.com/stretchr/testify/mock" +) + +// newAsn1Object creates a new instance of asn1Object. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newAsn1Object(t interface { + mock.TestingT + Cleanup(func()) +}) *asn1Object { + mock := &asn1Object{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// asn1Object is an autogenerated mock type for the asn1Object type +type asn1Object struct { + mock.Mock +} + +type asn1Object_Expecter struct { + mock *mock.Mock +} + +func (_m *asn1Object) EXPECT() *asn1Object_Expecter { + return &asn1Object_Expecter{mock: &_m.Mock} +} + +// EncodeTo provides a mock function for the type asn1Object +func (_mock *asn1Object) EncodeTo(writer *bytes.Buffer) error { + ret := _mock.Called(writer) + + if len(ret) == 0 { + panic("no return value specified for EncodeTo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*bytes.Buffer) error); ok { + r0 = returnFunc(writer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// asn1Object_EncodeTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EncodeTo' +type asn1Object_EncodeTo_Call struct { + *mock.Call +} + +// EncodeTo is a helper method to define mock.On call +// - writer *bytes.Buffer +func (_e *asn1Object_Expecter) EncodeTo(writer interface{}) *asn1Object_EncodeTo_Call { + return &asn1Object_EncodeTo_Call{Call: _e.mock.On("EncodeTo", writer)} +} + +func (_c *asn1Object_EncodeTo_Call) Run(run func(writer *bytes.Buffer)) *asn1Object_EncodeTo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *bytes.Buffer + if args[0] != nil { + arg0 = args[0].(*bytes.Buffer) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *asn1Object_EncodeTo_Call) Return(err error) *asn1Object_EncodeTo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *asn1Object_EncodeTo_Call) RunAndReturn(run func(writer *bytes.Buffer) error) *asn1Object_EncodeTo_Call { + _c.Call.Return(run) + return _c +} diff --git a/api/mocks/db.go b/api/mocks/db.go index 7dc89d1c..d7f723e3 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -9,6 +9,7 @@ import ( "time" "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" "github.com/ivpn/dns/api/model" mock "github.com/stretchr/testify/mock" "go.mongodb.org/mongo-driver/bson/primitive" @@ -1204,6 +1205,68 @@ func (_c *Db_EnableServices_Call) RunAndReturn(run func(ctx context.Context, pro return _c } +// FindExpiredUnnotified provides a mock function for the type Db +func (_mock *Db) FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindExpiredUnnotified") + } + + var r0 []model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]model.Subscription, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []model.Subscription); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Db_FindExpiredUnnotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindExpiredUnnotified' +type Db_FindExpiredUnnotified_Call struct { + *mock.Call +} + +// FindExpiredUnnotified is a helper method to define mock.On call +// - ctx context.Context +func (_e *Db_Expecter) FindExpiredUnnotified(ctx interface{}) *Db_FindExpiredUnnotified_Call { + return &Db_FindExpiredUnnotified_Call{Call: _e.mock.On("FindExpiredUnnotified", ctx)} +} + +func (_c *Db_FindExpiredUnnotified_Call) Run(run func(ctx context.Context)) *Db_FindExpiredUnnotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Db_FindExpiredUnnotified_Call) Return(subscriptions []model.Subscription, err error) *Db_FindExpiredUnnotified_Call { + _c.Call.Return(subscriptions, err) + return _c +} + +func (_c *Db_FindExpiredUnnotified_Call) RunAndReturn(run func(ctx context.Context) ([]model.Subscription, error)) *Db_FindExpiredUnnotified_Call { + _c.Call.Return(run) + return _c +} + // Get provides a mock function for the type Db func (_mock *Db) Get(ctx context.Context, filter map[string]any, sortBy string) ([]*model.Blocklist, error) { ret := _mock.Called(ctx, filter, sortBy) @@ -2338,6 +2401,63 @@ func (_c *Db_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context return _c } +// MarkNotified provides a mock function for the type Db +func (_mock *Db) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + ret := _mock.Called(ctx, subscriptionIDs) + + if len(ret) == 0 { + panic("no return value specified for MarkNotified") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_MarkNotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkNotified' +type Db_MarkNotified_Call struct { + *mock.Call +} + +// MarkNotified is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionIDs []uuid.UUID +func (_e *Db_Expecter) MarkNotified(ctx interface{}, subscriptionIDs interface{}) *Db_MarkNotified_Call { + return &Db_MarkNotified_Call{Call: _e.mock.On("MarkNotified", ctx, subscriptionIDs)} +} + +func (_c *Db_MarkNotified_Call) Run(run func(ctx context.Context, subscriptionIDs []uuid.UUID)) *Db_MarkNotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []uuid.UUID + if args[1] != nil { + arg1 = args[1].([]uuid.UUID) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Db_MarkNotified_Call) Return(err error) *Db_MarkNotified_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_MarkNotified_Call) RunAndReturn(run func(ctx context.Context, subscriptionIDs []uuid.UUID) error) *Db_MarkNotified_Call { + _c.Call.Return(run) + return _c +} + // Migrate provides a mock function for the type Db func (_mock *Db) Migrate() error { ret := _mock.Called() @@ -2508,6 +2628,57 @@ func (_c *Db_RemoveProfileFromAccount_Call) RunAndReturn(run func(ctx context.Co return _c } +// ResetNotifiedForActive provides a mock function for the type Db +func (_mock *Db) ResetNotifiedForActive(ctx context.Context) error { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ResetNotifiedForActive") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_ResetNotifiedForActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetNotifiedForActive' +type Db_ResetNotifiedForActive_Call struct { + *mock.Call +} + +// ResetNotifiedForActive is a helper method to define mock.On call +// - ctx context.Context +func (_e *Db_Expecter) ResetNotifiedForActive(ctx interface{}) *Db_ResetNotifiedForActive_Call { + return &Db_ResetNotifiedForActive_Call{Call: _e.mock.On("ResetNotifiedForActive", ctx)} +} + +func (_c *Db_ResetNotifiedForActive_Call) Run(run func(ctx context.Context)) *Db_ResetNotifiedForActive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Db_ResetNotifiedForActive_Call) Return(err error) *Db_ResetNotifiedForActive_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_ResetNotifiedForActive_Call) RunAndReturn(run func(ctx context.Context) error) *Db_ResetNotifiedForActive_Call { + _c.Call.Return(run) + return _c +} + // SaveCredential provides a mock function for the type Db func (_mock *Db) SaveCredential(ctx context.Context, credential webauthn.Credential, accountID primitive.ObjectID) error { ret := _mock.Called(ctx, credential, accountID) diff --git a/api/mocks/mailer_email.go b/api/mocks/mailer_email.go index e5e81417..25a79535 100644 --- a/api/mocks/mailer_email.go +++ b/api/mocks/mailer_email.go @@ -163,6 +163,63 @@ func (_c *Maileremail_SendPasswordResetEmail_Call) RunAndReturn(run func(ctx con return _c } +// SendSubscriptionExpiryEmail provides a mock function for the type Maileremail +func (_mock *Maileremail) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error { + ret := _mock.Called(ctx, sendTo) + + if len(ret) == 0 { + panic("no return value specified for SendSubscriptionExpiryEmail") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sendTo) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Maileremail_SendSubscriptionExpiryEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSubscriptionExpiryEmail' +type Maileremail_SendSubscriptionExpiryEmail_Call struct { + *mock.Call +} + +// SendSubscriptionExpiryEmail is a helper method to define mock.On call +// - ctx context.Context +// - sendTo string +func (_e *Maileremail_Expecter) SendSubscriptionExpiryEmail(ctx interface{}, sendTo interface{}) *Maileremail_SendSubscriptionExpiryEmail_Call { + return &Maileremail_SendSubscriptionExpiryEmail_Call{Call: _e.mock.On("SendSubscriptionExpiryEmail", ctx, sendTo)} +} + +func (_c *Maileremail_SendSubscriptionExpiryEmail_Call) Run(run func(ctx context.Context, sendTo string)) *Maileremail_SendSubscriptionExpiryEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Maileremail_SendSubscriptionExpiryEmail_Call) Return(err error) *Maileremail_SendSubscriptionExpiryEmail_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Maileremail_SendSubscriptionExpiryEmail_Call) RunAndReturn(run func(ctx context.Context, sendTo string) error) *Maileremail_SendSubscriptionExpiryEmail_Call { + _c.Call.Return(run) + return _c +} + // SendWelcomeEmail provides a mock function for the type Maileremail func (_mock *Maileremail) SendWelcomeEmail(ctx context.Context, sendTo string, confirmationToken string) error { ret := _mock.Called(ctx, sendTo, confirmationToken) diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index e0639a13..8689a82a 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -3954,6 +3954,75 @@ func (_c *Servicer_UpdateSubscription_Call) RunAndReturn(run func(ctx context.Co return _c } +// UpdateSubscriptionFromPASession provides a mock function for the type Servicer +func (_mock *Servicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { + ret := _mock.Called(ctx, sub, subID, sessionID) + + if len(ret) == 0 { + panic("no return value specified for UpdateSubscriptionFromPASession") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string, string) error); ok { + r0 = returnFunc(ctx, sub, subID, sessionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Servicer_UpdateSubscriptionFromPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSubscriptionFromPASession' +type Servicer_UpdateSubscriptionFromPASession_Call struct { + *mock.Call +} + +// UpdateSubscriptionFromPASession is a helper method to define mock.On call +// - ctx context.Context +// - sub *model.Subscription +// - subID string +// - sessionID string +func (_e *Servicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, subID interface{}, sessionID interface{}) *Servicer_UpdateSubscriptionFromPASession_Call { + return &Servicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, subID, sessionID)} +} + +func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string)) *Servicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *model.Subscription + if args[1] != nil { + arg1 = args[1].(*model.Subscription) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Return(err error) *Servicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Servicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error) *Servicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Return(run) + return _c +} + // ValidateAndGetPreauth provides a mock function for the type Servicer func (_mock *Servicer) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { ret := _mock.Called(ctx, sessionID) diff --git a/api/mocks/subscription_repository.go b/api/mocks/subscription_repository.go index 8cff14d9..2012d415 100644 --- a/api/mocks/subscription_repository.go +++ b/api/mocks/subscription_repository.go @@ -7,6 +7,7 @@ package mocks import ( "context" + "github.com/google/uuid" "github.com/ivpn/dns/api/model" mock "github.com/stretchr/testify/mock" ) @@ -95,6 +96,68 @@ func (_c *SubscriptionRepository_Create_Call) RunAndReturn(run func(ctx context. return _c } +// FindExpiredUnnotified provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindExpiredUnnotified") + } + + var r0 []model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]model.Subscription, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []model.Subscription); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionRepository_FindExpiredUnnotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindExpiredUnnotified' +type SubscriptionRepository_FindExpiredUnnotified_Call struct { + *mock.Call +} + +// FindExpiredUnnotified is a helper method to define mock.On call +// - ctx context.Context +func (_e *SubscriptionRepository_Expecter) FindExpiredUnnotified(ctx interface{}) *SubscriptionRepository_FindExpiredUnnotified_Call { + return &SubscriptionRepository_FindExpiredUnnotified_Call{Call: _e.mock.On("FindExpiredUnnotified", ctx)} +} + +func (_c *SubscriptionRepository_FindExpiredUnnotified_Call) Run(run func(ctx context.Context)) *SubscriptionRepository_FindExpiredUnnotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_FindExpiredUnnotified_Call) Return(subscriptions []model.Subscription, err error) *SubscriptionRepository_FindExpiredUnnotified_Call { + _c.Call.Return(subscriptions, err) + return _c +} + +func (_c *SubscriptionRepository_FindExpiredUnnotified_Call) RunAndReturn(run func(ctx context.Context) ([]model.Subscription, error)) *SubscriptionRepository_FindExpiredUnnotified_Call { + _c.Call.Return(run) + return _c +} + // GetSubscriptionByAccountId provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) GetSubscriptionByAccountId(ctx context.Context, accountId string) (*model.Subscription, error) { ret := _mock.Called(ctx, accountId) @@ -231,6 +294,114 @@ func (_c *SubscriptionRepository_GetSubscriptionById_Call) RunAndReturn(run func return _c } +// MarkNotified provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + ret := _mock.Called(ctx, subscriptionIDs) + + if len(ret) == 0 { + panic("no return value specified for MarkNotified") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SubscriptionRepository_MarkNotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkNotified' +type SubscriptionRepository_MarkNotified_Call struct { + *mock.Call +} + +// MarkNotified is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionIDs []uuid.UUID +func (_e *SubscriptionRepository_Expecter) MarkNotified(ctx interface{}, subscriptionIDs interface{}) *SubscriptionRepository_MarkNotified_Call { + return &SubscriptionRepository_MarkNotified_Call{Call: _e.mock.On("MarkNotified", ctx, subscriptionIDs)} +} + +func (_c *SubscriptionRepository_MarkNotified_Call) Run(run func(ctx context.Context, subscriptionIDs []uuid.UUID)) *SubscriptionRepository_MarkNotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []uuid.UUID + if args[1] != nil { + arg1 = args[1].([]uuid.UUID) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_MarkNotified_Call) Return(err error) *SubscriptionRepository_MarkNotified_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SubscriptionRepository_MarkNotified_Call) RunAndReturn(run func(ctx context.Context, subscriptionIDs []uuid.UUID) error) *SubscriptionRepository_MarkNotified_Call { + _c.Call.Return(run) + return _c +} + +// ResetNotifiedForActive provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) ResetNotifiedForActive(ctx context.Context) error { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ResetNotifiedForActive") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SubscriptionRepository_ResetNotifiedForActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetNotifiedForActive' +type SubscriptionRepository_ResetNotifiedForActive_Call struct { + *mock.Call +} + +// ResetNotifiedForActive is a helper method to define mock.On call +// - ctx context.Context +func (_e *SubscriptionRepository_Expecter) ResetNotifiedForActive(ctx interface{}) *SubscriptionRepository_ResetNotifiedForActive_Call { + return &SubscriptionRepository_ResetNotifiedForActive_Call{Call: _e.mock.On("ResetNotifiedForActive", ctx)} +} + +func (_c *SubscriptionRepository_ResetNotifiedForActive_Call) Run(run func(ctx context.Context)) *SubscriptionRepository_ResetNotifiedForActive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_ResetNotifiedForActive_Call) Return(err error) *SubscriptionRepository_ResetNotifiedForActive_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SubscriptionRepository_ResetNotifiedForActive_Call) RunAndReturn(run func(ctx context.Context) error) *SubscriptionRepository_ResetNotifiedForActive_Call { + _c.Call.Return(run) + return _c +} + // Upsert provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) Upsert(ctx context.Context, subscription model.Subscription) error { ret := _mock.Called(ctx, subscription) diff --git a/api/mocks/subscription_servicer.go b/api/mocks/subscription_servicer.go index 8a1aced0..685461bd 100644 --- a/api/mocks/subscription_servicer.go +++ b/api/mocks/subscription_servicer.go @@ -434,6 +434,75 @@ func (_c *SubscriptionServicer_UpdateSubscription_Call) RunAndReturn(run func(ct return _c } +// UpdateSubscriptionFromPASession provides a mock function for the type SubscriptionServicer +func (_mock *SubscriptionServicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { + ret := _mock.Called(ctx, sub, subID, sessionID) + + if len(ret) == 0 { + panic("no return value specified for UpdateSubscriptionFromPASession") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string, string) error); ok { + r0 = returnFunc(ctx, sub, subID, sessionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SubscriptionServicer_UpdateSubscriptionFromPASession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSubscriptionFromPASession' +type SubscriptionServicer_UpdateSubscriptionFromPASession_Call struct { + *mock.Call +} + +// UpdateSubscriptionFromPASession is a helper method to define mock.On call +// - ctx context.Context +// - sub *model.Subscription +// - subID string +// - sessionID string +func (_e *SubscriptionServicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, subID interface{}, sessionID interface{}) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { + return &SubscriptionServicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, subID, sessionID)} +} + +func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string)) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *model.Subscription + if args[1] != nil { + arg1 = args[1].(*model.Subscription) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Return(err error) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { + _c.Call.Return(run) + return _c +} + // ValidateAndGetPreauth provides a mock function for the type SubscriptionServicer func (_mock *SubscriptionServicer) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { ret := _mock.Called(ctx, sessionID) diff --git a/api/model/subscription.go b/api/model/subscription.go index 54e6e669..a56aa15f 100644 --- a/api/model/subscription.go +++ b/api/model/subscription.go @@ -64,7 +64,12 @@ func (s *Subscription) ActiveStatus() bool { } // IsOutage returns true when the subscription hasn't been updated in over 48 hours. +// Returns false for zero UpdatedAt (never-synced pre-ZLA accounts) to avoid +// incorrectly degrading paid subscriptions that haven't gone through ZLA sync yet. func (s *Subscription) IsOutage() bool { + if s.UpdatedAt.IsZero() { + return false + } return s.UpdatedAt.Add(48 * time.Hour).Before(time.Now()) } diff --git a/api/service/service.go b/api/service/service.go index 78ef13a2..924f6edf 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -177,6 +177,7 @@ type SubscriptionServicer interface { RotatePASessionID(ctx context.Context, oldID string) (string, error) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) + UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error } // DeleteAccount deletes account with all connected data including sessions diff --git a/api/service/subscription/service.go b/api/service/subscription/service.go index f0329cdb..1f2ef2d0 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -55,7 +55,8 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, accountId str } subscription.Status = subscription.GetStatus() - subscription.Outage = subscription.IsOutage() + // Outage UI flag: true when never synced (zero UpdatedAt) OR genuinely stale (>48h) + subscription.Outage = subscription.UpdatedAt.IsZero() || subscription.IsOutage() return subscription, nil } @@ -152,3 +153,29 @@ func (s *SubscriptionService) ValidateAndGetPreauth(ctx context.Context, session return &preauth, nil } + +// UpdateSubscriptionFromPASession validates the PASession, updates subscription fields from preauth, and persists. +func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { + preauth, err := s.ValidateAndGetPreauth(ctx, sessionID) + if err != nil { + return err + } + + sub.ActiveUntil = preauth.ActiveUntil + sub.IsActive = preauth.IsActive + sub.Tier = preauth.Tier + sub.TokenHash = preauth.TokenHash + sub.UpdatedAt = time.Now() + + if err := s.SubscriptionRepository.Upsert(ctx, *sub); err != nil { + log.Error().Err(err).Msg("Failed to update subscription from PASession") + return err + } + + if err := s.Http.SignupWebhook(subID); err != nil { + log.Error().Err(err).Str("sub_id", subID).Msg("Failed to send signup webhook after subscription update") + return err + } + + return nil +} From e7847cdd73450b4a1ccf62eb29d41223b5743938 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 9 Apr 2026 16:11:29 +0200 Subject: [PATCH 12/54] docs(api): Update swagger docs Signed-off-by: Maciek --- api/docs/docs.go | 72 +++++++++++++++++++++++++++++++++++++++++++ api/docs/swagger.json | 72 +++++++++++++++++++++++++++++++++++++++++++ api/docs/swagger.yaml | 46 +++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) diff --git a/api/docs/docs.go b/api/docs/docs.go index eecb5866..87bd09b7 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1849,6 +1849,57 @@ const docTemplate = `{ } } }, + "/api/v1/sub/update": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Resync subscription using a pre-auth session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Subscription" + ], + "summary": "Update subscription via PASession", + "parameters": [ + { + "description": "Subscription update request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SubscriptionUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/fiber.Map" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/verify/email/otp/confirm": { "post": { "description": "Verifies the 6-digit OTP provided by the authenticated user", @@ -3557,6 +3608,21 @@ const docTemplate = `{ } } }, + "requests.SubscriptionUpdateReq": { + "type": "object", + "required": [ + "id", + "subid" + ], + "properties": { + "id": { + "type": "string" + }, + "subid": { + "type": "string" + } + } + }, "requests.TotpReq": { "type": "object", "required": [ @@ -3692,6 +3758,12 @@ const docTemplate = `{ "type": "integer" } }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index d3044c01..c4114896 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1841,6 +1841,57 @@ } } }, + "/api/v1/sub/update": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Resync subscription using a pre-auth session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Subscription" + ], + "summary": "Update subscription via PASession", + "parameters": [ + { + "description": "Subscription update request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.SubscriptionUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/fiber.Map" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/verify/email/otp/confirm": { "post": { "description": "Verifies the 6-digit OTP provided by the authenticated user", @@ -3549,6 +3600,21 @@ } } }, + "requests.SubscriptionUpdateReq": { + "type": "object", + "required": [ + "id", + "subid" + ], + "properties": { + "id": { + "type": "string" + }, + "subid": { + "type": "string" + } + } + }, "requests.TotpReq": { "type": "object", "required": [ @@ -3684,6 +3750,12 @@ "type": "integer" } }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index c215efd6..b2e08233 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -875,6 +875,16 @@ definitions: required: - sessionid type: object + requests.SubscriptionUpdateReq: + properties: + id: + type: string + subid: + type: string + required: + - id + - subid + type: object requests.TotpReq: properties: otp: @@ -963,6 +973,10 @@ definitions: items: type: integer type: array + domains: + items: + type: string + type: array id: type: string logo_key: @@ -2185,6 +2199,38 @@ paths: summary: Get subscription data tags: - Subscription + /api/v1/sub/update: + put: + consumes: + - application/json + description: Resync subscription using a pre-auth session + parameters: + - description: Subscription update request + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.SubscriptionUpdateReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/fiber.Map' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Update subscription via PASession + tags: + - Subscription /api/v1/verify/email/otp/confirm: post: consumes: From c968e1cf61184998a945e87bd8317f54b56929bb Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 9 Apr 2026 16:14:24 +0200 Subject: [PATCH 13/54] feat(app): subscription status display with resync and lifecycle alerts Signed-off-by: Maciek --- app/src/api/client/api.ts | 96 ++++++++++ app/src/components/AccountSubscription.tsx | 178 ++++++++++++++++++ app/src/components/general/StatusBadge.tsx | 2 +- app/src/pages/account_preferences/Account.tsx | 11 +- 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 app/src/components/AccountSubscription.tsx diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index 28a1e0a6..892c0e81 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -1610,6 +1610,25 @@ export interface RequestsRotatePASessionReq { */ 'sessionid': string; } +/** + * + * @export + * @interface RequestsSubscriptionUpdateReq + */ +export interface RequestsSubscriptionUpdateReq { + /** + * + * @type {string} + * @memberof RequestsSubscriptionUpdateReq + */ + 'id': string; + /** + * + * @type {string} + * @memberof RequestsSubscriptionUpdateReq + */ + 'subid': string; +} /** * * @export @@ -1808,6 +1827,12 @@ export interface ServicescatalogService { * @memberof ServicescatalogService */ 'asns'?: Array; + /** + * + * @type {Array} + * @memberof ServicescatalogService + */ + 'domains'?: Array; /** * * @type {string} @@ -5514,6 +5539,42 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Resync subscription using a pre-auth session + * @summary Update subscription via PASession + * @param {RequestsSubscriptionUpdateReq} body Subscription update request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1SubUpdatePut: async (body: RequestsSubscriptionUpdateReq, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1SubUpdatePut', 'body', body) + const localVarPath = `/api/v1/sub/update`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -5541,6 +5602,19 @@ export const SubscriptionApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubGet']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Resync subscription using a pre-auth session + * @summary Update subscription via PASession + * @param {RequestsSubscriptionUpdateReq} body Subscription update request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1SubUpdatePut(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubUpdatePut']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, } }; @@ -5560,6 +5634,16 @@ export const SubscriptionApiFactory = function (configuration?: Configuration, b apiV1SubGet(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1SubGet(options).then((request) => request(axios, basePath)); }, + /** + * Resync subscription using a pre-auth session + * @summary Update subscription via PASession + * @param {RequestsSubscriptionUpdateReq} body Subscription update request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { + return localVarFp.apiV1SubUpdatePut(body, options).then((request) => request(axios, basePath)); + }, }; }; @@ -5580,6 +5664,18 @@ export class SubscriptionApi extends BaseAPI { public apiV1SubGet(options?: RawAxiosRequestConfig) { return SubscriptionApiFp(this.configuration).apiV1SubGet(options).then((request) => request(this.axios, this.basePath)); } + + /** + * Resync subscription using a pre-auth session + * @summary Update subscription via PASession + * @param {RequestsSubscriptionUpdateReq} body Subscription update request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SubscriptionApi + */ + public apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig) { + return SubscriptionApiFp(this.configuration).apiV1SubUpdatePut(body, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx new file mode 100644 index 00000000..984bc6bb --- /dev/null +++ b/app/src/components/AccountSubscription.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Info } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import StatusBadge from "@/components/general/StatusBadge"; +import api from "@/api/api"; +import type { ModelSubscription } from "@/api/client/api"; + +const RESYNC_URL = import.meta.env.VITE_RESYNC_URL || "https://www.ivpn.net/en/account/"; + +export default function AccountSubscription() { + const [sub, setSub] = useState(null); + const [error, setError] = useState(""); + const [syncing, setSyncing] = useState(false); + const [searchParams] = useSearchParams(); + + const sessionid = searchParams.get("sessionid") || ""; + const subid = searchParams.get("subid") || ""; + + const fetchSubscription = async () => { + try { + const res = await api.Client.subscriptionApi.apiV1SubGet(); + setSub(res.data); + } catch { + setError("Failed to load subscription."); + } + }; + + const resync = async () => { + if (!sessionid || !subid) return; + + setSyncing(true); + setError(""); + try { + await api.Client.paSessionApi.apiV1PasessionRotatePut({ sessionid }); + const currentSub = await api.Client.subscriptionApi.apiV1SubGet(); + await api.Client.subscriptionApi.apiV1SubUpdatePut({ + id: currentSub.data.id || "", + subid, + }); + await fetchSubscription(); + } catch { + setError("Failed to sync subscription. Please try again."); + } finally { + setSyncing(false); + } + }; + + useEffect(() => { fetchSubscription(); }, []); + + useEffect(() => { + if (sessionid && subid) { resync(); } + }, [sessionid, subid]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!sub) return null; + + const isActive = sub.status === "active" || sub.status === "grace_period"; + const isLimited = sub.status === "limited_access"; + const isPendingDelete = sub.status === "pending_delete"; + const hasAlerts = isLimited || isPendingDelete || sub.outage || !!error; + + const statusBadge = syncing + ? + : isActive + ? + : isLimited + ? + : isPendingDelete + ? + : ; + + const formatDate = (dateStr?: string) => { + if (!dateStr) return "—"; + return new Date(dateStr).toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", + }); + }; + + const rows: { label: string; value: React.ReactNode }[] = [ + { label: "Status", value: statusBadge }, + { label: "Active until", value: formatDate(sub.active_until) }, + ]; + + if (sub.updated_at) { + rows.push({ label: "Last synced", value: formatDate(sub.updated_at) }); + } + + return ( + <> + {/* Alerts — rendered first, meant to be placed above the cards by parent */} + {hasAlerts && ( +
+ {isLimited && ( +
+ +
+

+ Limited Access Mode +

+

+ Your modDNS account is in limited access mode. To regain full access add time to your{" "} + IVPN account. +

+
+
+ )} + + {isPendingDelete && ( +
+ +
+

+ Pending Deletion +

+

+ Your account is pending deletion. To reinstate access add time to your{" "} + IVPN account. +

+
+
+ )} + + {sub.outage && ( +
+ +
+

+ Out of sync +

+

+ Your last account status update was {sub.updated_at ? formatDate(sub.updated_at) : "unknown"}.{" "} + Sync with IVPN +

+
+
+ )} + + {error && ( +

{error}

+ )} +
+ )} + + {/* Subscription info card */} + + +
+

+ Subscription +

+
+ +
+ {rows.map((item, index) => ( +
+ + {item.label} + +
+ {typeof item.value === "string" ? ( + + {item.value} + + ) : ( + item.value + )} +
+
+ ))} +
+
+
+ + ); +} diff --git a/app/src/components/general/StatusBadge.tsx b/app/src/components/general/StatusBadge.tsx index 72f2cb11..60beb546 100644 --- a/app/src/components/general/StatusBadge.tsx +++ b/app/src/components/general/StatusBadge.tsx @@ -16,7 +16,7 @@ export interface StatusBadgeProps { const intentStyles: Record = { success: 'bg-[var(--tailwind-colors-rdns-600)] border border-[var(--tailwind-colors-rdns-600)] text-[var(--tailwind-colors-slate-700)]', error: '!bg-[var(--tailwind-colors-red-600)] border border-[var(--tailwind-colors-red-600)] text-white', - warning: 'bg-[var(--tailwind-colors-orange-500)]/30 border border-[var(--tailwind-colors-orange-500)] text-[var(--tailwind-colors-slate-900)]', + warning: 'bg-[var(--tailwind-colors-orange-500)]/30 border border-[var(--tailwind-colors-orange-500)] text-[var(--tailwind-colors-orange-300)]', info: 'bg-[var(--tailwind-colors-rdns-600)]/30 border border-[var(--tailwind-colors-rdns-600)] text-[var(--tailwind-colors-slate-50)]', neutral: 'bg-[var(--tailwind-colors-slate-600)]/30 border border-[var(--tailwind-colors-slate-600)] text-[var(--tailwind-colors-slate-50)]', }; diff --git a/app/src/pages/account_preferences/Account.tsx b/app/src/pages/account_preferences/Account.tsx index f359adb2..972a556b 100644 --- a/app/src/pages/account_preferences/Account.tsx +++ b/app/src/pages/account_preferences/Account.tsx @@ -24,6 +24,7 @@ import PasskeySettings from "@/pages/account_preferences/PasskeySettings"; import VerifyEmailDialog from "@/pages/account_preferences/VerifyEmailDialog"; import ChangeEmailDialog from "@/pages/account_preferences/ChangeEmailDialog"; import { useAppStore } from "@/store/general"; +import AccountSubscription from "@/components/AccountSubscription"; interface PreferencesSectionProps { account: ModelAccount | null; @@ -220,12 +221,10 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element =
- {/* Account Info Card */} -
-
+ {/* Alerts + Account Info + Subscription Cards */} +
+ +
From 29a1e3887309317aef7a7a339f9977898b566caf Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 10 Apr 2026 09:17:34 +0200 Subject: [PATCH 14/54] chore(api): Change service catalog log level Signed-off-by: Maciek --- libs/servicescatalogcache/loader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/servicescatalogcache/loader.go b/libs/servicescatalogcache/loader.go index b0a52bbf..b21abe01 100644 --- a/libs/servicescatalogcache/loader.go +++ b/libs/servicescatalogcache/loader.go @@ -73,7 +73,7 @@ func (l *Loader) Reload() error { return err } - log.Debug().Str("path", l.path).Int("services", len(cat.Services)).Msg("Services catalog loaded") + log.Trace().Str("path", l.path).Int("services", len(cat.Services)).Msg("Services catalog loaded") return nil } From f582843da9945d08c7a2ce83c281b7f3bf0521ac Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 10 Apr 2026 10:06:56 +0200 Subject: [PATCH 15/54] chore(api): Setup API endpoints as expected Signed-off-by: Maciek --- api/api/server.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/api/server.go b/api/api/server.go index f740a78c..e48594bc 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -109,12 +109,12 @@ func (s *APIServer) RegisterRoutes() { v1.Post("/login", middleware.NewLimit(10, 1*time.Minute), s.login()) - // PSK-protected PASession endpoint (outside v1 auth chain) - pasession := s.App.Group("/api/v1/pasession") - pasession.Use(middleware.NewPSK(*s.Config.API)) - pasession.Post("/add", middleware.NewLimit(10, 1*time.Minute), s.addPASession()) + // PSK-protected PASession add endpoint (outside v1 auth chain) + pasessionPSK := s.App.Group("/api/v1/pasession/add") + pasessionPSK.Use(middleware.NewPSK(*s.Config.API)) + pasessionPSK.Post("", middleware.NewLimit(10, 1*time.Minute), s.addPASession()) - // Public PASession rotation endpoint + // Public PASession rotation endpoint (no auth, rate limited only) v1.Put("/pasession/rotate", middleware.NewLimit(10, 1*time.Minute), s.rotatePASession()) accounts := v1.Group("/accounts") From 27082213d8e3bc049e9a99d1c47336d23fc12719 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 10 Apr 2026 16:41:50 +0200 Subject: [PATCH 16/54] fix(api): Fix incorrect webhook log logic Signed-off-by: Maciek --- api/internal/client/http.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/internal/client/http.go b/api/internal/client/http.go index 695eef1e..fc472c98 100644 --- a/api/internal/client/http.go +++ b/api/internal/client/http.go @@ -42,8 +42,9 @@ func (h Http) SignupWebhook(subID string) error { log.Error().Int("status", status).Msgf("Error calling signup webhook") return errors.New("error response from signup webhook") } + } else { + log.Debug().Msg("No signup webhook configured, skipping") } - log.Debug().Msg("No signup webhook configured, skipping") return nil } From 3a2ba19fc9245452f14c91514ba5cbeb95071f22 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 10 Apr 2026 19:04:01 +0200 Subject: [PATCH 17/54] fix(app): Avoid multiple sign up requests Signed-off-by: Maciek --- app/src/pages/auth/Signup.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/pages/auth/Signup.tsx b/app/src/pages/auth/Signup.tsx index b3febd5a..9c696475 100644 --- a/app/src/pages/auth/Signup.tsx +++ b/app/src/pages/auth/Signup.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "@/App"; @@ -26,6 +26,7 @@ export default function Signup() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); + const submittingRef = useRef(false); useEffect(() => { if (isAuthenticated) { @@ -54,6 +55,9 @@ export default function Signup() { } const handleSignup = async (email: string, password: string) => { + // Guard against double-submit: ref is synchronous, unlike state + if (submittingRef.current) return; + submittingRef.current = true; setLoading(true); setError(null); @@ -84,12 +88,15 @@ export default function Signup() { setError(errorMessage); authToasts.unexpectedError(errorMessage); + submittingRef.current = false; // allow retry on failure } finally { setLoading(false); } }; const handlePasskeySignup = async (email: string) => { + if (submittingRef.current) return; + submittingRef.current = true; setLoading(true); setError(null); @@ -110,6 +117,7 @@ export default function Signup() { setError(errorMessage); authToasts.unexpectedError(errorMessage); + submittingRef.current = false; } finally { setLoading(false); } From c2262e09c7b8adadfa8a3509b0917b03fdd5ea93 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 27 Apr 2026 16:42:11 +0200 Subject: [PATCH 18/54] feat(libs): register UUID BSON codec for subtype 0x04 Signed-off-by: Maciek --- libs/store/mongodb.go | 54 ++++++++++++++++++++ libs/store/mongodb_codec_test.go | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 libs/store/mongodb_codec_test.go diff --git a/libs/store/mongodb.go b/libs/store/mongodb.go index 61c916d4..3a58b8c9 100644 --- a/libs/store/mongodb.go +++ b/libs/store/mongodb.go @@ -6,10 +6,15 @@ import ( "crypto/x509" "fmt" "os" + "reflect" "time" + "github.com/google/uuid" "github.com/ivpn/dns/libs/store/migrator" "github.com/rs/zerolog/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsoncodec" + "go.mongodb.org/mongo-driver/bson/bsonrw" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/readpref" @@ -20,6 +25,54 @@ const ( DbPingTimeout = 20 * time.Second ) +// buildUUIDRegistry returns a BSON registry that encodes uuid.UUID as BSON +// binary subtype 0x04 (the canonical UUID subtype per the BSON spec). +// +// The default mongo-driver v1 codec treats uuid.UUID ([16]byte) as a generic +// byte array and writes it with subtype 0x00. That round-trips within a single +// Go service but breaks cross-driver queries and fails the BSON spec contract, +// so every mongo client constructed by this package installs the registry. +// +// The decoder is deliberately tolerant of legacy subtypes 0x00 and 0x03 so +// existing documents (written before the codec was installed) keep decoding +// while the data is being migrated to 0x04. +func buildUUIDRegistry() *bsoncodec.Registry { + const uuidSubtype = byte(0x04) + tUUID := reflect.TypeOf(uuid.UUID{}) + + uuidEncoder := bsoncodec.ValueEncoderFunc(func(_ bsoncodec.EncodeContext, vw bsonrw.ValueWriter, val reflect.Value) error { + if !val.IsValid() || val.Type() != tUUID { + return bsoncodec.ValueEncoderError{Name: "uuidEncoder", Types: []reflect.Type{tUUID}, Received: val} + } + u := val.Interface().(uuid.UUID) + return vw.WriteBinaryWithSubtype(u[:], uuidSubtype) + }) + + uuidDecoder := bsoncodec.ValueDecoderFunc(func(_ bsoncodec.DecodeContext, vr bsonrw.ValueReader, val reflect.Value) error { + if !val.CanSet() || val.Type() != tUUID { + return bsoncodec.ValueDecoderError{Name: "uuidDecoder", Types: []reflect.Type{tUUID}, Received: val} + } + data, subtype, err := vr.ReadBinary() + if err != nil { + return err + } + if subtype != 0x00 && subtype != 0x03 && subtype != 0x04 { + return fmt.Errorf("unsupported binary subtype %#x for uuid.UUID", subtype) + } + u, err := uuid.FromBytes(data) + if err != nil { + return err + } + val.Set(reflect.ValueOf(u)) + return nil + }) + + reg := bson.NewRegistry() + reg.RegisterTypeEncoder(tUUID, uuidEncoder) + reg.RegisterTypeDecoder(tUUID, uuidDecoder) + return reg +} + // MongoDB is a MongoDB database instance type MongoDB struct { Config *Config @@ -51,6 +104,7 @@ func (db *MongoDB) connect() error { defer cancel() clientOpts := options.Client().ApplyURI(db.Config.DbURI) + clientOpts.SetRegistry(buildUUIDRegistry()) if db.Config.Username != "" && db.Config.Password != "" { log.Debug().Msg("Authenticating to mongoDB") credentials := buildMongoCredentials(db.Config) diff --git a/libs/store/mongodb_codec_test.go b/libs/store/mongodb_codec_test.go new file mode 100644 index 00000000..694b28da --- /dev/null +++ b/libs/store/mongodb_codec_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// docWithUUIDID mirrors how api/model/subscription.go maps uuid.UUID into _id. +type docWithUUIDID struct { + ID uuid.UUID `bson:"_id"` +} + +func TestUUIDCodec_EncodesSubtype04(t *testing.T) { + reg := buildUUIDRegistry() + id := uuid.New() + + data, err := bson.MarshalWithRegistry(reg, docWithUUIDID{ID: id}) + require.NoError(t, err) + + // Decode as raw bson.D to inspect the stored subtype independently of the + // registry's decoder. + var raw bson.D + require.NoError(t, bson.Unmarshal(data, &raw)) + require.Len(t, raw, 1) + require.Equal(t, "_id", raw[0].Key) + + bin, ok := raw[0].Value.(primitive.Binary) + require.True(t, ok, "expected _id to decode as primitive.Binary, got %T", raw[0].Value) + require.Equal(t, byte(0x04), bin.Subtype, "expected UUID subtype 0x04") + require.Equal(t, id[:], bin.Data) + + // Round-trip into a typed struct and ensure the UUID comes back intact. + var decoded docWithUUIDID + require.NoError(t, bson.UnmarshalWithRegistry(reg, data, &decoded)) + require.Equal(t, id, decoded.ID) +} + +func TestUUIDCodec_DecodesLegacySubtype00(t *testing.T) { + reg := buildUUIDRegistry() + id := uuid.New() + + // Hand-craft a BSON document with the legacy subtype 0x00 that the default + // mongo-driver codec used to produce for uuid.UUID values. + legacy := bson.D{{Key: "_id", Value: primitive.Binary{Subtype: 0x00, Data: id[:]}}} + data, err := bson.Marshal(legacy) + require.NoError(t, err) + + var decoded docWithUUIDID + require.NoError(t, bson.UnmarshalWithRegistry(reg, data, &decoded)) + require.Equal(t, id, decoded.ID) +} + +func TestUUIDCodec_DecodesLegacySubtype03(t *testing.T) { + reg := buildUUIDRegistry() + id := uuid.New() + + // Subtype 0x03 is the deprecated "old UUID" subtype. Some drivers still + // emit it; the tolerant decoder should accept it. + legacy := bson.D{{Key: "_id", Value: primitive.Binary{Subtype: 0x03, Data: id[:]}}} + data, err := bson.Marshal(legacy) + require.NoError(t, err) + + var decoded docWithUUIDID + require.NoError(t, bson.UnmarshalWithRegistry(reg, data, &decoded)) + require.Equal(t, id, decoded.ID) +} + +func TestUUIDCodec_RejectsInvalidSubtype(t *testing.T) { + reg := buildUUIDRegistry() + id := uuid.New() + + // Any non-UUID binary subtype should be rejected loudly rather than + // silently copying bytes into a uuid.UUID. + bad := bson.D{{Key: "_id", Value: primitive.Binary{Subtype: 0x05, Data: id[:]}}} + data, err := bson.Marshal(bad) + require.NoError(t, err) + + var decoded docWithUUIDID + err = bson.UnmarshalWithRegistry(reg, data, &decoded) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported binary subtype") +} From f81bbdd7e03505b668432a946cfa54f43af367b4 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 27 Apr 2026 16:49:49 +0200 Subject: [PATCH 19/54] refactor(api): remove dead GetSubscriptionById Signed-off-by: Maciek --- api/db/mongodb/subscription.go | 14 ------ api/db/repository/subscription.go | 1 - api/mocks/db.go | 68 ---------------------------- api/mocks/servicer.go | 68 ---------------------------- api/mocks/subscription_repository.go | 68 ---------------------------- api/mocks/subscription_servicer.go | 68 ---------------------------- api/service/service.go | 1 - api/service/subscription/service.go | 14 +++--- 8 files changed, 8 insertions(+), 294 deletions(-) diff --git a/api/db/mongodb/subscription.go b/api/db/mongodb/subscription.go index e0adb6e7..7ad71bf6 100644 --- a/api/db/mongodb/subscription.go +++ b/api/db/mongodb/subscription.go @@ -50,20 +50,6 @@ func (r *SubscriptionRepository) GetSubscriptionByAccountId(ctx context.Context, return &subscription, nil } -// GetSubscriptionById returns subscription by its UUID (_id) -func (r *SubscriptionRepository) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - // The subscriptionId is a UUID string stored as _id field - filter := bson.D{primitive.E{Key: "_id", Value: subscriptionId}} - var subscription model.Subscription - if err := r.subscriptionsCollection.FindOne(ctx, filter).Decode(&subscription); err != nil { - if err == mongo.ErrNoDocuments { - return nil, errors.ErrSubscriptionNotFound - } - return nil, err - } - return &subscription, nil -} - // Upsert creates or updates a subscription in the subscriptions collection func (r *SubscriptionRepository) Upsert(ctx context.Context, subscription model.Subscription) error { filter := bson.M{"account_id": subscription.AccountID} diff --git a/api/db/repository/subscription.go b/api/db/repository/subscription.go index 808fa3a5..78a2a6a6 100644 --- a/api/db/repository/subscription.go +++ b/api/db/repository/subscription.go @@ -10,7 +10,6 @@ import ( // SubscriptionRepository represents a Subscription repository type SubscriptionRepository interface { GetSubscriptionByAccountId(ctx context.Context, accountId string) (*model.Subscription, error) - GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) Upsert(ctx context.Context, subscription model.Subscription) error Create(ctx context.Context, subscription model.Subscription) error ResetNotifiedForActive(ctx context.Context) error diff --git a/api/mocks/db.go b/api/mocks/db.go index d7f723e3..bd6d075b 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -2333,74 +2333,6 @@ func (_c *Db_GetSubscriptionByAccountId_Call) RunAndReturn(run func(ctx context. return _c } -// GetSubscriptionById provides a mock function for the type Db -func (_mock *Db) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - ret := _mock.Called(ctx, subscriptionId) - - if len(ret) == 0 { - panic("no return value specified for GetSubscriptionById") - } - - var r0 *model.Subscription - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { - return returnFunc(ctx, subscriptionId) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { - r0 = returnFunc(ctx, subscriptionId) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// Db_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type Db_GetSubscriptionById_Call struct { - *mock.Call -} - -// GetSubscriptionById is a helper method to define mock.On call -// - ctx context.Context -// - subscriptionId string -func (_e *Db_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *Db_GetSubscriptionById_Call { - return &Db_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} -} - -func (_c *Db_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *Db_GetSubscriptionById_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *Db_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *Db_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) - return _c -} - -func (_c *Db_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *Db_GetSubscriptionById_Call { - _c.Call.Return(run) - return _c -} - // MarkNotified provides a mock function for the type Db func (_mock *Db) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { ret := _mock.Called(ctx, subscriptionIDs) diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 8689a82a..646af8dc 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -2917,74 +2917,6 @@ func (_c *Servicer_GetSubscription_Call) RunAndReturn(run func(ctx context.Conte return _c } -// GetSubscriptionById provides a mock function for the type Servicer -func (_mock *Servicer) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - ret := _mock.Called(ctx, subscriptionId) - - if len(ret) == 0 { - panic("no return value specified for GetSubscriptionById") - } - - var r0 *model.Subscription - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { - return returnFunc(ctx, subscriptionId) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { - r0 = returnFunc(ctx, subscriptionId) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// Servicer_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type Servicer_GetSubscriptionById_Call struct { - *mock.Call -} - -// GetSubscriptionById is a helper method to define mock.On call -// - ctx context.Context -// - subscriptionId string -func (_e *Servicer_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *Servicer_GetSubscriptionById_Call { - return &Servicer_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} -} - -func (_c *Servicer_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *Servicer_GetSubscriptionById_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *Servicer_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *Servicer_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) - return _c -} - -func (_c *Servicer_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *Servicer_GetSubscriptionById_Call { - _c.Call.Return(run) - return _c -} - // GetUnfinishedSignupOrPostAccount provides a mock function for the type Servicer func (_mock *Servicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error) { ret := _mock.Called(ctx, email, password, subscriptionID, sessionID) diff --git a/api/mocks/subscription_repository.go b/api/mocks/subscription_repository.go index 2012d415..512ec7b6 100644 --- a/api/mocks/subscription_repository.go +++ b/api/mocks/subscription_repository.go @@ -226,74 +226,6 @@ func (_c *SubscriptionRepository_GetSubscriptionByAccountId_Call) RunAndReturn(r return _c } -// GetSubscriptionById provides a mock function for the type SubscriptionRepository -func (_mock *SubscriptionRepository) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - ret := _mock.Called(ctx, subscriptionId) - - if len(ret) == 0 { - panic("no return value specified for GetSubscriptionById") - } - - var r0 *model.Subscription - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { - return returnFunc(ctx, subscriptionId) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { - r0 = returnFunc(ctx, subscriptionId) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// SubscriptionRepository_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type SubscriptionRepository_GetSubscriptionById_Call struct { - *mock.Call -} - -// GetSubscriptionById is a helper method to define mock.On call -// - ctx context.Context -// - subscriptionId string -func (_e *SubscriptionRepository_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *SubscriptionRepository_GetSubscriptionById_Call { - return &SubscriptionRepository_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} -} - -func (_c *SubscriptionRepository_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *SubscriptionRepository_GetSubscriptionById_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *SubscriptionRepository_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *SubscriptionRepository_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) - return _c -} - -func (_c *SubscriptionRepository_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *SubscriptionRepository_GetSubscriptionById_Call { - _c.Call.Return(run) - return _c -} - // MarkNotified provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { ret := _mock.Called(ctx, subscriptionIDs) diff --git a/api/mocks/subscription_servicer.go b/api/mocks/subscription_servicer.go index 685461bd..6ec88854 100644 --- a/api/mocks/subscription_servicer.go +++ b/api/mocks/subscription_servicer.go @@ -226,74 +226,6 @@ func (_c *SubscriptionServicer_GetSubscription_Call) RunAndReturn(run func(ctx c return _c } -// GetSubscriptionById provides a mock function for the type SubscriptionServicer -func (_mock *SubscriptionServicer) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - ret := _mock.Called(ctx, subscriptionId) - - if len(ret) == 0 { - panic("no return value specified for GetSubscriptionById") - } - - var r0 *model.Subscription - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { - return returnFunc(ctx, subscriptionId) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { - r0 = returnFunc(ctx, subscriptionId) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// SubscriptionServicer_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type SubscriptionServicer_GetSubscriptionById_Call struct { - *mock.Call -} - -// GetSubscriptionById is a helper method to define mock.On call -// - ctx context.Context -// - subscriptionId string -func (_e *SubscriptionServicer_Expecter) GetSubscriptionById(ctx interface{}, subscriptionId interface{}) *SubscriptionServicer_GetSubscriptionById_Call { - return &SubscriptionServicer_GetSubscriptionById_Call{Call: _e.mock.On("GetSubscriptionById", ctx, subscriptionId)} -} - -func (_c *SubscriptionServicer_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *SubscriptionServicer_GetSubscriptionById_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *SubscriptionServicer_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *SubscriptionServicer_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) - return _c -} - -func (_c *SubscriptionServicer_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *SubscriptionServicer_GetSubscriptionById_Call { - _c.Call.Return(run) - return _c -} - // RotatePASessionID provides a mock function for the type SubscriptionServicer func (_mock *SubscriptionServicer) RotatePASessionID(ctx context.Context, oldID string) (string, error) { ret := _mock.Called(ctx, oldID) diff --git a/api/service/service.go b/api/service/service.go index 924f6edf..2575340f 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -176,7 +176,6 @@ type SubscriptionServicer interface { AddPASession(ctx context.Context, session *model.PASession) error RotatePASessionID(ctx context.Context, oldID string) (string, error) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) - GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error } diff --git a/api/service/subscription/service.go b/api/service/subscription/service.go index 1f2ef2d0..8d157df0 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -61,11 +61,6 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, accountId str return subscription, nil } -// GetSubscriptionById returns subscription by its UUID. -func (s *SubscriptionService) GetSubscriptionById(ctx context.Context, subscriptionId string) (*model.Subscription, error) { - return s.SubscriptionRepository.GetSubscriptionById(ctx, subscriptionId) -} - // UpdateSubscription updates subscription data. func (s *SubscriptionService) UpdateSubscription(ctx context.Context, accountId string, updates []model.SubscriptionUpdate) (*model.Subscription, error) { subscription, err := s.SubscriptionRepository.GetSubscriptionByAccountId(ctx, accountId) @@ -135,11 +130,13 @@ func (s *SubscriptionService) RotatePASessionID(ctx context.Context, oldID strin func (s *SubscriptionService) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) { paSession, err := s.Cache.GetPASession(ctx, sessionID) if err != nil { + log.Warn().Err(err).Str("session_id", sessionID).Msg("ValidateAndGetPreauth: PASession not found in cache") return nil, ErrPASessionNotFound } preauth, err := s.Http.GetPreauth(paSession.PreauthID) if err != nil { + log.Warn().Err(err).Str("preauth_id", paSession.PreauthID).Msg("ValidateAndGetPreauth: preauth service call failed") return nil, ErrPANotFound } @@ -147,7 +144,12 @@ func (s *SubscriptionService) ValidateAndGetPreauth(ctx context.Context, session tokenHashStr := base64.StdEncoding.EncodeToString(tokenHash[:]) if subtle.ConstantTimeCompare([]byte(tokenHashStr), []byte(preauth.TokenHash)) != 1 { - log.Warn().Str("session_id", sessionID).Msg("Token hash mismatch during PASession validation") + log.Warn(). + Str("session_id", sessionID). + Str("preauth_id", paSession.PreauthID). + Str("computed_hash", tokenHashStr). + Str("preauth_hash", preauth.TokenHash). + Msg("ValidateAndGetPreauth: token hash mismatch") return nil, ErrTokenHashMismatch } From 994e8e1e44908fd4fd5e0ffe2ece8074842dc2df Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 27 Apr 2026 16:51:13 +0200 Subject: [PATCH 20/54] feat(api): run subscription UUID subtype migration on startup Signed-off-by: Maciek --- api/config/config.go | 22 +- .../migrations/subscription_uuid_subtype.go | 142 +++++++++ .../subscription_uuid_subtype_test.go | 291 ++++++++++++++++++ api/main.go | 13 + 4 files changed, 459 insertions(+), 9 deletions(-) create mode 100644 api/internal/migrations/subscription_uuid_subtype.go create mode 100644 api/internal/migrations/subscription_uuid_subtype_test.go diff --git a/api/config/config.go b/api/config/config.go index 8275481b..d5ac1d1e 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -48,6 +48,9 @@ type ServiceConfig struct { MaxCredentials int ServicesCatalogPath string ServicesCatalogReloadEvery time.Duration + + // Startup migrations (removable after all environments are migrated) + MigrateSubscriptionUUIDSubtype bool } // SentryConfig represents the Sentry configuration @@ -244,15 +247,16 @@ func New() (*Config, error) { AuthToken: os.Getenv("EMAIL_SENDER_AUTH_TOKEN"), }, Service: &ServiceConfig{ - OTPExpirationTime: otpExp, - MobileConfigPrivateKeyPath: os.Getenv("MOBILECONFIG_PRIVATE_KEY_PATH"), - MobileConfigCertPath: os.Getenv("MOBILECONFIG_CERT_PATH"), - IdLimiterMax: idLimiterMax, - IdLimiterExpiration: idLimiterExpiration, - MaxProfiles: maxProfiles, - MaxCredentials: maxCredentials, - ServicesCatalogPath: servicesCatalogPath, - ServicesCatalogReloadEvery: servicesCatalogReloadEvery, + OTPExpirationTime: otpExp, + MobileConfigPrivateKeyPath: os.Getenv("MOBILECONFIG_PRIVATE_KEY_PATH"), + MobileConfigCertPath: os.Getenv("MOBILECONFIG_CERT_PATH"), + IdLimiterMax: idLimiterMax, + IdLimiterExpiration: idLimiterExpiration, + MaxProfiles: maxProfiles, + MaxCredentials: maxCredentials, + ServicesCatalogPath: servicesCatalogPath, + ServicesCatalogReloadEvery: servicesCatalogReloadEvery, + MigrateSubscriptionUUIDSubtype: parseBoolEnv("MIGRATE_SUBSCRIPTION_UUID_SUBTYPE"), }, Sentry: &SentryConfig{ DSN: os.Getenv("SENTRY_DSN"), diff --git a/api/internal/migrations/subscription_uuid_subtype.go b/api/internal/migrations/subscription_uuid_subtype.go new file mode 100644 index 00000000..490a46b7 --- /dev/null +++ b/api/internal/migrations/subscription_uuid_subtype.go @@ -0,0 +1,142 @@ +// Package migrations provides Go-based data migrations that run on API +// startup when enabled via config flags. Unlike the JSON schema migrations +// in api/db/mongodb/migrations/ (managed by golang-migrate), these are +// custom Go logic for data transformations that can't be expressed in JSON. +package migrations + +import ( + "context" + "fmt" + + "github.com/rs/zerolog/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" +) + +// subscriptionsCollection is the collection name used by api/db/mongodb. +const subscriptionsCollection = "subscriptions" + +// Stats captures the outcome of a migration run. +type Stats struct { + Scanned int // total documents the cursor yielded + Migrated int // documents whose _id was rewritten from !=0x04 to 0x04 + Skipped int // already-0x04 docs, or docs with unexpected _id shape + Failed int // per-document errors (do not abort the run) +} + +// MigrateSubscriptionUUIDSubtype walks the subscriptions collection and +// rewrites every _id with a non-0x04 binary subtype to subtype 0x04. +// +// Background: the mongo-driver v1 default codec encodes uuid.UUID as BSON +// binary subtype 0x00 (generic). A registered codec in libs/store/mongodb.go +// now writes subtype 0x04 going forward, but existing documents in staging +// and production still carry subtype 0x00 and therefore stop matching +// MarkNotified queries sent by the new code. +// +// The migration is idempotent: re-running it on a fully-migrated collection +// scans every doc, sees subtype 0x04, increments Skipped, and returns with +// Migrated == 0. +func MigrateSubscriptionUUIDSubtype(ctx context.Context, client *mongo.Client, dbName string) (Stats, error) { + coll := client.Database(dbName).Collection(subscriptionsCollection) + + cursor, err := coll.Find(ctx, bson.D{}) + if err != nil { + return Stats{}, fmt.Errorf("find subscriptions: %w", err) + } + defer cursor.Close(ctx) + + var stats Stats + for cursor.Next(ctx) { + stats.Scanned++ + + var raw bson.Raw + if err := cursor.Decode(&raw); err != nil { + log.Error().Err(err).Int("scanned", stats.Scanned).Msg("decode raw subscription failed") + stats.Failed++ + continue + } + + subtype, data, ok := raw.Lookup("_id").BinaryOK() + if !ok { + log.Warn().Int("scanned", stats.Scanned).Msg("subscription _id is not binary, skipping") + stats.Skipped++ + continue + } + if subtype == 0x04 { + stats.Skipped++ // already migrated + continue + } + if subtype != 0x00 && subtype != 0x03 { + log.Warn().Uint8("subtype", subtype).Msg("unexpected _id subtype, skipping") + stats.Skipped++ + continue + } + + if err := rewriteIDSubtype(ctx, coll, raw, subtype, data); err != nil { + log.Error().Err(err).Msg("rewrite _id subtype failed for document") + stats.Failed++ + continue + } + stats.Migrated++ + } + if err := cursor.Err(); err != nil { + return stats, fmt.Errorf("cursor iteration: %w", err) + } + + return stats, nil +} + +// rewriteIDSubtype rebuilds a document with its _id recoded as +// primitive.Binary subtype 0x04, then inserts the rewritten copy and deletes +// the legacy row. The two writes are NOT wrapped in a multi-document +// transaction so the migration runs against standalone and replica-set +// deployments alike. +// +// Safety comes from fixed ordering (insert-new, delete-old) plus an +// idempotency pre-check: if a previous interrupted run left both copies +// coexisting, the next run detects the 0x04 copy, skips the insert, and +// finishes by deleting the legacy row. +func rewriteIDSubtype( + ctx context.Context, + coll *mongo.Collection, + raw bson.Raw, + oldSubtype byte, + idBytes []byte, +) error { + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("unmarshal document: %w", err) + } + + newID := primitive.Binary{Subtype: 0x04, Data: idBytes} + oldID := primitive.Binary{Subtype: oldSubtype, Data: idBytes} + + for i := range doc { + if doc[i].Key == "_id" { + doc[i].Value = newID + break + } + } + + // Idempotency pre-check: if the 0x04 copy is already present from a + // previous interrupted run, just finish the legacy delete. + err := coll.FindOne(ctx, bson.D{{Key: "_id", Value: newID}}).Err() + if err == nil { + if _, derr := coll.DeleteOne(ctx, bson.D{{Key: "_id", Value: oldID}}); derr != nil { + return fmt.Errorf("delete legacy _id after partial rerun: %w", derr) + } + return nil + } + if err != mongo.ErrNoDocuments { + return fmt.Errorf("check for existing 0x04 _id: %w", err) + } + + if _, err := coll.InsertOne(ctx, doc); err != nil { + return fmt.Errorf("insert rewritten document: %w", err) + } + if _, err := coll.DeleteOne(ctx, bson.D{{Key: "_id", Value: oldID}}); err != nil { + return fmt.Errorf("delete legacy _id: %w", err) + } + return nil +} diff --git a/api/internal/migrations/subscription_uuid_subtype_test.go b/api/internal/migrations/subscription_uuid_subtype_test.go new file mode 100644 index 00000000..61f82f19 --- /dev/null +++ b/api/internal/migrations/subscription_uuid_subtype_test.go @@ -0,0 +1,291 @@ +package migrations + +import ( + "context" + "fmt" + "net/url" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/ivpn/dns/libs/store" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MigrateSuite exercises the subscription uuid subtype migration against a +// real MongoDB running in a testcontainer, matching the pattern used by +// api/db/mongodb/account_test.go. +type MigrateSuite struct { + suite.Suite + client *mongo.Client + dbName string + container testcontainers.Container + // storeCfg mirrors the live container so TestCodecWiredThroughNewMongoDB + // can reconnect via libs/store.NewMongoDB and exercise the real connect() + // path including clientOpts.SetRegistry. + storeCfg *store.Config +} + +func TestMigrateSuite(t *testing.T) { + suite.Run(t, new(MigrateSuite)) +} + +func (s *MigrateSuite) SetupSuite() { + ctx := context.Background() + + mongoImage := firstNonEmptyEnv("TEST_MONGO_IMAGE", "mongo:7.0.8") + username := firstNonEmptyEnv("TEST_MONGO_USERNAME", "testuser") + password := firstNonEmptyEnv("TEST_MONGO_PASSWORD", "testpass") + authSource := firstNonEmptyEnv("DB_AUTH_SOURCE", "admin") + + req := testcontainers.ContainerRequest{ + Image: mongoImage, + Env: map[string]string{ + "MONGO_INITDB_ROOT_USERNAME": username, + "MONGO_INITDB_ROOT_PASSWORD": password, + }, + ExposedPorts: []string{"27017/tcp"}, + WaitingFor: wait.ForLog("Waiting for connections").WithStartupTimeout(60 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + s.T().Fatalf("failed to start mongo container: %v", err) + } + s.container = container + + host, err := container.Host(ctx) + s.Require().NoError(err) + port, err := container.MappedPort(ctx, "27017/tcp") + s.Require().NoError(err) + + uri := fmt.Sprintf("mongodb://%s:%s@%s:%s", url.QueryEscape(username), url.QueryEscape(password), host, port.Port()) + clientOpts := options.Client().ApplyURI(uri).SetAuth(options.Credential{Username: username, Password: password, AuthSource: authSource}) + + connectCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + client, err := mongo.Connect(connectCtx, clientOpts) + s.Require().NoError(err) + s.Require().NoError(client.Database(authSource).RunCommand(connectCtx, bson.D{{Key: "ping", Value: 1}}).Err()) + + s.client = client + s.dbName = firstNonEmptyEnv("DB_TEST_NAME", "dns_migrate_test") + _ = client.Database(s.dbName).Drop(connectCtx) + + s.storeCfg = &store.Config{ + DbURI: uri, + Name: s.dbName, + Username: username, + Password: password, + AuthSource: authSource, + } +} + +func (s *MigrateSuite) TearDownSuite() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if s.client != nil { + _ = s.client.Database(s.dbName).Drop(ctx) + } + if s.container != nil { + _ = s.container.Terminate(ctx) + } +} + +func (s *MigrateSuite) SetupTest() { + if s.client == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.client.Database(s.dbName).Collection(subscriptionsCollection).Drop(ctx) +} + +// seedDoc inserts a subscription-shaped document with the given _id subtype. +func (s *MigrateSuite) seedDoc(ctx context.Context, subtype byte, tier string) uuid.UUID { + s.T().Helper() + id := uuid.New() + doc := bson.D{ + {Key: "_id", Value: primitive.Binary{Subtype: subtype, Data: id[:]}}, + {Key: "account_id", Value: primitive.NewObjectID()}, + {Key: "active_until", Value: time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Millisecond)}, + {Key: "is_active", Value: true}, + {Key: "tier", Value: tier}, + {Key: "token_hash", Value: "hash-" + tier}, + {Key: "updated_at", Value: time.Now().UTC().Truncate(time.Millisecond)}, + {Key: "notified", Value: false}, + {Key: "limits", Value: bson.D{{Key: "max_queries_per_month", Value: int32(0)}}}, + } + _, err := s.client.Database(s.dbName).Collection(subscriptionsCollection).InsertOne(ctx, doc) + s.Require().NoError(err) + return id +} + +// subtypeOf reads a single document's _id subtype directly from the raw BSON. +func (s *MigrateSuite) subtypeOf(ctx context.Context, accountIDQuery primitive.ObjectID) byte { + s.T().Helper() + var raw bson.Raw + err := s.client. + Database(s.dbName). + Collection(subscriptionsCollection). + FindOne(ctx, bson.D{{Key: "account_id", Value: accountIDQuery}}). + Decode(&raw) + s.Require().NoError(err) + subtype, _, ok := raw.Lookup("_id").BinaryOK() + s.Require().True(ok, "_id must be binary") + return subtype +} + +func (s *MigrateSuite) TestMigrateRewritesLegacySubtypes() { + ctx := context.Background() + + id00a := s.seedDoc(ctx, 0x00, "Tier 2") + id00b := s.seedDoc(ctx, 0x00, "Tier 3") + id03 := s.seedDoc(ctx, 0x03, "Tier 2") + id04 := s.seedDoc(ctx, 0x04, "Tier 1") + + coll := s.client.Database(s.dbName).Collection(subscriptionsCollection) + before := s.snapshotByUUID(ctx, coll) + s.Require().Len(before, 4) + + stats, err := MigrateSubscriptionUUIDSubtype(ctx, s.client, s.dbName) + s.Require().NoError(err) + s.Require().Equal(0, stats.Failed) + s.Require().Equal(4, stats.Scanned) + s.Require().Equal(3, stats.Migrated) + s.Require().Equal(1, stats.Skipped) + + after := s.snapshotByUUID(ctx, coll) + s.Require().Len(after, 4) + for _, id := range []uuid.UUID{id00a, id00b, id03, id04} { + doc, ok := after[id] + s.Require().True(ok, "subscription %s missing after migration", id) + + subtype, data, bok := doc.Lookup("_id").BinaryOK() + s.Require().True(bok) + s.Require().Equal(byte(0x04), subtype, "id=%s", id) + s.Require().Equal(id[:], data, "id=%s", id) + + s.assertFieldsPreserved(before[id], doc) + } + + // Idempotency: second run is a no-op. + stats2, err := MigrateSubscriptionUUIDSubtype(ctx, s.client, s.dbName) + s.Require().NoError(err) + s.Require().Equal(Stats{Scanned: 4, Migrated: 0, Skipped: 4, Failed: 0}, stats2) +} + +func (s *MigrateSuite) TestMigrateResumesAfterPartialInsert() { + ctx := context.Background() + coll := s.client.Database(s.dbName).Collection(subscriptionsCollection) + + id := uuid.New() + accountID := primitive.NewObjectID() + base := bson.D{ + {Key: "account_id", Value: accountID}, + {Key: "active_until", Value: time.Now().Add(24 * time.Hour).UTC().Truncate(time.Millisecond)}, + {Key: "is_active", Value: true}, + {Key: "tier", Value: "Tier 2"}, + } + + legacy := append(bson.D{{Key: "_id", Value: primitive.Binary{Subtype: 0x00, Data: id[:]}}}, base...) + migrated := append(bson.D{{Key: "_id", Value: primitive.Binary{Subtype: 0x04, Data: id[:]}}}, base...) + _, err := coll.InsertOne(ctx, legacy) + s.Require().NoError(err) + _, err = coll.InsertOne(ctx, migrated) + s.Require().NoError(err) + + count, err := coll.CountDocuments(ctx, bson.D{}) + s.Require().NoError(err) + s.Require().Equal(int64(2), count) + + stats, err := MigrateSubscriptionUUIDSubtype(ctx, s.client, s.dbName) + s.Require().NoError(err) + s.Require().Equal(0, stats.Failed) + s.Require().Equal(1, stats.Migrated) + s.Require().Equal(1, stats.Skipped) + + count, err = coll.CountDocuments(ctx, bson.D{}) + s.Require().NoError(err) + s.Require().Equal(int64(1), count) + + subtype := s.subtypeOf(ctx, accountID) + s.Require().Equal(byte(0x04), subtype) +} + +// TestCodecWiredThroughNewMongoDB is an end-to-end regression guard for the +// clientOpts.SetRegistry(buildUUIDRegistry()) call in libs/store.connect(). +func (s *MigrateSuite) TestCodecWiredThroughNewMongoDB() { + ctx := context.Background() + + db, err := store.NewMongoDB(s.storeCfg) + s.Require().NoError(err) + defer func() { _ = db.Disconnect() }() + + type wireCheckDoc struct { + ID uuid.UUID `bson:"_id"` + } + id := uuid.New() + + coll := db.GetClient().Database(s.dbName).Collection("codec_wire_check") + _ = coll.Drop(ctx) + + _, err = coll.InsertOne(ctx, wireCheckDoc{ID: id}) + s.Require().NoError(err) + + var raw bson.Raw + s.Require().NoError(coll.FindOne(ctx, bson.D{}).Decode(&raw)) + + subtype, data, ok := raw.Lookup("_id").BinaryOK() + s.Require().True(ok, "_id must decode as primitive.Binary") + s.Require().Equal(id[:], data) + s.Require().Equal(byte(0x04), subtype, + "connect() must call clientOpts.SetRegistry(buildUUIDRegistry()); got subtype 0x%02x", subtype) +} + +func (s *MigrateSuite) snapshotByUUID(ctx context.Context, coll *mongo.Collection) map[uuid.UUID]bson.Raw { + s.T().Helper() + cursor, err := coll.Find(ctx, bson.D{}) + s.Require().NoError(err) + defer cursor.Close(ctx) + + out := make(map[uuid.UUID]bson.Raw) + for cursor.Next(ctx) { + var raw bson.Raw + s.Require().NoError(cursor.Decode(&raw)) + _, data, ok := raw.Lookup("_id").BinaryOK() + s.Require().True(ok) + var id uuid.UUID + copy(id[:], data) + cp := make(bson.Raw, len(raw)) + copy(cp, raw) + out[id] = cp + } + s.Require().NoError(cursor.Err()) + return out +} + +func (s *MigrateSuite) assertFieldsPreserved(before, after bson.Raw) { + s.T().Helper() + preservedKeys := []string{"account_id", "active_until", "is_active", "tier", "token_hash", "updated_at", "notified", "limits"} + for _, key := range preservedKeys { + beforeVal := before.Lookup(key) + afterVal := after.Lookup(key) + s.Require().Equal(beforeVal.Type, afterVal.Type, "type mismatch for field %q", key) + s.Require().Equal(beforeVal.Value, afterVal.Value, "value mismatch for field %q", key) + } +} + +func firstNonEmptyEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/api/main.go b/api/main.go index 686a1e7b..c6ca2c74 100644 --- a/api/main.go +++ b/api/main.go @@ -13,6 +13,7 @@ import ( "github.com/ivpn/dns/api/internal/email" "github.com/ivpn/dns/api/internal/idgen" "github.com/ivpn/dns/api/internal/middleware" + "github.com/ivpn/dns/api/internal/migrations" "github.com/ivpn/dns/api/internal/validator" "github.com/ivpn/dns/api/service" "github.com/ivpn/dns/libs/servicescatalogcache" @@ -92,6 +93,18 @@ func main() { log.Panic().Err(err).Msg("Failed to run migrations") } + if appConfig.Service.MigrateSubscriptionUUIDSubtype { + migCtx, migCancel := context.WithTimeout(context.Background(), 5*time.Minute) + stats, migErr := migrations.MigrateSubscriptionUUIDSubtype(migCtx, storeI.GetClient(), appConfig.DB.Name) + migCancel() + if migErr != nil { + log.Panic().Err(migErr).Msg("Subscription UUID subtype migration failed") + } + if stats.Migrated > 0 { + log.Info().Int("migrated", stats.Migrated).Int("skipped", stats.Skipped).Msg("Subscription UUID subtype migration applied") + } + } + // cache create, load data on startup cache, err := cache.NewCache(appConfig.Cache, cache.CacheTypeRedis) if err != nil { From d6a541bfeed1126bfd541df80d7a504471196671 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 27 Apr 2026 17:03:04 +0200 Subject: [PATCH 21/54] chore(blocklists): Update go.mod and go.sum after libs/ package changes Signed-off-by: Maciek --- blocklists/go.mod | 1 + blocklists/go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/blocklists/go.mod b/blocklists/go.mod index fff1957a..32aee625 100644 --- a/blocklists/go.mod +++ b/blocklists/go.mod @@ -17,6 +17,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/golang-migrate/migrate/v4 v4.18.2 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/blocklists/go.sum b/blocklists/go.sum index e0a71a64..43f3a15b 100644 --- a/blocklists/go.sum +++ b/blocklists/go.sum @@ -50,6 +50,8 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= From f9da4195d9add19c63581f474b867c4adcd73aa2 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 12:57:02 +0200 Subject: [PATCH 22/54] chore(app): Add VITE_RESYNC_URL values Signed-off-by: Maciek --- app/env/.env.production | 1 + app/env/.env.staging | 1 + app/env/.env.test | 1 + 3 files changed, 3 insertions(+) diff --git a/app/env/.env.production b/app/env/.env.production index 39fee2d1..74a43254 100644 --- a/app/env/.env.production +++ b/app/env/.env.production @@ -7,3 +7,4 @@ VITE_SENTRY_DSN= VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=production VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto +VITE_RESYNC_URL=https://www.ivpn.net/en/account/ diff --git a/app/env/.env.staging b/app/env/.env.staging index cbc39e4e..88dc27ac 100644 --- a/app/env/.env.staging +++ b/app/env/.env.staging @@ -7,3 +7,4 @@ VITE_SENTRY_DSN= VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=staging VITE_DNS_SERVER_LOCATIONS=vm1:Quebec +VITE_RESYNC_URL=https://staging.tamazaki.com/en/account/ diff --git a/app/env/.env.test b/app/env/.env.test index 4b1a196e..5cec5c3a 100644 --- a/app/env/.env.test +++ b/app/env/.env.test @@ -8,3 +8,4 @@ VITE_SENTRY_DSN= VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=test VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto +VITE_RESYNC_URL=https://www.ivpn.net/en/account/ From 9c11ced3ae67accca97839e0bcc81fa35853b838 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 14:15:34 +0200 Subject: [PATCH 23/54] chore(api): Simplify Update subscription endpoint Signed-off-by: Maciek --- api/api/requests/subscription.go | 5 ----- api/api/subscription.go | 19 ++---------------- api/docs/docs.go | 31 +---------------------------- api/docs/swagger.json | 31 +---------------------------- api/docs/swagger.yaml | 22 ++------------------ api/mocks/servicer.go | 22 ++++++++------------ api/mocks/subscription_servicer.go | 22 ++++++++------------ api/service/service.go | 2 +- api/service/subscription/service.go | 3 ++- 9 files changed, 25 insertions(+), 132 deletions(-) diff --git a/api/api/requests/subscription.go b/api/api/requests/subscription.go index 7e9edbed..22d4a7cf 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -6,8 +6,3 @@ type SubscriptionUpdates struct { Updates []model.SubscriptionUpdate `json:"updates" validate:"required,dive"` } -// SubscriptionUpdateReq represents a request to resync a subscription via PASession. -type SubscriptionUpdateReq struct { - ID string `json:"id" validate:"required,uuid4"` - SubID string `json:"subid" validate:"required,uuid4"` -} diff --git a/api/api/subscription.go b/api/api/subscription.go index 24403d57..e3de9af1 100644 --- a/api/api/subscription.go +++ b/api/api/subscription.go @@ -1,10 +1,7 @@ package api import ( - "strings" - "github.com/gofiber/fiber/v2" - "github.com/ivpn/dns/api/api/requests" "github.com/ivpn/dns/api/internal/auth" "github.com/ivpn/dns/api/model" ) @@ -35,12 +32,10 @@ func (s *APIServer) getSubscription() fiber.Handler { } // @Summary Update subscription via PASession -// @Description Resync subscription using a pre-auth session +// @Description Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). // @Tags Subscription -// @Accept json // @Produce json // @Security ApiKeyAuth -// @Param body body requests.SubscriptionUpdateReq true "Subscription update request" // @Success 200 {object} fiber.Map // @Failure 400 {object} ErrResponse // @Failure 401 {object} ErrResponse @@ -50,22 +45,12 @@ func (s *APIServer) updateSubscription() fiber.Handler { sessionID := c.Cookies(PASessionCookie) accountId := auth.GetAccountID(c) - req := new(requests.SubscriptionUpdateReq) - if err := c.BodyParser(req); err != nil { - return HandleError(c, err, ErrInvalidRequestBody.Error()) - } - - errMsgs := s.Validator.ValidateRequest(c, req, ErrInvalidRequestBody.Error()) - if len(errMsgs) > 0 { - return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) - } - sub, err := s.Service.GetSubscription(c.Context(), accountId) if err != nil { return HandleError(c, err, ErrFailedToGetSubscription.Error()) } - if err := s.Service.UpdateSubscriptionFromPASession(c.Context(), sub, req.SubID, sessionID); err != nil { + if err := s.Service.UpdateSubscriptionFromPASession(c.Context(), sub, sessionID); err != nil { return HandleError(c, err, "failed to update subscription") } diff --git a/api/docs/docs.go b/api/docs/docs.go index 87bd09b7..73a3a15a 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1856,10 +1856,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Resync subscription using a pre-auth session", - "consumes": [ - "application/json" - ], + "description": "Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation).", "produces": [ "application/json" ], @@ -1867,17 +1864,6 @@ const docTemplate = `{ "Subscription" ], "summary": "Update subscription via PASession", - "parameters": [ - { - "description": "Subscription update request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionUpdateReq" - } - } - ], "responses": { "200": { "description": "OK", @@ -3608,21 +3594,6 @@ const docTemplate = `{ } } }, - "requests.SubscriptionUpdateReq": { - "type": "object", - "required": [ - "id", - "subid" - ], - "properties": { - "id": { - "type": "string" - }, - "subid": { - "type": "string" - } - } - }, "requests.TotpReq": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index c4114896..a8832127 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1848,10 +1848,7 @@ "ApiKeyAuth": [] } ], - "description": "Resync subscription using a pre-auth session", - "consumes": [ - "application/json" - ], + "description": "Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation).", "produces": [ "application/json" ], @@ -1859,17 +1856,6 @@ "Subscription" ], "summary": "Update subscription via PASession", - "parameters": [ - { - "description": "Subscription update request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionUpdateReq" - } - } - ], "responses": { "200": { "description": "OK", @@ -3600,21 +3586,6 @@ } } }, - "requests.SubscriptionUpdateReq": { - "type": "object", - "required": [ - "id", - "subid" - ], - "properties": { - "id": { - "type": "string" - }, - "subid": { - "type": "string" - } - } - }, "requests.TotpReq": { "type": "object", "required": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index b2e08233..7cb94149 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -875,16 +875,6 @@ definitions: required: - sessionid type: object - requests.SubscriptionUpdateReq: - properties: - id: - type: string - subid: - type: string - required: - - id - - subid - type: object requests.TotpReq: properties: otp: @@ -2201,16 +2191,8 @@ paths: - Subscription /api/v1/sub/update: put: - consumes: - - application/json - description: Resync subscription using a pre-auth session - parameters: - - description: Subscription update request - in: body - name: body - required: true - schema: - $ref: '#/definitions/requests.SubscriptionUpdateReq' + description: Resync subscription using a pre-auth session. Requires pa_session + cookie (set by prior PASession rotation). produces: - application/json responses: diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 646af8dc..9af5004e 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -3887,16 +3887,16 @@ func (_c *Servicer_UpdateSubscription_Call) RunAndReturn(run func(ctx context.Co } // UpdateSubscriptionFromPASession provides a mock function for the type Servicer -func (_mock *Servicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { - ret := _mock.Called(ctx, sub, subID, sessionID) +func (_mock *Servicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, sessionID string) error { + ret := _mock.Called(ctx, sub, sessionID) if len(ret) == 0 { panic("no return value specified for UpdateSubscriptionFromPASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string, string) error); ok { - r0 = returnFunc(ctx, sub, subID, sessionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string) error); ok { + r0 = returnFunc(ctx, sub, sessionID) } else { r0 = ret.Error(0) } @@ -3911,13 +3911,12 @@ type Servicer_UpdateSubscriptionFromPASession_Call struct { // UpdateSubscriptionFromPASession is a helper method to define mock.On call // - ctx context.Context // - sub *model.Subscription -// - subID string // - sessionID string -func (_e *Servicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, subID interface{}, sessionID interface{}) *Servicer_UpdateSubscriptionFromPASession_Call { - return &Servicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, subID, sessionID)} +func (_e *Servicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, sessionID interface{}) *Servicer_UpdateSubscriptionFromPASession_Call { + return &Servicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, sessionID)} } -func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string)) *Servicer_UpdateSubscriptionFromPASession_Call { +func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, sessionID string)) *Servicer_UpdateSubscriptionFromPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -3931,15 +3930,10 @@ func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx contex if args[2] != nil { arg2 = args[2].(string) } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) - } run( arg0, arg1, arg2, - arg3, ) }) return _c @@ -3950,7 +3944,7 @@ func (_c *Servicer_UpdateSubscriptionFromPASession_Call) Return(err error) *Serv return _c } -func (_c *Servicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error) *Servicer_UpdateSubscriptionFromPASession_Call { +func (_c *Servicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, sessionID string) error) *Servicer_UpdateSubscriptionFromPASession_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/subscription_servicer.go b/api/mocks/subscription_servicer.go index 6ec88854..67403c41 100644 --- a/api/mocks/subscription_servicer.go +++ b/api/mocks/subscription_servicer.go @@ -367,16 +367,16 @@ func (_c *SubscriptionServicer_UpdateSubscription_Call) RunAndReturn(run func(ct } // UpdateSubscriptionFromPASession provides a mock function for the type SubscriptionServicer -func (_mock *SubscriptionServicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { - ret := _mock.Called(ctx, sub, subID, sessionID) +func (_mock *SubscriptionServicer) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, sessionID string) error { + ret := _mock.Called(ctx, sub, sessionID) if len(ret) == 0 { panic("no return value specified for UpdateSubscriptionFromPASession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string, string) error); ok { - r0 = returnFunc(ctx, sub, subID, sessionID) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Subscription, string) error); ok { + r0 = returnFunc(ctx, sub, sessionID) } else { r0 = ret.Error(0) } @@ -391,13 +391,12 @@ type SubscriptionServicer_UpdateSubscriptionFromPASession_Call struct { // UpdateSubscriptionFromPASession is a helper method to define mock.On call // - ctx context.Context // - sub *model.Subscription -// - subID string // - sessionID string -func (_e *SubscriptionServicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, subID interface{}, sessionID interface{}) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { - return &SubscriptionServicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, subID, sessionID)} +func (_e *SubscriptionServicer_Expecter) UpdateSubscriptionFromPASession(ctx interface{}, sub interface{}, sessionID interface{}) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { + return &SubscriptionServicer_UpdateSubscriptionFromPASession_Call{Call: _e.mock.On("UpdateSubscriptionFromPASession", ctx, sub, sessionID)} } -func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string)) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { +func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Run(run func(ctx context.Context, sub *model.Subscription, sessionID string)) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -411,15 +410,10 @@ func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Run(run fun if args[2] != nil { arg2 = args[2].(string) } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) - } run( arg0, arg1, arg2, - arg3, ) }) return _c @@ -430,7 +424,7 @@ func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) Return(err return _c } -func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { +func (_c *SubscriptionServicer_UpdateSubscriptionFromPASession_Call) RunAndReturn(run func(ctx context.Context, sub *model.Subscription, sessionID string) error) *SubscriptionServicer_UpdateSubscriptionFromPASession_Call { _c.Call.Return(run) return _c } diff --git a/api/service/service.go b/api/service/service.go index 2575340f..1ac51738 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -176,7 +176,7 @@ type SubscriptionServicer interface { AddPASession(ctx context.Context, session *model.PASession) error RotatePASessionID(ctx context.Context, oldID string) (string, error) ValidateAndGetPreauth(ctx context.Context, sessionID string) (*model.Preauth, error) - UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error + UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, sessionID string) error } // DeleteAccount deletes account with all connected data including sessions diff --git a/api/service/subscription/service.go b/api/service/subscription/service.go index 8d157df0..d03d3068 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -157,7 +157,7 @@ func (s *SubscriptionService) ValidateAndGetPreauth(ctx context.Context, session } // UpdateSubscriptionFromPASession validates the PASession, updates subscription fields from preauth, and persists. -func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, subID string, sessionID string) error { +func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, sessionID string) error { preauth, err := s.ValidateAndGetPreauth(ctx, sessionID) if err != nil { return err @@ -174,6 +174,7 @@ func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Contex return err } + subID := sub.ID.String() if err := s.Http.SignupWebhook(subID); err != nil { log.Error().Err(err).Str("sub_id", subID).Msg("Failed to send signup webhook after subscription update") return err From 3d1c2a71908d6799db2478bbe92718eb41beccce Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 14:16:09 +0200 Subject: [PATCH 24/54] chore(app): Adjust AccountSubscription component to API changes Signed-off-by: Maciek --- app/src/api/client/api.ts | 50 +++++----------------- app/src/components/AccountSubscription.tsx | 13 ++---- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index 892c0e81..933179a7 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -1610,25 +1610,6 @@ export interface RequestsRotatePASessionReq { */ 'sessionid': string; } -/** - * - * @export - * @interface RequestsSubscriptionUpdateReq - */ -export interface RequestsSubscriptionUpdateReq { - /** - * - * @type {string} - * @memberof RequestsSubscriptionUpdateReq - */ - 'id': string; - /** - * - * @type {string} - * @memberof RequestsSubscriptionUpdateReq - */ - 'subid': string; -} /** * * @export @@ -5545,15 +5526,12 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Resync subscription using a pre-auth session + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). * @summary Update subscription via PASession - * @param {RequestsSubscriptionUpdateReq} body Subscription update request * @param {*} [options] Override http request option. * @throws {RequiredError} */ - apiV1SubUpdatePut: async (body: RequestsSubscriptionUpdateReq, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'body' is not null or undefined - assertParamExists('apiV1SubUpdatePut', 'body', body) + apiV1SubUpdatePut: async (options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v1/sub/update`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -5568,12 +5546,9 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config - localVarHeaderParameter['Content-Type'] = 'application/json'; - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -5603,14 +5578,13 @@ export const SubscriptionApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Resync subscription using a pre-auth session + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). * @summary Update subscription via PASession - * @param {RequestsSubscriptionUpdateReq} body Subscription update request * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1SubUpdatePut(body, options); + async apiV1SubUpdatePut(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1SubUpdatePut(options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubUpdatePut']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -5635,14 +5609,13 @@ export const SubscriptionApiFactory = function (configuration?: Configuration, b return localVarFp.apiV1SubGet(options).then((request) => request(axios, basePath)); }, /** - * Resync subscription using a pre-auth session + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). * @summary Update subscription via PASession - * @param {RequestsSubscriptionUpdateReq} body Subscription update request * @param {*} [options] Override http request option. * @throws {RequiredError} */ - apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { - return localVarFp.apiV1SubUpdatePut(body, options).then((request) => request(axios, basePath)); + apiV1SubUpdatePut(options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { + return localVarFp.apiV1SubUpdatePut(options).then((request) => request(axios, basePath)); }, }; }; @@ -5666,15 +5639,14 @@ export class SubscriptionApi extends BaseAPI { } /** - * Resync subscription using a pre-auth session + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). * @summary Update subscription via PASession - * @param {RequestsSubscriptionUpdateReq} body Subscription update request * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SubscriptionApi */ - public apiV1SubUpdatePut(body: RequestsSubscriptionUpdateReq, options?: RawAxiosRequestConfig) { - return SubscriptionApiFp(this.configuration).apiV1SubUpdatePut(body, options).then((request) => request(this.axios, this.basePath)); + public apiV1SubUpdatePut(options?: RawAxiosRequestConfig) { + return SubscriptionApiFp(this.configuration).apiV1SubUpdatePut(options).then((request) => request(this.axios, this.basePath)); } } diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx index 984bc6bb..1ff271c6 100644 --- a/app/src/components/AccountSubscription.tsx +++ b/app/src/components/AccountSubscription.tsx @@ -15,7 +15,6 @@ export default function AccountSubscription() { const [searchParams] = useSearchParams(); const sessionid = searchParams.get("sessionid") || ""; - const subid = searchParams.get("subid") || ""; const fetchSubscription = async () => { try { @@ -27,17 +26,13 @@ export default function AccountSubscription() { }; const resync = async () => { - if (!sessionid || !subid) return; + if (!sessionid) return; setSyncing(true); setError(""); try { await api.Client.paSessionApi.apiV1PasessionRotatePut({ sessionid }); - const currentSub = await api.Client.subscriptionApi.apiV1SubGet(); - await api.Client.subscriptionApi.apiV1SubUpdatePut({ - id: currentSub.data.id || "", - subid, - }); + await api.Client.subscriptionApi.apiV1SubUpdatePut(); await fetchSubscription(); } catch { setError("Failed to sync subscription. Please try again."); @@ -49,8 +44,8 @@ export default function AccountSubscription() { useEffect(() => { fetchSubscription(); }, []); useEffect(() => { - if (sessionid && subid) { resync(); } - }, [sessionid, subid]); // eslint-disable-line react-hooks/exhaustive-deps + if (sessionid) { resync(); } + }, [sessionid]); // eslint-disable-line react-hooks/exhaustive-deps if (!sub) return null; From 340a1a53e296577125084ed9fa11984204f7cfe6 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 14:16:48 +0200 Subject: [PATCH 25/54] docs(tests): Update Python client Signed-off-by: Maciek --- tests/moddns_client/README.md | 1 + .../docs/ServicescatalogService.md | 1 + tests/moddns_client/docs/SubscriptionApi.md | 66 +++++ .../moddns/api/subscription_api.py | 252 ++++++++++++++++++ .../moddns/models/servicescatalog_service.py | 4 +- .../test/test_servicescatalog_catalog.py | 3 + .../test/test_servicescatalog_service.py | 3 + .../test/test_subscription_api.py | 7 + 8 files changed, 336 insertions(+), 1 deletion(-) diff --git a/tests/moddns_client/README.md b/tests/moddns_client/README.md index ef278838..e37ded62 100644 --- a/tests/moddns_client/README.md +++ b/tests/moddns_client/README.md @@ -129,6 +129,7 @@ Class | Method | HTTP request | Description *SessionsApi* | [**api_v1_sessions_delete**](docs/SessionsApi.md#api_v1_sessions_delete) | **DELETE** /api/v1/sessions | Delete all other sessions *StatisticsApi* | [**api_v1_profiles_id_statistics_get**](docs/StatisticsApi.md#api_v1_profiles_id_statistics_get) | **GET** /api/v1/profiles/{id}/statistics | Get statistics data for a profile *SubscriptionApi* | [**api_v1_sub_get**](docs/SubscriptionApi.md#api_v1_sub_get) | **GET** /api/v1/sub | Get subscription data +*SubscriptionApi* | [**api_v1_sub_update_put**](docs/SubscriptionApi.md#api_v1_sub_update_put) | **PUT** /api/v1/sub/update | Update subscription via PASession *VerificationApi* | [**api_v1_verify_email_otp_confirm_post**](docs/VerificationApi.md#api_v1_verify_email_otp_confirm_post) | **POST** /api/v1/verify/email/otp/confirm | Confirm email verification OTP *VerificationApi* | [**api_v1_verify_email_otp_request_post**](docs/VerificationApi.md#api_v1_verify_email_otp_request_post) | **POST** /api/v1/verify/email/otp/request | Request email verification OTP *VerificationApi* | [**api_v1_verify_reset_password_post**](docs/VerificationApi.md#api_v1_verify_reset_password_post) | **POST** /api/v1/verify/reset-password | Confirm password reset diff --git a/tests/moddns_client/docs/ServicescatalogService.md b/tests/moddns_client/docs/ServicescatalogService.md index 4eaa57b1..52e6d1dd 100644 --- a/tests/moddns_client/docs/ServicescatalogService.md +++ b/tests/moddns_client/docs/ServicescatalogService.md @@ -6,6 +6,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **asns** | **List[int]** | | [optional] +**domains** | **List[str]** | | [optional] **id** | **str** | | [optional] **logo_key** | **str** | | [optional] **name** | **str** | | [optional] diff --git a/tests/moddns_client/docs/SubscriptionApi.md b/tests/moddns_client/docs/SubscriptionApi.md index d96561e4..73390e99 100644 --- a/tests/moddns_client/docs/SubscriptionApi.md +++ b/tests/moddns_client/docs/SubscriptionApi.md @@ -5,6 +5,7 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- [**api_v1_sub_get**](SubscriptionApi.md#api_v1_sub_get) | **GET** /api/v1/sub | Get subscription data +[**api_v1_sub_update_put**](SubscriptionApi.md#api_v1_sub_update_put) | **PUT** /api/v1/sub/update | Update subscription via PASession # **api_v1_sub_get** @@ -74,3 +75,68 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **api_v1_sub_update_put** +> Dict[str, object] api_v1_sub_update_put() + +Update subscription via PASession + +Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + +### Example + + +```python +import moddns +from moddns.rest import ApiException +from pprint import pprint + +# Defining the host is optional and defaults to http://localhost +# See configuration.py for a list of all supported configuration parameters. +configuration = moddns.Configuration( + host = "http://localhost" +) + + +# Enter a context with an instance of the API client +with moddns.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = moddns.SubscriptionApi(api_client) + + try: + # Update subscription via PASession + api_response = api_instance.api_v1_sub_update_put() + print("The response of SubscriptionApi->api_v1_sub_update_put:\n") + pprint(api_response) + except Exception as e: + print("Exception when calling SubscriptionApi->api_v1_sub_update_put: %s\n" % e) +``` + + + +### Parameters + +This endpoint does not need any parameter. + +### Return type + +**Dict[str, object]** + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | OK | - | +**400** | Bad Request | - | +**401** | Unauthorized | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/tests/moddns_client/moddns/api/subscription_api.py b/tests/moddns_client/moddns/api/subscription_api.py index 328ffd8d..3c9b8d6f 100644 --- a/tests/moddns_client/moddns/api/subscription_api.py +++ b/tests/moddns_client/moddns/api/subscription_api.py @@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated +from typing import Any, Dict from moddns.models.model_subscription import ModelSubscription from moddns.api_client import ApiClient, RequestSerialized @@ -288,3 +289,254 @@ def _api_v1_sub_get_serialize( ) + + + @validate_call + def api_v1_sub_update_put( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> Dict[str, object]: + """Update subscription via PASession + + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_sub_update_put_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def api_v1_sub_update_put_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[Dict[str, object]]: + """Update subscription via PASession + + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_sub_update_put_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def api_v1_sub_update_put_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Update subscription via PASession + + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_sub_update_put_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "Dict[str, object]", + '400': "ApiErrResponse", + '401': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _api_v1_sub_update_put_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='PUT', + resource_path='/api/v1/sub/update', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/tests/moddns_client/moddns/models/servicescatalog_service.py b/tests/moddns_client/moddns/models/servicescatalog_service.py index 19b23094..0c86c17c 100644 --- a/tests/moddns_client/moddns/models/servicescatalog_service.py +++ b/tests/moddns_client/moddns/models/servicescatalog_service.py @@ -27,10 +27,11 @@ class ServicescatalogService(BaseModel): ServicescatalogService """ # noqa: E501 asns: Optional[List[StrictInt]] = None + domains: Optional[List[StrictStr]] = None id: Optional[StrictStr] = None logo_key: Optional[StrictStr] = None name: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["asns", "id", "logo_key", "name"] + __properties: ClassVar[List[str]] = ["asns", "domains", "id", "logo_key", "name"] model_config = ConfigDict( populate_by_name=True, @@ -84,6 +85,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "asns": obj.get("asns"), + "domains": obj.get("domains"), "id": obj.get("id"), "logo_key": obj.get("logo_key"), "name": obj.get("name") diff --git a/tests/moddns_client/test/test_servicescatalog_catalog.py b/tests/moddns_client/test/test_servicescatalog_catalog.py index 2bf71616..5c78e085 100644 --- a/tests/moddns_client/test/test_servicescatalog_catalog.py +++ b/tests/moddns_client/test/test_servicescatalog_catalog.py @@ -40,6 +40,9 @@ def make_instance(self, include_optional) -> ServicescatalogCatalog: asns = [ 56 ], + domains = [ + '' + ], id = '', logo_key = '', name = '', ) diff --git a/tests/moddns_client/test/test_servicescatalog_service.py b/tests/moddns_client/test/test_servicescatalog_service.py index 8074f04a..b7e13b00 100644 --- a/tests/moddns_client/test/test_servicescatalog_service.py +++ b/tests/moddns_client/test/test_servicescatalog_service.py @@ -38,6 +38,9 @@ def make_instance(self, include_optional) -> ServicescatalogService: asns = [ 56 ], + domains = [ + '' + ], id = '', logo_key = '', name = '' diff --git a/tests/moddns_client/test/test_subscription_api.py b/tests/moddns_client/test/test_subscription_api.py index ccc01306..588bbb72 100644 --- a/tests/moddns_client/test/test_subscription_api.py +++ b/tests/moddns_client/test/test_subscription_api.py @@ -33,6 +33,13 @@ def test_api_v1_sub_get(self) -> None: """ pass + def test_api_v1_sub_update_put(self) -> None: + """Test case for api_v1_sub_update_put + + Update subscription via PASession + """ + pass + if __name__ == '__main__': unittest.main() From ad9a96b41fdfa1bdcb88164e0f2cd3b7c44b0950 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 15:26:01 +0200 Subject: [PATCH 26/54] chore(app): Add success message after sync Signed-off-by: Maciek --- app/src/components/AccountSubscription.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx index 1ff271c6..2adf0d92 100644 --- a/app/src/components/AccountSubscription.tsx +++ b/app/src/components/AccountSubscription.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Info } from "lucide-react"; +import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; import StatusBadge from "@/components/general/StatusBadge"; import api from "@/api/api"; @@ -34,6 +35,7 @@ export default function AccountSubscription() { await api.Client.paSessionApi.apiV1PasessionRotatePut({ sessionid }); await api.Client.subscriptionApi.apiV1SubUpdatePut(); await fetchSubscription(); + toast.success("Your account has been successfully synced."); } catch { setError("Failed to sync subscription. Please try again."); } finally { From b0fa7d71467d48e295c34e74bd2073c0fdff3713 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 29 Apr 2026 17:23:54 +0200 Subject: [PATCH 27/54] fix(api): Send proper subid value in passkey registration finish Signed-off-by: Maciek --- api/api/auth.go | 2 +- api/api/requests/subscription.go | 1 - api/api/webauthn.go | 2 +- api/api/webauthn_test.go | 2 +- api/db/mongodb/session.go | 3 +- api/db/repository/session.go | 2 +- api/mocks/db.go | 22 ++++++++----- api/mocks/passkey_servicer.go | 34 +++++++++++-------- api/mocks/servicer.go | 56 +++++++++++++++++++------------- api/mocks/session_repository.go | 22 ++++++++----- api/mocks/session_servicer.go | 22 ++++++++----- api/model/session.go | 1 + api/service/service.go | 4 +-- api/service/session.go | 4 +-- api/service/webauthn.go | 26 +++++++-------- 15 files changed, 119 insertions(+), 84 deletions(-) diff --git a/api/api/auth.go b/api/api/auth.go index 23304201..c619b20a 100644 --- a/api/api/auth.go +++ b/api/api/auth.go @@ -96,7 +96,7 @@ func (s *APIServer) login() fiber.Handler { }) } - err = s.Service.SaveSession(c.Context(), sessionData, token, acc.ID.Hex(), "") + err = s.Service.SaveSession(c.Context(), sessionData, token, acc.ID.Hex(), "", "") if err != nil { return c.Status(400).JSON(fiber.Map{ "error": ErrSaveSession, diff --git a/api/api/requests/subscription.go b/api/api/requests/subscription.go index 22d4a7cf..71832f83 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -5,4 +5,3 @@ import "github.com/ivpn/dns/api/model" type SubscriptionUpdates struct { Updates []model.SubscriptionUpdate `json:"updates" validate:"required,dive"` } - diff --git a/api/api/webauthn.go b/api/api/webauthn.go index 7b37f602..c8320330 100644 --- a/api/api/webauthn.go +++ b/api/api/webauthn.go @@ -64,7 +64,7 @@ func (s *APIServer) beginRegistration() fiber.Handler { return HandleError(c, err, ErrFailedToRegisterAccount.Error()) } - options, token, err := s.Service.BeginRegistration(c.Context(), acc) + options, token, err := s.Service.BeginRegistration(c.Context(), acc, req.SubID) if err != nil { return HandleError(c, err, "Failed to begin registration") } diff --git a/api/api/webauthn_test.go b/api/api/webauthn_test.go index 8ff613b1..30100e47 100644 --- a/api/api/webauthn_test.go +++ b/api/api/webauthn_test.go @@ -122,7 +122,7 @@ func (suite *WebAuthnAPITestSuite) TestBeginReauthSuccess() { suite.mockServ.On("GetAccount", mock.Anything, accountID.Hex()).Return(acc, nil).Once() suite.mockDB.On("GetCredentials", mock.Anything, accountID).Return([]model.Credential{credential}, nil).Once() - suite.mockDB.On("SaveSession", mock.Anything, mock.Anything, mock.AnythingOfType("string"), accountID.Hex(), "email_change").Return(nil).Once() + suite.mockDB.On("SaveSession", mock.Anything, mock.Anything, mock.AnythingOfType("string"), accountID.Hex(), "email_change", "").Return(nil).Once() suite.mockCache.On("Incr", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("time.Duration")).Return(int64(1), nil).Once() suite.mockCache.On("Get", mock.Anything, mock.AnythingOfType("string")).Return("1", nil).Once() diff --git a/api/db/mongodb/session.go b/api/db/mongodb/session.go index 86c12b2e..da9f1e6c 100644 --- a/api/db/mongodb/session.go +++ b/api/db/mongodb/session.go @@ -119,7 +119,7 @@ func (r *SessionRepository) GetSession(ctx context.Context, token string) (model } // SaveSession saves a webauthn session -func (r *SessionRepository) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, accID string, purpose string) error { +func (r *SessionRepository) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, accID string, purpose string, subID string) error { // Serialize the webauthn session data to JSON dataBytes, err := json.Marshal(sessionData) if err != nil { @@ -132,6 +132,7 @@ func (r *SessionRepository) SaveSession(ctx context.Context, sessionData webauth AccountID: accID, Data: dataBytes, Purpose: purpose, + SubID: subID, LastModified: time.Now(), } diff --git a/api/db/repository/session.go b/api/db/repository/session.go index 630abd9d..bb99dcc9 100644 --- a/api/db/repository/session.go +++ b/api/db/repository/session.go @@ -10,7 +10,7 @@ import ( // SessionRepository represents a Session repository type SessionRepository interface { GetSession(ctx context.Context, token string) (model.Session, bool, error) - SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string) error + SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string) error DeleteSession(ctx context.Context, token string) error DeleteSessionsByAccountID(ctx context.Context, accID string) error DeleteSessionsByAccountIDExceptCurrent(ctx context.Context, accID, currentToken string) error diff --git a/api/mocks/db.go b/api/mocks/db.go index bd6d075b..35d796ed 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -2675,16 +2675,16 @@ func (_c *Db_SaveCredential_Call) RunAndReturn(run func(ctx context.Context, cre } // SaveSession provides a mock function for the type Db -func (_mock *Db) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string) error { - ret := _mock.Called(ctx, sessionData, token, userID, purpose) +func (_mock *Db) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string) error { + ret := _mock.Called(ctx, sessionData, token, userID, purpose, subID) if len(ret) == 0 { panic("no return value specified for SaveSession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string) error); ok { - r0 = returnFunc(ctx, sessionData, token, userID, purpose) + if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string, string) error); ok { + r0 = returnFunc(ctx, sessionData, token, userID, purpose, subID) } else { r0 = ret.Error(0) } @@ -2702,11 +2702,12 @@ type Db_SaveSession_Call struct { // - token string // - userID string // - purpose string -func (_e *Db_Expecter) SaveSession(ctx interface{}, sessionData interface{}, token interface{}, userID interface{}, purpose interface{}) *Db_SaveSession_Call { - return &Db_SaveSession_Call{Call: _e.mock.On("SaveSession", ctx, sessionData, token, userID, purpose)} +// - subID string +func (_e *Db_Expecter) SaveSession(ctx interface{}, sessionData interface{}, token interface{}, userID interface{}, purpose interface{}, subID interface{}) *Db_SaveSession_Call { + return &Db_SaveSession_Call{Call: _e.mock.On("SaveSession", ctx, sessionData, token, userID, purpose, subID)} } -func (_c *Db_SaveSession_Call) Run(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string)) *Db_SaveSession_Call { +func (_c *Db_SaveSession_Call) Run(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string)) *Db_SaveSession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -2728,12 +2729,17 @@ func (_c *Db_SaveSession_Call) Run(run func(ctx context.Context, sessionData web if args[4] != nil { arg4 = args[4].(string) } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -2744,7 +2750,7 @@ func (_c *Db_SaveSession_Call) Return(err error) *Db_SaveSession_Call { return _c } -func (_c *Db_SaveSession_Call) RunAndReturn(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string) error) *Db_SaveSession_Call { +func (_c *Db_SaveSession_Call) RunAndReturn(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string) error) *Db_SaveSession_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/passkey_servicer.go b/api/mocks/passkey_servicer.go index 9d7b20b7..41cf1916 100644 --- a/api/mocks/passkey_servicer.go +++ b/api/mocks/passkey_servicer.go @@ -195,8 +195,8 @@ func (_c *PasskeyServicer_BeginReauth_Call) RunAndReturn(run func(ctx context.Co } // BeginRegistration provides a mock function for the type PasskeyServicer -func (_mock *PasskeyServicer) BeginRegistration(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error) { - ret := _mock.Called(ctx, account) +func (_mock *PasskeyServicer) BeginRegistration(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error) { + ret := _mock.Called(ctx, account, subID) if len(ret) == 0 { panic("no return value specified for BeginRegistration") @@ -205,23 +205,23 @@ func (_mock *PasskeyServicer) BeginRegistration(ctx context.Context, account *mo var r0 *protocol.CredentialCreation var r1 string var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account) (*protocol.CredentialCreation, string, error)); ok { - return returnFunc(ctx, account) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) (*protocol.CredentialCreation, string, error)); ok { + return returnFunc(ctx, account, subID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account) *protocol.CredentialCreation); ok { - r0 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) *protocol.CredentialCreation); ok { + r0 = returnFunc(ctx, account, subID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*protocol.CredentialCreation) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account) string); ok { - r1 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account, string) string); ok { + r1 = returnFunc(ctx, account, subID) } else { r1 = ret.Get(1).(string) } - if returnFunc, ok := ret.Get(2).(func(context.Context, *model.Account) error); ok { - r2 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(2).(func(context.Context, *model.Account, string) error); ok { + r2 = returnFunc(ctx, account, subID) } else { r2 = ret.Error(2) } @@ -236,11 +236,12 @@ type PasskeyServicer_BeginRegistration_Call struct { // BeginRegistration is a helper method to define mock.On call // - ctx context.Context // - account *model.Account -func (_e *PasskeyServicer_Expecter) BeginRegistration(ctx interface{}, account interface{}) *PasskeyServicer_BeginRegistration_Call { - return &PasskeyServicer_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, account)} +// - subID string +func (_e *PasskeyServicer_Expecter) BeginRegistration(ctx interface{}, account interface{}, subID interface{}) *PasskeyServicer_BeginRegistration_Call { + return &PasskeyServicer_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, account, subID)} } -func (_c *PasskeyServicer_BeginRegistration_Call) Run(run func(ctx context.Context, account *model.Account)) *PasskeyServicer_BeginRegistration_Call { +func (_c *PasskeyServicer_BeginRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subID string)) *PasskeyServicer_BeginRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -250,9 +251,14 @@ func (_c *PasskeyServicer_BeginRegistration_Call) Run(run func(ctx context.Conte if args[1] != nil { arg1 = args[1].(*model.Account) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c @@ -263,7 +269,7 @@ func (_c *PasskeyServicer_BeginRegistration_Call) Return(credentialCreation *pro return _c } -func (_c *PasskeyServicer_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error)) *PasskeyServicer_BeginRegistration_Call { +func (_c *PasskeyServicer_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error)) *PasskeyServicer_BeginRegistration_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 9af5004e..566a756d 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -257,8 +257,8 @@ func (_c *Servicer_BeginReauth_Call) RunAndReturn(run func(ctx context.Context, } // BeginRegistration provides a mock function for the type Servicer -func (_mock *Servicer) BeginRegistration(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error) { - ret := _mock.Called(ctx, account) +func (_mock *Servicer) BeginRegistration(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error) { + ret := _mock.Called(ctx, account, subID) if len(ret) == 0 { panic("no return value specified for BeginRegistration") @@ -267,23 +267,23 @@ func (_mock *Servicer) BeginRegistration(ctx context.Context, account *model.Acc var r0 *protocol.CredentialCreation var r1 string var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account) (*protocol.CredentialCreation, string, error)); ok { - return returnFunc(ctx, account) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) (*protocol.CredentialCreation, string, error)); ok { + return returnFunc(ctx, account, subID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account) *protocol.CredentialCreation); ok { - r0 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) *protocol.CredentialCreation); ok { + r0 = returnFunc(ctx, account, subID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*protocol.CredentialCreation) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account) string); ok { - r1 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account, string) string); ok { + r1 = returnFunc(ctx, account, subID) } else { r1 = ret.Get(1).(string) } - if returnFunc, ok := ret.Get(2).(func(context.Context, *model.Account) error); ok { - r2 = returnFunc(ctx, account) + if returnFunc, ok := ret.Get(2).(func(context.Context, *model.Account, string) error); ok { + r2 = returnFunc(ctx, account, subID) } else { r2 = ret.Error(2) } @@ -298,11 +298,12 @@ type Servicer_BeginRegistration_Call struct { // BeginRegistration is a helper method to define mock.On call // - ctx context.Context // - account *model.Account -func (_e *Servicer_Expecter) BeginRegistration(ctx interface{}, account interface{}) *Servicer_BeginRegistration_Call { - return &Servicer_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, account)} +// - subID string +func (_e *Servicer_Expecter) BeginRegistration(ctx interface{}, account interface{}, subID interface{}) *Servicer_BeginRegistration_Call { + return &Servicer_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, account, subID)} } -func (_c *Servicer_BeginRegistration_Call) Run(run func(ctx context.Context, account *model.Account)) *Servicer_BeginRegistration_Call { +func (_c *Servicer_BeginRegistration_Call) Run(run func(ctx context.Context, account *model.Account, subID string)) *Servicer_BeginRegistration_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -312,9 +313,14 @@ func (_c *Servicer_BeginRegistration_Call) Run(run func(ctx context.Context, acc if args[1] != nil { arg1 = args[1].(*model.Account) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c @@ -325,7 +331,7 @@ func (_c *Servicer_BeginRegistration_Call) Return(credentialCreation *protocol.C return _c } -func (_c *Servicer_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error)) *Servicer_BeginRegistration_Call { +func (_c *Servicer_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error)) *Servicer_BeginRegistration_Call { _c.Call.Return(run) return _c } @@ -3253,16 +3259,16 @@ func (_c *Servicer_SaveCredential_Call) RunAndReturn(run func(context1 context.C } // SaveSession provides a mock function for the type Servicer -func (_mock *Servicer) SaveSession(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string) error { - ret := _mock.Called(context1, sessionData, s, s1, s2) +func (_mock *Servicer) SaveSession(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string) error { + ret := _mock.Called(context1, sessionData, s, s1, s2, s3) if len(ret) == 0 { panic("no return value specified for SaveSession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string) error); ok { - r0 = returnFunc(context1, sessionData, s, s1, s2) + if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string, string) error); ok { + r0 = returnFunc(context1, sessionData, s, s1, s2, s3) } else { r0 = ret.Error(0) } @@ -3280,11 +3286,12 @@ type Servicer_SaveSession_Call struct { // - s string // - s1 string // - s2 string -func (_e *Servicer_Expecter) SaveSession(context1 interface{}, sessionData interface{}, s interface{}, s1 interface{}, s2 interface{}) *Servicer_SaveSession_Call { - return &Servicer_SaveSession_Call{Call: _e.mock.On("SaveSession", context1, sessionData, s, s1, s2)} +// - s3 string +func (_e *Servicer_Expecter) SaveSession(context1 interface{}, sessionData interface{}, s interface{}, s1 interface{}, s2 interface{}, s3 interface{}) *Servicer_SaveSession_Call { + return &Servicer_SaveSession_Call{Call: _e.mock.On("SaveSession", context1, sessionData, s, s1, s2, s3)} } -func (_c *Servicer_SaveSession_Call) Run(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string)) *Servicer_SaveSession_Call { +func (_c *Servicer_SaveSession_Call) Run(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string)) *Servicer_SaveSession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -3306,12 +3313,17 @@ func (_c *Servicer_SaveSession_Call) Run(run func(context1 context.Context, sess if args[4] != nil { arg4 = args[4].(string) } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -3322,7 +3334,7 @@ func (_c *Servicer_SaveSession_Call) Return(err error) *Servicer_SaveSession_Cal return _c } -func (_c *Servicer_SaveSession_Call) RunAndReturn(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string) error) *Servicer_SaveSession_Call { +func (_c *Servicer_SaveSession_Call) RunAndReturn(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string) error) *Servicer_SaveSession_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/session_repository.go b/api/mocks/session_repository.go index 054eaf5e..69305357 100644 --- a/api/mocks/session_repository.go +++ b/api/mocks/session_repository.go @@ -355,16 +355,16 @@ func (_c *SessionRepository_GetSession_Call) RunAndReturn(run func(ctx context.C } // SaveSession provides a mock function for the type SessionRepository -func (_mock *SessionRepository) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string) error { - ret := _mock.Called(ctx, sessionData, token, userID, purpose) +func (_mock *SessionRepository) SaveSession(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string) error { + ret := _mock.Called(ctx, sessionData, token, userID, purpose, subID) if len(ret) == 0 { panic("no return value specified for SaveSession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string) error); ok { - r0 = returnFunc(ctx, sessionData, token, userID, purpose) + if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string, string) error); ok { + r0 = returnFunc(ctx, sessionData, token, userID, purpose, subID) } else { r0 = ret.Error(0) } @@ -382,11 +382,12 @@ type SessionRepository_SaveSession_Call struct { // - token string // - userID string // - purpose string -func (_e *SessionRepository_Expecter) SaveSession(ctx interface{}, sessionData interface{}, token interface{}, userID interface{}, purpose interface{}) *SessionRepository_SaveSession_Call { - return &SessionRepository_SaveSession_Call{Call: _e.mock.On("SaveSession", ctx, sessionData, token, userID, purpose)} +// - subID string +func (_e *SessionRepository_Expecter) SaveSession(ctx interface{}, sessionData interface{}, token interface{}, userID interface{}, purpose interface{}, subID interface{}) *SessionRepository_SaveSession_Call { + return &SessionRepository_SaveSession_Call{Call: _e.mock.On("SaveSession", ctx, sessionData, token, userID, purpose, subID)} } -func (_c *SessionRepository_SaveSession_Call) Run(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string)) *SessionRepository_SaveSession_Call { +func (_c *SessionRepository_SaveSession_Call) Run(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string)) *SessionRepository_SaveSession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -408,12 +409,17 @@ func (_c *SessionRepository_SaveSession_Call) Run(run func(ctx context.Context, if args[4] != nil { arg4 = args[4].(string) } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -424,7 +430,7 @@ func (_c *SessionRepository_SaveSession_Call) Return(err error) *SessionReposito return _c } -func (_c *SessionRepository_SaveSession_Call) RunAndReturn(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string) error) *SessionRepository_SaveSession_Call { +func (_c *SessionRepository_SaveSession_Call) RunAndReturn(run func(ctx context.Context, sessionData webauthn.SessionData, token string, userID string, purpose string, subID string) error) *SessionRepository_SaveSession_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/session_servicer.go b/api/mocks/session_servicer.go index fbe1b095..c32da456 100644 --- a/api/mocks/session_servicer.go +++ b/api/mocks/session_servicer.go @@ -355,16 +355,16 @@ func (_c *SessionServicer_GetSession_Call) RunAndReturn(run func(context1 contex } // SaveSession provides a mock function for the type SessionServicer -func (_mock *SessionServicer) SaveSession(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string) error { - ret := _mock.Called(context1, sessionData, s, s1, s2) +func (_mock *SessionServicer) SaveSession(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string) error { + ret := _mock.Called(context1, sessionData, s, s1, s2, s3) if len(ret) == 0 { panic("no return value specified for SaveSession") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string) error); ok { - r0 = returnFunc(context1, sessionData, s, s1, s2) + if returnFunc, ok := ret.Get(0).(func(context.Context, webauthn.SessionData, string, string, string, string) error); ok { + r0 = returnFunc(context1, sessionData, s, s1, s2, s3) } else { r0 = ret.Error(0) } @@ -382,11 +382,12 @@ type SessionServicer_SaveSession_Call struct { // - s string // - s1 string // - s2 string -func (_e *SessionServicer_Expecter) SaveSession(context1 interface{}, sessionData interface{}, s interface{}, s1 interface{}, s2 interface{}) *SessionServicer_SaveSession_Call { - return &SessionServicer_SaveSession_Call{Call: _e.mock.On("SaveSession", context1, sessionData, s, s1, s2)} +// - s3 string +func (_e *SessionServicer_Expecter) SaveSession(context1 interface{}, sessionData interface{}, s interface{}, s1 interface{}, s2 interface{}, s3 interface{}) *SessionServicer_SaveSession_Call { + return &SessionServicer_SaveSession_Call{Call: _e.mock.On("SaveSession", context1, sessionData, s, s1, s2, s3)} } -func (_c *SessionServicer_SaveSession_Call) Run(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string)) *SessionServicer_SaveSession_Call { +func (_c *SessionServicer_SaveSession_Call) Run(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string)) *SessionServicer_SaveSession_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -408,12 +409,17 @@ func (_c *SessionServicer_SaveSession_Call) Run(run func(context1 context.Contex if args[4] != nil { arg4 = args[4].(string) } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -424,7 +430,7 @@ func (_c *SessionServicer_SaveSession_Call) Return(err error) *SessionServicer_S return _c } -func (_c *SessionServicer_SaveSession_Call) RunAndReturn(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string) error) *SessionServicer_SaveSession_Call { +func (_c *SessionServicer_SaveSession_Call) RunAndReturn(run func(context1 context.Context, sessionData webauthn.SessionData, s string, s1 string, s2 string, s3 string) error) *SessionServicer_SaveSession_Call { _c.Call.Return(run) return _c } diff --git a/api/model/session.go b/api/model/session.go index daf9c7ab..0fa55d5c 100644 --- a/api/model/session.go +++ b/api/model/session.go @@ -15,6 +15,7 @@ type Session struct { Data []byte `bson:"data" json:"-"` SessionData webauthn.SessionData `bson:"-" json:"-"` Purpose string `bson:"purpose,omitempty" json:"-"` + SubID string `bson:"sub_id,omitempty" json:"-"` // ExpiresAt is used as TTL index in mongoDB LastModified time.Time `bson:"last_modified" json:"-"` } diff --git a/api/service/service.go b/api/service/service.go index 1ac51738..a5180a8a 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -85,7 +85,7 @@ type CredentialServicer interface { } type PasskeyServicer interface { - BeginRegistration(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error) + BeginRegistration(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error) FinishRegistration(ctx context.Context, token string, httpReq *http.Request, paSessionID string) error BeginLogin(ctx context.Context, email string) (*protocol.CredentialAssertion, string, error) FinishLogin(ctx context.Context, token string, httpReq *http.Request, saveSession bool) (*model.Account, string, string, error) @@ -97,7 +97,7 @@ type PasskeyServicer interface { type SessionServicer interface { GetSession(context.Context, string) (model.Session, bool, error) - SaveSession(context.Context, webauthn.SessionData, string, string, string) error + SaveSession(context.Context, webauthn.SessionData, string, string, string, string) error DeleteSession(context.Context, string) error DeleteSessionsByAccountID(ctx context.Context, accID string) error DeleteSessionsByAccountIDExceptCurrent(ctx context.Context, accID, currentToken string) error diff --git a/api/service/session.go b/api/service/session.go index 1b3c0a27..be0e9886 100644 --- a/api/service/session.go +++ b/api/service/session.go @@ -23,8 +23,8 @@ func (s *Service) GetSession(ctx context.Context, token string) (model.Session, return session, exists, nil } -func (s *Service) SaveSession(ctx context.Context, session webauthn.SessionData, token string, accID string, purpose string) error { - err := s.Store.SaveSession(ctx, session, token, accID, purpose) +func (s *Service) SaveSession(ctx context.Context, session webauthn.SessionData, token string, accID string, purpose string, subID string) error { + err := s.Store.SaveSession(ctx, session, token, accID, purpose, subID) if err != nil { return ErrSaveSession } diff --git a/api/service/webauthn.go b/api/service/webauthn.go index cfec01ca..0e4b0859 100644 --- a/api/service/webauthn.go +++ b/api/service/webauthn.go @@ -19,8 +19,10 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" ) -// BeginRegistration starts the WebAuthn registration process -func (s *Service) BeginRegistration(ctx context.Context, account *model.Account) (*protocol.CredentialCreation, string, error) { +// BeginRegistration starts the WebAuthn registration process. +// subID is the external subscription ID from the signup request; it is stored +// in the transient session so FinishRegistration can forward it to the webhook. +func (s *Service) BeginRegistration(ctx context.Context, account *model.Account, subID string) (*protocol.CredentialCreation, string, error) { // Create a user object with credentials user := &passkey.WebAuthnUser{ Account: account, @@ -39,8 +41,8 @@ func (s *Service) BeginRegistration(ctx context.Context, account *model.Account) return nil, "", fmt.Errorf("failed to generate session token: %w", err) } - // Save session - err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "") + // Save session (includes subID so FinishRegistration can forward it to the webhook) + err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "", subID) if err != nil { return nil, "", fmt.Errorf("failed to save session: %w", err) } @@ -74,12 +76,8 @@ func (s *Service) FinishRegistration(ctx context.Context, token string, httpReq return fmt.Errorf("failed to save credential: %w", err) } - // Get subscription ID - sub, err := s.Store.GetSubscriptionByAccountId(ctx, account.ID.Hex()) - if err != nil { - return fmt.Errorf("failed to get subscription ID for account: %w", err) - } - if err = s.CompleteRegistration(ctx, account, sub.ID.String(), paSessionID); err != nil { + // Use the external subID preserved in the session from BeginRegistration + if err = s.CompleteRegistration(ctx, account, session.SubID, paSessionID); err != nil { return fmt.Errorf("failed to complete registration: %w", err) } @@ -129,7 +127,7 @@ func (s *Service) BeginLogin(ctx context.Context, email string) (*protocol.Crede } // Save session - err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "") // s.sessionDuration + err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "", "") if err != nil { return nil, "", fmt.Errorf("failed to save session: %w", err) } @@ -185,7 +183,7 @@ func (s *Service) BeginReauth(ctx context.Context, purpose, accountId string) (* return nil, "", fmt.Errorf("failed to generate session token: %w", err) } - if err = s.SaveSession(ctx, *sessionData, token, acc.ID.Hex(), purpose); err != nil { + if err = s.SaveSession(ctx, *sessionData, token, acc.ID.Hex(), purpose, ""); err != nil { return nil, "", fmt.Errorf("failed to save reauth session: %w", err) } @@ -290,7 +288,7 @@ func (s *Service) FinishLogin(ctx context.Context, tmpToken string, httpReq *htt return nil, "", purpose, fmt.Errorf("failed to generate session token: %w", err) } - err = s.SaveSession(ctx, sessionData, token, account.ID.Hex(), "") + err = s.SaveSession(ctx, sessionData, token, account.ID.Hex(), "", "") if err != nil { return nil, "", purpose, fmt.Errorf("failed to save session: %w", err) } @@ -333,7 +331,7 @@ func (s *Service) BeginAddPasskey(ctx context.Context, account *model.Account) ( } // Save session - err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "") + err = s.SaveSession(ctx, *sessionData, token, account.ID.Hex(), "", "") if err != nil { return nil, "", fmt.Errorf("failed to save session: %w", err) } From e72a760854904322dba1b858c7ae22872f4b7705 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 30 Apr 2026 11:57:22 +0200 Subject: [PATCH 28/54] feat(api): Subscription Guard mechanism Signed-off-by: Maciek --- api/api/server.go | 1 + api/internal/middleware/subscription.go | 157 +++++++++++++++++++ api/internal/middleware/subscription_test.go | 109 +++++++++++++ api/model/subscription.go | 5 +- 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 api/internal/middleware/subscription.go create mode 100644 api/internal/middleware/subscription_test.go diff --git a/api/api/server.go b/api/api/server.go index e48594bc..b6e7087f 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -145,6 +145,7 @@ func (s *APIServer) RegisterRoutes() { // Protected endpoints start here (note: only v1 group is protected) v1.Use(middleware.NewAuth(&s.Service, s.Config.API, func(c *fiber.Ctx) bool { return true })) + v1.Use(middleware.NewSubscriptionGuard(s.Service.SubscriptionServicer)) // Subscription (protected) endpoint (session auth only) sub.Get("", middleware.NewLimit(40, 1*time.Minute), s.getSubscription()) diff --git a/api/internal/middleware/subscription.go b/api/internal/middleware/subscription.go new file mode 100644 index 00000000..7514d95f --- /dev/null +++ b/api/internal/middleware/subscription.go @@ -0,0 +1,157 @@ +package middleware + +import ( + "context" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/ivpn/dns/api/internal/auth" + "github.com/ivpn/dns/api/model" +) + +// SubscriptionProvider can fetch a subscription for a given account. +type SubscriptionProvider interface { + GetSubscription(ctx context.Context, accountID string) (*model.Subscription, error) +} + +// NewSubscriptionGuard returns middleware that enforces API access rules based +// on subscription lifecycle status. Routes are classified into three tiers: +// +// - alwaysAllowed: accessible in any subscription state (auth, logout, delete account, resync, export) +// - limitedAllowed: accessible during Active, GracePeriod, AND LimitedAccess (read-only views, account mgmt) +// - everything else: blocked during both LimitedAccess and PendingDelete (mutations to profiles, rules, blocklists) +// +// Uses an allowlist pattern: new endpoints are blocked by default until explicitly classified. +// If provider is nil, the middleware is a no-op (allows all requests). +func NewSubscriptionGuard(provider SubscriptionProvider) fiber.Handler { + return func(c *fiber.Ctx) error { + if provider == nil { + return c.Next() + } + + accountID := auth.GetAccountID(c) + if accountID == "" { + return c.Next() // unauthenticated route — auth middleware handles this + } + + sub, err := provider.GetSubscription(c.Context(), accountID) + if err != nil { + return c.Next() // no subscription found — allow (pre-ZLA or new accounts) + } + + status := sub.GetStatus() + if status == model.StatusActive || status == model.StatusGracePeriod { + return c.Next() + } + + method := c.Method() + path := c.Path() + c.Route() + + if isAlwaysAllowed(method, path) { + return c.Next() + } + + if status == model.StatusLimitedAccess && isLimitedAccessAllowed(method, path) { + return c.Next() + } + + msg := "Your account is in limited access mode." + if status == model.StatusPendingDelete { + msg = "Your account is pending deletion." + } + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": msg, "status": string(status)}) + } +} + +// isAlwaysAllowed returns true for routes accessible in any subscription state. +// Uses the raw request path (c.Path()), not the parameterized Fiber route. +func isAlwaysAllowed(method, path string) bool { + always := []routeRule{ + // Auth (login, register, webauthn auth ceremonies) + {method: "POST", prefix: "/api/v1/login"}, + {method: "POST", prefix: "/api/v1/accounts", exact: true}, + {method: "POST", prefix: "/api/v1/webauthn/register/"}, + {method: "POST", prefix: "/api/v1/webauthn/login/"}, + // PASession flow + {method: "PUT", prefix: "/api/v1/pasession/rotate"}, + // Subscription view + resync + {method: "GET", prefix: "/api/v1/sub", exact: true}, + {method: "PUT", prefix: "/api/v1/sub/update"}, + // Logout + {method: "POST", prefix: "/api/v1/accounts/logout"}, + // View own account + {method: "GET", prefix: "/api/v1/accounts/current", exact: true}, + // Account deletion + {method: "POST", prefix: "/api/v1/accounts/current/deletion-code"}, + {method: "DELETE", prefix: "/api/v1/accounts/current", exact: true}, + // Export (future — pre-whitelisted) + {method: "GET", prefix: "/api/v1/accounts/current/export"}, + // Password reset + {method: "POST", prefix: "/api/v1/verify/reset-password"}, + {method: "POST", prefix: "/api/v1/accounts/reset-password"}, + } + + return matchRoute(method, path, always) +} + +// isLimitedAccessAllowed returns true for routes allowed during LimitedAccess +// (in addition to alwaysAllowed). These are blocked during PendingDelete. +func isLimitedAccessAllowed(method, path string) bool { + limited := []routeRule{ + // Read-only profile views + {method: "GET", prefix: "/api/v1/profiles"}, + // Delete logs (data cleanup) + {method: "DELETE", prefix: "/api/v1/profiles/", suffix: "/logs"}, + // Read-only catalogs + {method: "GET", prefix: "/api/v1/blocklists"}, + {method: "GET", prefix: "/api/v1/services"}, + // Passkey management + {method: "GET", prefix: "/api/v1/webauthn/passkeys"}, + {method: "POST", prefix: "/api/v1/webauthn/passkey/add/"}, + {method: "DELETE", prefix: "/api/v1/webauthn/passkey/"}, + // Reauth + {method: "POST", prefix: "/api/v1/webauthn/passkey/reauth/"}, + // Account management + {method: "PATCH", prefix: "/api/v1/accounts", exact: true}, + // 2FA + {method: "POST", prefix: "/api/v1/accounts/mfa/totp/"}, + // Sessions + {method: "DELETE", prefix: "/api/v1/sessions"}, + // Email verification + {method: "POST", prefix: "/api/v1/verify/email/otp/"}, + } + + return matchRoute(method, path, limited) +} + +type routeRule struct { + method string + prefix string + suffix string // optional: path must also end with this + exact bool // if true, path must equal prefix exactly +} + +// matchRoute checks if the request matches any route rule against the raw +// request URL path (c.Path()). +func matchRoute(method, path string, rules []routeRule) bool { + for _, r := range rules { + if r.method != method { + continue + } + if r.exact { + if path == r.prefix { + return true + } + continue + } + if !strings.HasPrefix(path, r.prefix) { + continue + } + if r.suffix != "" && !strings.HasSuffix(path, r.suffix) { + continue + } + return true + } + return false +} diff --git a/api/internal/middleware/subscription_test.go b/api/internal/middleware/subscription_test.go new file mode 100644 index 00000000..cdc3bfe3 --- /dev/null +++ b/api/internal/middleware/subscription_test.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "testing" +) + +func TestIsAlwaysAllowed(t *testing.T) { + tests := []struct { + method string + path string + want bool + }{ + // Always allowed + {"POST", "/api/v1/login", true}, + {"POST", "/api/v1/accounts", true}, + {"POST", "/api/v1/webauthn/register/begin", true}, + {"POST", "/api/v1/webauthn/register/finish", true}, + {"POST", "/api/v1/webauthn/login/begin", true}, + {"POST", "/api/v1/webauthn/login/finish", true}, + {"PUT", "/api/v1/pasession/rotate", true}, + {"GET", "/api/v1/sub", true}, + {"PUT", "/api/v1/sub/update", true}, + {"POST", "/api/v1/accounts/logout", true}, + {"GET", "/api/v1/accounts/current", true}, + {"POST", "/api/v1/accounts/current/deletion-code", true}, + {"DELETE", "/api/v1/accounts/current", true}, + {"GET", "/api/v1/accounts/current/export", true}, + {"POST", "/api/v1/verify/reset-password", true}, + {"POST", "/api/v1/accounts/reset-password", true}, + + // POST /accounts is exact — sub-paths are NOT always allowed + {"POST", "/api/v1/accounts/mfa/totp/enable", false}, + + // GET /accounts/current is exact — sub-paths are NOT always allowed + {"GET", "/api/v1/accounts/current/export", true}, // export IS explicitly listed + + // NOT always allowed — require LimitedAccess + {"GET", "/api/v1/profiles", false}, + {"GET", "/api/v1/profiles/abc123", false}, + {"GET", "/api/v1/blocklists", false}, + {"PATCH", "/api/v1/accounts", false}, + {"POST", "/api/v1/verify/email/otp/request", false}, + + // Mutations — never always allowed + {"POST", "/api/v1/profiles", false}, + {"PATCH", "/api/v1/profiles/abc123", false}, + {"POST", "/api/v1/profiles/abc123/blocklists", false}, + {"POST", "/api/v1/mobileconfig", false}, + } + + for _, tt := range tests { + got := isAlwaysAllowed(tt.method, tt.path) + if got != tt.want { + t.Errorf("isAlwaysAllowed(%s, %s) = %v, want %v", tt.method, tt.path, got, tt.want) + } + } +} + +func TestIsLimitedAccessAllowed(t *testing.T) { + tests := []struct { + method string + path string + want bool + }{ + // Allowed during Limited Access + {"GET", "/api/v1/profiles", true}, + {"GET", "/api/v1/profiles/abc123", true}, + {"GET", "/api/v1/profiles/abc123/logs", true}, + {"GET", "/api/v1/profiles/abc123/logs/download", true}, + {"DELETE", "/api/v1/profiles/abc123/logs", true}, + {"GET", "/api/v1/profiles/abc123/statistics", true}, + {"GET", "/api/v1/blocklists", true}, + {"GET", "/api/v1/services", true}, + {"GET", "/api/v1/webauthn/passkeys", true}, + {"POST", "/api/v1/webauthn/passkey/add/begin", true}, + {"POST", "/api/v1/webauthn/passkey/add/finish", true}, + {"DELETE", "/api/v1/webauthn/passkey/abc123", true}, + {"POST", "/api/v1/webauthn/passkey/reauth/begin", true}, + {"POST", "/api/v1/webauthn/passkey/reauth/finish", true}, + {"PATCH", "/api/v1/accounts", true}, + {"POST", "/api/v1/accounts/mfa/totp/enable", true}, + {"POST", "/api/v1/accounts/mfa/totp/enable/confirm", true}, + {"POST", "/api/v1/accounts/mfa/totp/disable", true}, + {"DELETE", "/api/v1/sessions", true}, + {"POST", "/api/v1/verify/email/otp/request", true}, + {"POST", "/api/v1/verify/email/otp/confirm", true}, + + // NOT allowed — blocked mutations + {"POST", "/api/v1/profiles", false}, // create profile (POST prefix matches GET prefix, but method differs) + {"DELETE", "/api/v1/profiles/abc123", false}, // delete profile (not suffix /logs) + {"PATCH", "/api/v1/profiles/abc123", false}, // update profile settings + {"POST", "/api/v1/profiles/abc123/blocklists", false}, + {"DELETE", "/api/v1/profiles/abc123/blocklists", false}, + {"POST", "/api/v1/profiles/abc123/services", false}, + {"DELETE", "/api/v1/profiles/abc123/services", false}, + {"POST", "/api/v1/profiles/abc123/custom_rules", false}, + {"POST", "/api/v1/profiles/abc123/custom_rules/batch", false}, + {"DELETE", "/api/v1/profiles/prof1/custom_rules/rule1", false}, + {"POST", "/api/v1/mobileconfig", false}, + {"POST", "/api/v1/mobileconfig/short", false}, + } + + for _, tt := range tests { + got := isLimitedAccessAllowed(tt.method, tt.path) + if got != tt.want { + t.Errorf("isLimitedAccessAllowed(%s, %s) = %v, want %v", tt.method, tt.path, got, tt.want) + } + } +} diff --git a/api/model/subscription.go b/api/model/subscription.go index a56aa15f..ac84097a 100644 --- a/api/model/subscription.go +++ b/api/model/subscription.go @@ -30,8 +30,9 @@ type Subscription struct { Tier string `json:"tier,omitempty" bson:"tier,omitempty"` TokenHash string `json:"-" bson:"token_hash,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` - Notified bool `json:"-" bson:"notified"` - Limits SubscriptionLimits `json:"-" bson:"limits"` + Notified bool `json:"-" bson:"notified"` + NotifiedPendingDelete bool `json:"-" bson:"notified_pending_delete"` + Limits SubscriptionLimits `json:"-" bson:"limits"` // Computed fields (not persisted) Status SubscriptionStatus `json:"status" bson:"-"` From ad94309b6373460547bbb50be854a71e96e3ed35 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 15:57:57 +0200 Subject: [PATCH 29/54] feat(api): pending-delete subscription lifecycle Signed-off-by: Maciek --- api/db/mongodb/subscription.go | 47 +++++++++++++++++ api/db/repository/subscription.go | 3 ++ api/internal/cron/cron.go | 12 ++++- api/internal/cron/jobs.go | 70 +++++++++++++++++++++++++ api/internal/email/content/content.go | 9 ++++ api/internal/email/mailer.go | 1 + api/internal/email/mailpit/mailpit.go | 12 +++++ api/internal/email/mailtrap/mailtrap.go | 13 +++++ api/internal/email/sendgrid/sendgrid.go | 6 +++ api/main.go | 2 +- api/service/service.go | 2 +- api/service/subscription/service.go | 27 +++++++++- 12 files changed, 200 insertions(+), 4 deletions(-) diff --git a/api/db/mongodb/subscription.go b/api/db/mongodb/subscription.go index 7ad71bf6..81051f9d 100644 --- a/api/db/mongodb/subscription.go +++ b/api/db/mongodb/subscription.go @@ -119,3 +119,50 @@ func (r *SubscriptionRepository) MarkNotified(ctx context.Context, subscriptionI } return err } + +// FindPendingDeleteUnnotified returns subscriptions where notified_pending_delete=false +// and active_until + 14 days < now (both grace periods exceeded). +func (r *SubscriptionRepository) FindPendingDeleteUnnotified(ctx context.Context) ([]model.Subscription, error) { + fourteenDaysAgo := time.Now().AddDate(0, 0, -14) + filter := bson.M{ + "notified_pending_delete": false, + "active_until": bson.M{"$lt": fourteenDaysAgo}, + } + cursor, err := r.subscriptionsCollection.Find(ctx, filter) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var subs []model.Subscription + if err := cursor.All(ctx, &subs); err != nil { + return nil, err + } + return subs, nil +} + +// MarkPendingDeleteNotified sets notified_pending_delete=true for the given subscription IDs. +func (r *SubscriptionRepository) MarkPendingDeleteNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + if len(subscriptionIDs) == 0 { + return nil + } + filter := bson.M{"_id": bson.M{"$in": subscriptionIDs}} + update := bson.M{"$set": bson.M{"notified_pending_delete": true}} + _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) + if err != nil { + log.Error().Err(err).Msg("Failed to mark subscriptions as pending delete notified") + } + return err +} + +// ResetPendingDeleteNotifiedForActive sets notified_pending_delete=false for all subscriptions +// where active_until >= now. +func (r *SubscriptionRepository) ResetPendingDeleteNotifiedForActive(ctx context.Context) error { + filter := bson.M{"active_until": bson.M{"$gte": time.Now()}} + update := bson.M{"$set": bson.M{"notified_pending_delete": false}} + _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) + if err != nil { + log.Error().Err(err).Msg("Failed to reset notified_pending_delete flag for active subscriptions") + } + return err +} diff --git a/api/db/repository/subscription.go b/api/db/repository/subscription.go index 78a2a6a6..7668c8a7 100644 --- a/api/db/repository/subscription.go +++ b/api/db/repository/subscription.go @@ -15,4 +15,7 @@ type SubscriptionRepository interface { ResetNotifiedForActive(ctx context.Context) error FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) MarkNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error + FindPendingDeleteUnnotified(ctx context.Context) ([]model.Subscription, error) + MarkPendingDeleteNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error + ResetPendingDeleteNotifiedForActive(ctx context.Context) error } diff --git a/api/internal/cron/cron.go b/api/internal/cron/cron.go index b37dc494..ee32c299 100644 --- a/api/internal/cron/cron.go +++ b/api/internal/cron/cron.go @@ -2,13 +2,14 @@ package cron import ( "github.com/go-co-op/gocron/v2" + "github.com/ivpn/dns/api/cache" "github.com/ivpn/dns/api/db/repository" "github.com/ivpn/dns/api/internal/email" "github.com/rs/zerolog/log" ) // Start initializes the gocron scheduler with all periodic jobs. -func Start(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, mailer email.Mailer) { +func Start(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, profileRepo repository.ProfileRepository, profileCache cache.Cache, mailer email.Mailer) { s, err := gocron.NewScheduler() if err != nil { log.Error().Err(err).Msg("Failed to create cron scheduler") @@ -24,6 +25,15 @@ func Start(subRepo repository.SubscriptionRepository, accountRepo repository.Acc return } + _, err = s.NewJob( + gocron.CronJob("30 * * * *", false), // every hour at minute 30 + gocron.NewTask(NotifyPendingDeleteSubscriptions, subRepo, accountRepo, profileRepo, profileCache, mailer), + ) + if err != nil { + log.Error().Err(err).Msg("Failed to schedule pending-delete notification job") + return + } + s.Start() log.Info().Msg("Cron scheduler started") } diff --git a/api/internal/cron/jobs.go b/api/internal/cron/jobs.go index 7ece41ca..0bf8b0e4 100644 --- a/api/internal/cron/jobs.go +++ b/api/internal/cron/jobs.go @@ -4,6 +4,7 @@ import ( "context" "github.com/google/uuid" + "github.com/ivpn/dns/api/cache" "github.com/ivpn/dns/api/db/repository" "github.com/ivpn/dns/api/internal/email" "github.com/rs/zerolog/log" @@ -55,3 +56,72 @@ func NotifyExpiringSubscriptions(subRepo repository.SubscriptionRepository, acco log.Error().Err(err).Msg("Cron: failed to mark subscriptions as notified") } } + +// NotifyPendingDeleteSubscriptions resets the notified_pending_delete flag for active subscriptions, +// finds pending-delete+unnotified ones, deletes their Redis profile settings (DNS cutoff), +// sends notification emails, and marks them as notified. +func NotifyPendingDeleteSubscriptions(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, profileRepo repository.ProfileRepository, profileCache cache.Cache, mailer email.Mailer) { + ctx := context.Background() + + // 1. Reset notified_pending_delete for active subscriptions + if err := subRepo.ResetPendingDeleteNotifiedForActive(ctx); err != nil { + log.Error().Err(err).Msg("Cron: failed to reset notified_pending_delete flag for active subscriptions") + } + + // 2. Find pending-delete+unnotified subscriptions + subs, err := subRepo.FindPendingDeleteUnnotified(ctx) + if err != nil { + log.Error().Err(err).Msg("Cron: failed to find pending-delete unnotified subscriptions") + return + } + + if len(subs) == 0 { + return + } + + log.Info().Int("count", len(subs)).Msg("Cron: notifying pending-delete subscriptions") + + // 3. DNS cutoff: delete Redis profile settings for each subscription's profiles + for _, sub := range subs { + account, err := accountRepo.GetAccountById(ctx, sub.AccountID.Hex()) + if err != nil { + log.Error().Err(err).Str("account_id", sub.AccountID.Hex()).Msg("Cron: failed to get account for DNS cutoff") + continue + } + + profiles, err := profileRepo.GetProfilesByAccountId(ctx, account.ID.Hex()) + if err != nil { + log.Error().Err(err).Str("account_id", sub.AccountID.Hex()).Msg("Cron: failed to get profiles for DNS cutoff") + continue + } + + for _, profile := range profiles { + if err := profileCache.DeleteProfileSettings(ctx, profile.ProfileId); err != nil { + log.Error().Err(err).Str("profile_id", profile.ProfileId).Msg("Cron: failed to delete profile settings from cache (DNS cutoff)") + } + } + } + + // 4. Send notification emails + for _, sub := range subs { + account, err := accountRepo.GetAccountById(ctx, sub.AccountID.Hex()) + if err != nil { + log.Error().Err(err).Str("account_id", sub.AccountID.Hex()).Msg("Cron: failed to get account for pending-delete notification") + continue + } + + if err := mailer.SendPendingDeleteEmail(ctx, account.Email); err != nil { + log.Error().Err(err).Str("email", account.Email).Msg("Cron: failed to send pending-delete email") + continue + } + } + + // 5. Mark as notified + ids := make([]uuid.UUID, 0, len(subs)) + for _, sub := range subs { + ids = append(ids, sub.ID) + } + if err := subRepo.MarkPendingDeleteNotified(ctx, ids); err != nil { + log.Error().Err(err).Msg("Cron: failed to mark subscriptions as pending-delete notified") + } +} diff --git a/api/internal/email/content/content.go b/api/internal/email/content/content.go index 3b50f2a6..637adcaa 100644 --- a/api/internal/email/content/content.go +++ b/api/internal/email/content/content.go @@ -38,6 +38,15 @@ func SubscriptionExpiryContent() EmailContent { } } +// PendingDeleteContent returns the pending deletion notification email content. +func PendingDeleteContent() EmailContent { + return EmailContent{ + Subject: "Your modDNS account is pending deletion", + Plain: "Hello,\n\nYour modDNS account has been in limited access mode for 14 days and is now pending deletion. DNS resolution has been disabled for your profiles.\n\nTo reinstate full access, add time to your IVPN account: https://www.ivpn.net\n\nRegards,\nmodDNS Staff", + Html: "

Hello,

Your modDNS account has been in limited access mode for 14 days and is now pending deletion. DNS resolution has been disabled for your profiles.

To reinstate full access, add time to your IVPN account: https://www.ivpn.net

Regards,
modDNS Staff

", + } +} + // EmailVerificationOTPContent returns the email verification OTP content. func EmailVerificationOTPContent(otp string) EmailContent { return EmailContent{ diff --git a/api/internal/email/mailer.go b/api/internal/email/mailer.go index edbd823a..07554c98 100644 --- a/api/internal/email/mailer.go +++ b/api/internal/email/mailer.go @@ -21,6 +21,7 @@ type Mailer interface { SendPasswordResetEmail(ctx context.Context, sendTo, passwordResetToken string) error SendEmailVerificationOTP(ctx context.Context, sendTo, otp string) error SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error + SendPendingDeleteEmail(ctx context.Context, sendTo string) error Verify(email string) error } diff --git a/api/internal/email/mailpit/mailpit.go b/api/internal/email/mailpit/mailpit.go index 95b75c1a..aa9f027e 100644 --- a/api/internal/email/mailpit/mailpit.go +++ b/api/internal/email/mailpit/mailpit.go @@ -102,6 +102,18 @@ func (m *Mailpit) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string return m.sendEmail(ctx, sendTo, reqBody) } +// SendPendingDeleteEmail notifies the user their account is pending deletion. +func (m *Mailpit) SendPendingDeleteEmail(ctx context.Context, sendTo string) error { + c := content.PendingDeleteContent() + reqBody := mailpitSendRequest{ + From: Email{Email: "info@moddns.net", Name: "modDNS"}, + To: []Email{{Email: sendTo, Name: "User"}}, + Subject: c.Subject, + Text: c.Plain, + } + return m.sendEmail(ctx, sendTo, reqBody) +} + // sendEmail sends an email using the Mailpit API func (m *Mailpit) sendEmail(ctx context.Context, email string, reqBody mailpitSendRequest) error { payload, err := json.Marshal(reqBody) diff --git a/api/internal/email/mailtrap/mailtrap.go b/api/internal/email/mailtrap/mailtrap.go index d1b225e0..5797b716 100644 --- a/api/internal/email/mailtrap/mailtrap.go +++ b/api/internal/email/mailtrap/mailtrap.go @@ -114,6 +114,19 @@ func (m *Mailtrap) SendSubscriptionExpiryEmail(ctx context.Context, sendTo strin return m.sendEmail(ctx, sendTo, req) } +// SendPendingDeleteEmail notifies the user their account is pending deletion. +func (m *Mailtrap) SendPendingDeleteEmail(ctx context.Context, sendTo string) error { + c := content.PendingDeleteContent() + req := SendEmailRequest{ + From: From{Email: "moddns@demomailtrap.com", Name: "modDNS Team"}, + To: []To{{Email: sendTo}}, + Subject: c.Subject, + Text: c.Plain, + Html: c.Html, + } + return m.sendEmail(ctx, sendTo, req) +} + // Verify checks if email provided is valid func (m *Mailtrap) Verify(email string) error { initVerRes, err := m.verifier.Verify(email) diff --git a/api/internal/email/sendgrid/sendgrid.go b/api/internal/email/sendgrid/sendgrid.go index 84b65c93..5863ddb4 100644 --- a/api/internal/email/sendgrid/sendgrid.go +++ b/api/internal/email/sendgrid/sendgrid.go @@ -80,6 +80,12 @@ func (m *Mailer) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) return m.sendBasic(ctx, sendTo, c.Subject, c.Plain, c.Html) } +// SendPendingDeleteEmail notifies the user their account is pending deletion. +func (m *Mailer) SendPendingDeleteEmail(ctx context.Context, sendTo string) error { + c := content.PendingDeleteContent() + return m.sendBasic(ctx, sendTo, c.Subject, c.Plain, c.Html) +} + // Verify performs basic syntax validation. Extend with more advanced service if needed. func (m *Mailer) Verify(email string) error { if email == "" { diff --git a/api/main.go b/api/main.go index c6ca2c74..090e2124 100644 --- a/api/main.go +++ b/api/main.go @@ -158,7 +158,7 @@ func main() { } server.RegisterRoutes() - cron.Start(db, db, mailer) + cron.Start(db, db, db, cache, mailer) err = server.App.Listen(appConfig.API.Port) log.Panic().Err(err).Msg("Failed to start REST API") diff --git a/api/service/service.go b/api/service/service.go index a5180a8a..c3d67116 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -48,7 +48,7 @@ func New(cfg config.Config, store db.Db, cache cache.Cache, idGen idgen.Generato statsSrv := statistics.NewStatisticsService(store) profSrv := profile.NewProfileService(*cfg.Server, *cfg.Service, store, blocklistSrv, queryLogsSrv, statsSrv, cache, idGen, apiValidator.Validator) httpClient := webhookClient.New(*cfg.API) - subSrv := subscription.NewSubscriptionService(store, cache, *cfg.Service, *cfg.API, *httpClient) + subSrv := subscription.NewSubscriptionService(store, store, cache, *cfg.Service, *cfg.API, *httpClient) accSrv := account.NewAccountService(*cfg.Service, store, profSrv, statsSrv, subSrv, store, cache, mailer, idGen, apiValidator.Validator, *httpClient) appleSrv := apple.NewAppleService(&cfg, cache, shortener) return Service{ diff --git a/api/service/subscription/service.go b/api/service/subscription/service.go index d03d3068..a207b14b 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -29,14 +29,16 @@ type SubscriptionService struct { ServiceCfg config.ServiceConfig APICfg config.APIConfig SubscriptionRepository repository.SubscriptionRepository + ProfileRepository repository.ProfileRepository Cache cache.Cache Http client.Http } // NewSubscriptionService creates a new subscription service -func NewSubscriptionService(db repository.SubscriptionRepository, cache cache.Cache, srvCfg config.ServiceConfig, apiCfg config.APIConfig, http client.Http) *SubscriptionService { +func NewSubscriptionService(db repository.SubscriptionRepository, profileRepo repository.ProfileRepository, cache cache.Cache, srvCfg config.ServiceConfig, apiCfg config.APIConfig, http client.Http) *SubscriptionService { return &SubscriptionService{ SubscriptionRepository: db, + ProfileRepository: profileRepo, Cache: cache, ServiceCfg: srvCfg, APICfg: apiCfg, @@ -174,6 +176,10 @@ func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Contex return err } + // Re-populate Redis profile settings for the account's profiles. + // This handles recovery from pending-delete state where DNS was cut (profile settings deleted from Redis). + s.repopulateProfileCache(ctx, sub.AccountID.Hex()) + subID := sub.ID.String() if err := s.Http.SignupWebhook(subID); err != nil { log.Error().Err(err).Str("sub_id", subID).Msg("Failed to send signup webhook after subscription update") @@ -182,3 +188,22 @@ func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Contex return nil } + +// repopulateProfileCache loads the account's profiles from MongoDB and writes their settings to Redis. +// Errors are logged but do not fail the caller -- DNS recovery is best-effort during resync. +func (s *SubscriptionService) repopulateProfileCache(ctx context.Context, accountID string) { + profiles, err := s.ProfileRepository.GetProfilesByAccountId(ctx, accountID) + if err != nil { + log.Error().Err(err).Str("account_id", accountID).Msg("Failed to load profiles for cache repopulation") + return + } + + for _, profile := range profiles { + if profile.Settings == nil { + continue + } + if err := s.Cache.CreateOrUpdateProfileSettings(ctx, profile.Settings, false); err != nil { + log.Error().Err(err).Str("profile_id", profile.ProfileId).Msg("Failed to repopulate profile settings in cache") + } + } +} From 2e45308c93d10c3bf18c74d2743db4d26809416b Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 15:59:58 +0200 Subject: [PATCH 30/54] chore(api): allow GET /webauthn/passkeys in any subscription state Signed-off-by: Maciek --- api/internal/middleware/subscription.go | 5 +++-- api/internal/middleware/subscription_test.go | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/api/internal/middleware/subscription.go b/api/internal/middleware/subscription.go index 7514d95f..2ea7bc79 100644 --- a/api/internal/middleware/subscription.go +++ b/api/internal/middleware/subscription.go @@ -87,6 +87,8 @@ func isAlwaysAllowed(method, path string) bool { {method: "DELETE", prefix: "/api/v1/accounts/current", exact: true}, // Export (future — pre-whitelisted) {method: "GET", prefix: "/api/v1/accounts/current/export"}, + // View passkeys (read-only, shown grayed out on account-preferences during PD) + {method: "GET", prefix: "/api/v1/webauthn/passkeys"}, // Password reset {method: "POST", prefix: "/api/v1/verify/reset-password"}, {method: "POST", prefix: "/api/v1/accounts/reset-password"}, @@ -106,8 +108,7 @@ func isLimitedAccessAllowed(method, path string) bool { // Read-only catalogs {method: "GET", prefix: "/api/v1/blocklists"}, {method: "GET", prefix: "/api/v1/services"}, - // Passkey management - {method: "GET", prefix: "/api/v1/webauthn/passkeys"}, + // Passkey management (GET passkeys moved to alwaysAllowed) {method: "POST", prefix: "/api/v1/webauthn/passkey/add/"}, {method: "DELETE", prefix: "/api/v1/webauthn/passkey/"}, // Reauth diff --git a/api/internal/middleware/subscription_test.go b/api/internal/middleware/subscription_test.go index cdc3bfe3..8c375662 100644 --- a/api/internal/middleware/subscription_test.go +++ b/api/internal/middleware/subscription_test.go @@ -27,6 +27,7 @@ func TestIsAlwaysAllowed(t *testing.T) { {"GET", "/api/v1/accounts/current/export", true}, {"POST", "/api/v1/verify/reset-password", true}, {"POST", "/api/v1/accounts/reset-password", true}, + {"GET", "/api/v1/webauthn/passkeys", true}, // POST /accounts is exact — sub-paths are NOT always allowed {"POST", "/api/v1/accounts/mfa/totp/enable", false}, @@ -71,7 +72,7 @@ func TestIsLimitedAccessAllowed(t *testing.T) { {"GET", "/api/v1/profiles/abc123/statistics", true}, {"GET", "/api/v1/blocklists", true}, {"GET", "/api/v1/services", true}, - {"GET", "/api/v1/webauthn/passkeys", true}, + // GET /webauthn/passkeys moved to alwaysAllowed — not tested here {"POST", "/api/v1/webauthn/passkey/add/begin", true}, {"POST", "/api/v1/webauthn/passkey/add/finish", true}, {"DELETE", "/api/v1/webauthn/passkey/abc123", true}, From 388695123f6615fbec35020b86ed28d103c8bcb4 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 16:03:27 +0200 Subject: [PATCH 31/54] chore(api): regenerate mocks Signed-off-by: Maciek --- api/mocks/db.go | 170 ++++++++++++++++++ api/mocks/mailer_email.go | 57 ++++++ api/mocks/subscription_provider_middleware.go | 107 +++++++++++ api/mocks/subscription_repository.go | 170 ++++++++++++++++++ api/service/account/reauth_test.go | 3 +- api/service/account/service_test.go | 4 +- api/service/account/verify_test.go | 3 +- 7 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 api/mocks/subscription_provider_middleware.go diff --git a/api/mocks/db.go b/api/mocks/db.go index 35d796ed..af41a8d1 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -1267,6 +1267,68 @@ func (_c *Db_FindExpiredUnnotified_Call) RunAndReturn(run func(ctx context.Conte return _c } +// FindPendingDeleteUnnotified provides a mock function for the type Db +func (_mock *Db) FindPendingDeleteUnnotified(ctx context.Context) ([]model.Subscription, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindPendingDeleteUnnotified") + } + + var r0 []model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]model.Subscription, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []model.Subscription); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Db_FindPendingDeleteUnnotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindPendingDeleteUnnotified' +type Db_FindPendingDeleteUnnotified_Call struct { + *mock.Call +} + +// FindPendingDeleteUnnotified is a helper method to define mock.On call +// - ctx context.Context +func (_e *Db_Expecter) FindPendingDeleteUnnotified(ctx interface{}) *Db_FindPendingDeleteUnnotified_Call { + return &Db_FindPendingDeleteUnnotified_Call{Call: _e.mock.On("FindPendingDeleteUnnotified", ctx)} +} + +func (_c *Db_FindPendingDeleteUnnotified_Call) Run(run func(ctx context.Context)) *Db_FindPendingDeleteUnnotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Db_FindPendingDeleteUnnotified_Call) Return(subscriptions []model.Subscription, err error) *Db_FindPendingDeleteUnnotified_Call { + _c.Call.Return(subscriptions, err) + return _c +} + +func (_c *Db_FindPendingDeleteUnnotified_Call) RunAndReturn(run func(ctx context.Context) ([]model.Subscription, error)) *Db_FindPendingDeleteUnnotified_Call { + _c.Call.Return(run) + return _c +} + // Get provides a mock function for the type Db func (_mock *Db) Get(ctx context.Context, filter map[string]any, sortBy string) ([]*model.Blocklist, error) { ret := _mock.Called(ctx, filter, sortBy) @@ -2390,6 +2452,63 @@ func (_c *Db_MarkNotified_Call) RunAndReturn(run func(ctx context.Context, subsc return _c } +// MarkPendingDeleteNotified provides a mock function for the type Db +func (_mock *Db) MarkPendingDeleteNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + ret := _mock.Called(ctx, subscriptionIDs) + + if len(ret) == 0 { + panic("no return value specified for MarkPendingDeleteNotified") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_MarkPendingDeleteNotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkPendingDeleteNotified' +type Db_MarkPendingDeleteNotified_Call struct { + *mock.Call +} + +// MarkPendingDeleteNotified is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionIDs []uuid.UUID +func (_e *Db_Expecter) MarkPendingDeleteNotified(ctx interface{}, subscriptionIDs interface{}) *Db_MarkPendingDeleteNotified_Call { + return &Db_MarkPendingDeleteNotified_Call{Call: _e.mock.On("MarkPendingDeleteNotified", ctx, subscriptionIDs)} +} + +func (_c *Db_MarkPendingDeleteNotified_Call) Run(run func(ctx context.Context, subscriptionIDs []uuid.UUID)) *Db_MarkPendingDeleteNotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []uuid.UUID + if args[1] != nil { + arg1 = args[1].([]uuid.UUID) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Db_MarkPendingDeleteNotified_Call) Return(err error) *Db_MarkPendingDeleteNotified_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_MarkPendingDeleteNotified_Call) RunAndReturn(run func(ctx context.Context, subscriptionIDs []uuid.UUID) error) *Db_MarkPendingDeleteNotified_Call { + _c.Call.Return(run) + return _c +} + // Migrate provides a mock function for the type Db func (_mock *Db) Migrate() error { ret := _mock.Called() @@ -2611,6 +2730,57 @@ func (_c *Db_ResetNotifiedForActive_Call) RunAndReturn(run func(ctx context.Cont return _c } +// ResetPendingDeleteNotifiedForActive provides a mock function for the type Db +func (_mock *Db) ResetPendingDeleteNotifiedForActive(ctx context.Context) error { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ResetPendingDeleteNotifiedForActive") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_ResetPendingDeleteNotifiedForActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetPendingDeleteNotifiedForActive' +type Db_ResetPendingDeleteNotifiedForActive_Call struct { + *mock.Call +} + +// ResetPendingDeleteNotifiedForActive is a helper method to define mock.On call +// - ctx context.Context +func (_e *Db_Expecter) ResetPendingDeleteNotifiedForActive(ctx interface{}) *Db_ResetPendingDeleteNotifiedForActive_Call { + return &Db_ResetPendingDeleteNotifiedForActive_Call{Call: _e.mock.On("ResetPendingDeleteNotifiedForActive", ctx)} +} + +func (_c *Db_ResetPendingDeleteNotifiedForActive_Call) Run(run func(ctx context.Context)) *Db_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Db_ResetPendingDeleteNotifiedForActive_Call) Return(err error) *Db_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_ResetPendingDeleteNotifiedForActive_Call) RunAndReturn(run func(ctx context.Context) error) *Db_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Return(run) + return _c +} + // SaveCredential provides a mock function for the type Db func (_mock *Db) SaveCredential(ctx context.Context, credential webauthn.Credential, accountID primitive.ObjectID) error { ret := _mock.Called(ctx, credential, accountID) diff --git a/api/mocks/mailer_email.go b/api/mocks/mailer_email.go index 25a79535..5a91b817 100644 --- a/api/mocks/mailer_email.go +++ b/api/mocks/mailer_email.go @@ -163,6 +163,63 @@ func (_c *Maileremail_SendPasswordResetEmail_Call) RunAndReturn(run func(ctx con return _c } +// SendPendingDeleteEmail provides a mock function for the type Maileremail +func (_mock *Maileremail) SendPendingDeleteEmail(ctx context.Context, sendTo string) error { + ret := _mock.Called(ctx, sendTo) + + if len(ret) == 0 { + panic("no return value specified for SendPendingDeleteEmail") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sendTo) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Maileremail_SendPendingDeleteEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendPendingDeleteEmail' +type Maileremail_SendPendingDeleteEmail_Call struct { + *mock.Call +} + +// SendPendingDeleteEmail is a helper method to define mock.On call +// - ctx context.Context +// - sendTo string +func (_e *Maileremail_Expecter) SendPendingDeleteEmail(ctx interface{}, sendTo interface{}) *Maileremail_SendPendingDeleteEmail_Call { + return &Maileremail_SendPendingDeleteEmail_Call{Call: _e.mock.On("SendPendingDeleteEmail", ctx, sendTo)} +} + +func (_c *Maileremail_SendPendingDeleteEmail_Call) Run(run func(ctx context.Context, sendTo string)) *Maileremail_SendPendingDeleteEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Maileremail_SendPendingDeleteEmail_Call) Return(err error) *Maileremail_SendPendingDeleteEmail_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Maileremail_SendPendingDeleteEmail_Call) RunAndReturn(run func(ctx context.Context, sendTo string) error) *Maileremail_SendPendingDeleteEmail_Call { + _c.Call.Return(run) + return _c +} + // SendSubscriptionExpiryEmail provides a mock function for the type Maileremail func (_mock *Maileremail) SendSubscriptionExpiryEmail(ctx context.Context, sendTo string) error { ret := _mock.Called(ctx, sendTo) diff --git a/api/mocks/subscription_provider_middleware.go b/api/mocks/subscription_provider_middleware.go new file mode 100644 index 00000000..b86c024c --- /dev/null +++ b/api/mocks/subscription_provider_middleware.go @@ -0,0 +1,107 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/ivpn/dns/api/model" + mock "github.com/stretchr/testify/mock" +) + +// NewSubscriptionProvidermiddleware creates a new instance of SubscriptionProvidermiddleware. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSubscriptionProvidermiddleware(t interface { + mock.TestingT + Cleanup(func()) +}) *SubscriptionProvidermiddleware { + mock := &SubscriptionProvidermiddleware{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// SubscriptionProvidermiddleware is an autogenerated mock type for the SubscriptionProvider type +type SubscriptionProvidermiddleware struct { + mock.Mock +} + +type SubscriptionProvidermiddleware_Expecter struct { + mock *mock.Mock +} + +func (_m *SubscriptionProvidermiddleware) EXPECT() *SubscriptionProvidermiddleware_Expecter { + return &SubscriptionProvidermiddleware_Expecter{mock: &_m.Mock} +} + +// GetSubscription provides a mock function for the type SubscriptionProvidermiddleware +func (_mock *SubscriptionProvidermiddleware) GetSubscription(ctx context.Context, accountID string) (*model.Subscription, error) { + ret := _mock.Called(ctx, accountID) + + if len(ret) == 0 { + panic("no return value specified for GetSubscription") + } + + var r0 *model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*model.Subscription, error)); ok { + return returnFunc(ctx, accountID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *model.Subscription); ok { + r0 = returnFunc(ctx, accountID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, accountID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionProvidermiddleware_GetSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscription' +type SubscriptionProvidermiddleware_GetSubscription_Call struct { + *mock.Call +} + +// GetSubscription is a helper method to define mock.On call +// - ctx context.Context +// - accountID string +func (_e *SubscriptionProvidermiddleware_Expecter) GetSubscription(ctx interface{}, accountID interface{}) *SubscriptionProvidermiddleware_GetSubscription_Call { + return &SubscriptionProvidermiddleware_GetSubscription_Call{Call: _e.mock.On("GetSubscription", ctx, accountID)} +} + +func (_c *SubscriptionProvidermiddleware_GetSubscription_Call) Run(run func(ctx context.Context, accountID string)) *SubscriptionProvidermiddleware_GetSubscription_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionProvidermiddleware_GetSubscription_Call) Return(subscription *model.Subscription, err error) *SubscriptionProvidermiddleware_GetSubscription_Call { + _c.Call.Return(subscription, err) + return _c +} + +func (_c *SubscriptionProvidermiddleware_GetSubscription_Call) RunAndReturn(run func(ctx context.Context, accountID string) (*model.Subscription, error)) *SubscriptionProvidermiddleware_GetSubscription_Call { + _c.Call.Return(run) + return _c +} diff --git a/api/mocks/subscription_repository.go b/api/mocks/subscription_repository.go index 512ec7b6..3cc5a25c 100644 --- a/api/mocks/subscription_repository.go +++ b/api/mocks/subscription_repository.go @@ -158,6 +158,68 @@ func (_c *SubscriptionRepository_FindExpiredUnnotified_Call) RunAndReturn(run fu return _c } +// FindPendingDeleteUnnotified provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) FindPendingDeleteUnnotified(ctx context.Context) ([]model.Subscription, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindPendingDeleteUnnotified") + } + + var r0 []model.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]model.Subscription, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []model.Subscription); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SubscriptionRepository_FindPendingDeleteUnnotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindPendingDeleteUnnotified' +type SubscriptionRepository_FindPendingDeleteUnnotified_Call struct { + *mock.Call +} + +// FindPendingDeleteUnnotified is a helper method to define mock.On call +// - ctx context.Context +func (_e *SubscriptionRepository_Expecter) FindPendingDeleteUnnotified(ctx interface{}) *SubscriptionRepository_FindPendingDeleteUnnotified_Call { + return &SubscriptionRepository_FindPendingDeleteUnnotified_Call{Call: _e.mock.On("FindPendingDeleteUnnotified", ctx)} +} + +func (_c *SubscriptionRepository_FindPendingDeleteUnnotified_Call) Run(run func(ctx context.Context)) *SubscriptionRepository_FindPendingDeleteUnnotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_FindPendingDeleteUnnotified_Call) Return(subscriptions []model.Subscription, err error) *SubscriptionRepository_FindPendingDeleteUnnotified_Call { + _c.Call.Return(subscriptions, err) + return _c +} + +func (_c *SubscriptionRepository_FindPendingDeleteUnnotified_Call) RunAndReturn(run func(ctx context.Context) ([]model.Subscription, error)) *SubscriptionRepository_FindPendingDeleteUnnotified_Call { + _c.Call.Return(run) + return _c +} + // GetSubscriptionByAccountId provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) GetSubscriptionByAccountId(ctx context.Context, accountId string) (*model.Subscription, error) { ret := _mock.Called(ctx, accountId) @@ -283,6 +345,63 @@ func (_c *SubscriptionRepository_MarkNotified_Call) RunAndReturn(run func(ctx co return _c } +// MarkPendingDeleteNotified provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) MarkPendingDeleteNotified(ctx context.Context, subscriptionIDs []uuid.UUID) error { + ret := _mock.Called(ctx, subscriptionIDs) + + if len(ret) == 0 { + panic("no return value specified for MarkPendingDeleteNotified") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SubscriptionRepository_MarkPendingDeleteNotified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkPendingDeleteNotified' +type SubscriptionRepository_MarkPendingDeleteNotified_Call struct { + *mock.Call +} + +// MarkPendingDeleteNotified is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionIDs []uuid.UUID +func (_e *SubscriptionRepository_Expecter) MarkPendingDeleteNotified(ctx interface{}, subscriptionIDs interface{}) *SubscriptionRepository_MarkPendingDeleteNotified_Call { + return &SubscriptionRepository_MarkPendingDeleteNotified_Call{Call: _e.mock.On("MarkPendingDeleteNotified", ctx, subscriptionIDs)} +} + +func (_c *SubscriptionRepository_MarkPendingDeleteNotified_Call) Run(run func(ctx context.Context, subscriptionIDs []uuid.UUID)) *SubscriptionRepository_MarkPendingDeleteNotified_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []uuid.UUID + if args[1] != nil { + arg1 = args[1].([]uuid.UUID) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_MarkPendingDeleteNotified_Call) Return(err error) *SubscriptionRepository_MarkPendingDeleteNotified_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SubscriptionRepository_MarkPendingDeleteNotified_Call) RunAndReturn(run func(ctx context.Context, subscriptionIDs []uuid.UUID) error) *SubscriptionRepository_MarkPendingDeleteNotified_Call { + _c.Call.Return(run) + return _c +} + // ResetNotifiedForActive provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) ResetNotifiedForActive(ctx context.Context) error { ret := _mock.Called(ctx) @@ -334,6 +453,57 @@ func (_c *SubscriptionRepository_ResetNotifiedForActive_Call) RunAndReturn(run f return _c } +// ResetPendingDeleteNotifiedForActive provides a mock function for the type SubscriptionRepository +func (_mock *SubscriptionRepository) ResetPendingDeleteNotifiedForActive(ctx context.Context) error { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ResetPendingDeleteNotifiedForActive") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResetPendingDeleteNotifiedForActive' +type SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call struct { + *mock.Call +} + +// ResetPendingDeleteNotifiedForActive is a helper method to define mock.On call +// - ctx context.Context +func (_e *SubscriptionRepository_Expecter) ResetPendingDeleteNotifiedForActive(ctx interface{}) *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call { + return &SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call{Call: _e.mock.On("ResetPendingDeleteNotifiedForActive", ctx)} +} + +func (_c *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call) Run(run func(ctx context.Context)) *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call) Return(err error) *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call) RunAndReturn(run func(ctx context.Context) error) *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call { + _c.Call.Return(run) + return _c +} + // Upsert provides a mock function for the type SubscriptionRepository func (_mock *SubscriptionRepository) Upsert(ctx context.Context, subscription model.Subscription) error { ret := _mock.Called(ctx, subscription) diff --git a/api/service/account/reauth_test.go b/api/service/account/reauth_test.go index b3aa85c7..25f15e3e 100644 --- a/api/service/account/reauth_test.go +++ b/api/service/account/reauth_test.go @@ -43,7 +43,8 @@ func (suite *ReauthTokenSuite) SetupSuite() { val := validatorv10.New() // Provide a minimal subscription service dependency required by constructor mockSubRepo := mocks.NewSubscriptionRepository(suite.T()) - subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) + mockProfileRepo := mocks.NewProfileRepository(suite.T()) + subService := subscription.NewSubscriptionService(mockSubRepo, mockProfileRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) // credential repo is not used in these tests; pass nil suite.service = account.NewAccountService(*cfg.Service, suite.mockAccountRepo, nil, nil, subService, nil, suite.mockCache, suite.mockMailer, nil, val, webhookClient.Http{}) } diff --git a/api/service/account/service_test.go b/api/service/account/service_test.go index dad43efd..71635baf 100644 --- a/api/service/account/service_test.go +++ b/api/service/account/service_test.go @@ -90,7 +90,7 @@ func (suite *AccountTestSuite) SetupSuite() { suite.queryLogsService = querylogs.NewQueryLogsService(suite.mockQueryLogsRepo) suite.statisticsService = statistics.NewStatisticsService(suite.mockStatsRepo) suite.mockSubscriptionRepo = mocks.NewSubscriptionRepository(suite.T()) - suite.subscriptionService = subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{}, webhookClient.Http{}) + suite.subscriptionService = subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockProfileRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{}, webhookClient.Http{}) // Create the profile service with mocks suite.profileService = profile.NewProfileService( @@ -151,7 +151,7 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { // Re-create subscription service and account service with the httptest server URL httpClient := webhookClient.Http{Cfg: config.APIConfig{PreauthURL: preauthServer.URL}} - subSvc := subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{PreauthURL: preauthServer.URL}, httpClient) + subSvc := subscription.NewSubscriptionService(suite.mockSubscriptionRepo, suite.mockProfileRepo, suite.mockCache, suite.serviceConfig, config.APIConfig{PreauthURL: preauthServer.URL}, httpClient) suite.service = account.NewAccountService( suite.serviceConfig, diff --git a/api/service/account/verify_test.go b/api/service/account/verify_test.go index 737b38c9..37f21ca9 100644 --- a/api/service/account/verify_test.go +++ b/api/service/account/verify_test.go @@ -47,7 +47,8 @@ func (suite *EmailVerificationOTPSuite) SetupSuite() { // Minimal dependencies; other repos not needed for OTP operations val := validatorv10.New() mockSubRepo := mocks.NewSubscriptionRepository(suite.T()) - subService := subscription.NewSubscriptionService(mockSubRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) + mockProfileRepo := mocks.NewProfileRepository(suite.T()) + subService := subscription.NewSubscriptionService(mockSubRepo, mockProfileRepo, suite.mockCache, config.ServiceConfig{}, config.APIConfig{}, webhookClient.Http{}) suite.service = account.NewAccountService( *cfg.Service, suite.mockAccountRepo, From c16c94b09c63799948cb2bcbe847719c74f90bb7 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 16:05:20 +0200 Subject: [PATCH 32/54] feat(app): subscription guard infrastructure Signed-off-by: Maciek --- app/src/App.tsx | 28 ++++++++++++++++++++++ app/src/components/LimitedAccessBanner.tsx | 25 +++++++++++++++++++ app/src/hooks/useSubscriptionGuard.ts | 11 +++++++++ app/src/store/general.ts | 4 ++++ 4 files changed, 68 insertions(+) create mode 100644 app/src/components/LimitedAccessBanner.tsx create mode 100644 app/src/hooks/useSubscriptionGuard.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 47aafbc3..f8a6c8a8 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -34,6 +34,7 @@ import api from "@/api/api"; import type { ModelAccount, ModelProfile } from "@/api/client/api"; import { AUTH_KEY } from "@/lib/consts" import { useAppStore } from "@/store/general" +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard" import { Toaster } from "@/components/ui/sonner" import { ApiErrorBoundary } from "@/components/errors/ApiErrorBoundary"; import { RouterErrorBoundary } from "@/components/errors/RouterErrorBoundary"; @@ -106,6 +107,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children useAppStore.getState().setAccount(null); useAppStore.getState().setProfiles([]); useAppStore.getState().setActiveProfile(null); + useAppStore.getState().setSubscriptionStatus(null); }; const logout = (toastMessage?: string, toastType: 'success' | 'info' | 'error' | 'warning' = 'success') => { @@ -277,6 +279,31 @@ function BaseLayout({ children, mode }: { children: React.ReactNode, mode: 'publ const AppLayout = ({ children }: { children: React.ReactNode }) => {children}; const PublicLayout = ({ children }: { children: React.ReactNode }) => {children}; +// Route guard: redirect all protected routes to /account-preferences when pending_delete. +// Fetches subscription status on mount if not yet in the store (e.g. after fresh login). +function PendingDeleteGuard() { + const { isPendingDelete } = useSubscriptionGuard(); + const subscriptionStatus = useAppStore(s => s.subscriptionStatus); + const setSubscriptionStatus = useAppStore(s => s.setSubscriptionStatus); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (subscriptionStatus !== null) return; + api.Client.subscriptionApi.apiV1SubGet() + .then(res => setSubscriptionStatus(res.data.status ?? null)) + .catch(() => {}); // no subscription = no restriction + }, [subscriptionStatus, setSubscriptionStatus]); + + useEffect(() => { + if (isPendingDelete && location.pathname !== '/account-preferences') { + navigate('/account-preferences', { replace: true }); + } + }, [isPendingDelete, location.pathname, navigate]); + + return null; +} + // Layout for protected routes function ProtectedLayout() { const { isAuthenticated } = useAuth(); @@ -371,6 +398,7 @@ function ProtectedLayout() { return ( <> + {navDesktop &&
} {isDesktop && connectionStatusVisible && ( diff --git a/app/src/components/LimitedAccessBanner.tsx b/app/src/components/LimitedAccessBanner.tsx new file mode 100644 index 00000000..a0748e6b --- /dev/null +++ b/app/src/components/LimitedAccessBanner.tsx @@ -0,0 +1,25 @@ +import { Info } from "lucide-react"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; + +const RESYNC_URL = import.meta.env.VITE_RESYNC_URL || "https://www.ivpn.net/en/account/"; + +export default function LimitedAccessBanner() { + const { isLimited } = useSubscriptionGuard(); + + if (!isLimited) return null; + + return ( +
+ +
+

+ Limited Access Mode +

+

+ Your modDNS account is in limited access mode. To regain full access add time to your{" "} + IVPN account. +

+
+
+ ); +} diff --git a/app/src/hooks/useSubscriptionGuard.ts b/app/src/hooks/useSubscriptionGuard.ts new file mode 100644 index 00000000..c1f65970 --- /dev/null +++ b/app/src/hooks/useSubscriptionGuard.ts @@ -0,0 +1,11 @@ +import { useAppStore } from "@/store/general"; + +export function useSubscriptionGuard() { + const status = useAppStore(s => s.subscriptionStatus); + return { + isLimited: status === 'limited_access', + isPendingDelete: status === 'pending_delete', + isRestricted: status === 'limited_access' || status === 'pending_delete', + canMutate: status === 'active' || status === 'grace_period' || status === null, + }; +} diff --git a/app/src/store/general.ts b/app/src/store/general.ts index 6933cf75..668ada20 100644 --- a/app/src/store/general.ts +++ b/app/src/store/general.ts @@ -23,6 +23,8 @@ interface AppState { setCustomRulesAlertDismissed: (dismissed: boolean) => void; passkeys: ModelCredential[]; setPasskeys: (passkeys: ModelCredential[]) => void; + subscriptionStatus: string | null; + setSubscriptionStatus: (status: string | null) => void; } export const useAppStore = create()( @@ -63,6 +65,8 @@ export const useAppStore = create()( setCustomRulesAlertDismissed: (dismissed) => set({ customRulesAlertDismissed: dismissed }), passkeys: [], setPasskeys: (passkeys) => set({ passkeys }), + subscriptionStatus: null, + setSubscriptionStatus: (status) => set({ subscriptionStatus: status }), }), { name: "moddns-storage", From 1f2e11430134decc82d33e65c6cb273ca3f969d2 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 16:09:54 +0200 Subject: [PATCH 33/54] feat(app): gate mutations across pages in Limited Access states Signed-off-by: Maciek --- app/src/components/AccountSubscription.tsx | 28 +++------ app/src/pages/account_preferences/Account.tsx | 24 +++++++- .../blocklists/CategoriesContentSection.tsx | 9 ++- .../pages/blocklists/MainContentSection.tsx | 25 +++++--- .../blocklists/ServicesContentSection.tsx | 4 +- .../pages/custom_rules/MainContentSection.tsx | 19 ++++-- app/src/pages/header/ProfileDropdown.tsx | 12 ++-- app/src/pages/home/HomeScreen.tsx | 2 + app/src/pages/logs/Logs.tsx | 9 ++- app/src/pages/logs/LogsNotActive.tsx | 32 ++++++---- app/src/pages/logs/QueryLogCard.tsx | 15 +++-- .../mobileconfig/MobileconfigGenerator.tsx | 50 ++++++++++------ .../settings/ProfileManagementSection.tsx | 60 +++++++++++-------- app/src/pages/settings/QueryLogsSection.tsx | 9 ++- app/src/pages/setup/Setup.tsx | 4 ++ 15 files changed, 197 insertions(+), 105 deletions(-) diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx index 2adf0d92..a4fc8211 100644 --- a/app/src/components/AccountSubscription.tsx +++ b/app/src/components/AccountSubscription.tsx @@ -6,6 +6,7 @@ import { Card, CardContent } from "@/components/ui/card"; import StatusBadge from "@/components/general/StatusBadge"; import api from "@/api/api"; import type { ModelSubscription } from "@/api/client/api"; +import { useAppStore } from "@/store/general"; const RESYNC_URL = import.meta.env.VITE_RESYNC_URL || "https://www.ivpn.net/en/account/"; @@ -14,6 +15,7 @@ export default function AccountSubscription() { const [error, setError] = useState(""); const [syncing, setSyncing] = useState(false); const [searchParams] = useSearchParams(); + const setSubscriptionStatus = useAppStore(s => s.setSubscriptionStatus); const sessionid = searchParams.get("sessionid") || ""; @@ -21,6 +23,7 @@ export default function AccountSubscription() { try { const res = await api.Client.subscriptionApi.apiV1SubGet(); setSub(res.data); + setSubscriptionStatus(res.data.status ?? null); } catch { setError("Failed to load subscription."); } @@ -88,8 +91,8 @@ export default function AccountSubscription() { {hasAlerts && (
{isLimited && ( -
- +
+

Limited Access Mode @@ -102,31 +105,16 @@ export default function AccountSubscription() {

)} - {isPendingDelete && ( -
- -
-

- Pending Deletion -

-

- Your account is pending deletion. To reinstate access add time to your{" "} - IVPN account. -

-
-
- )} - {sub.outage && ( -
- +
+

Out of sync

Your last account status update was {sub.updated_at ? formatDate(sub.updated_at) : "unknown"}.{" "} - Sync with IVPN + Sync with IVPN

diff --git a/app/src/pages/account_preferences/Account.tsx b/app/src/pages/account_preferences/Account.tsx index 972a556b..35e027b1 100644 --- a/app/src/pages/account_preferences/Account.tsx +++ b/app/src/pages/account_preferences/Account.tsx @@ -1,4 +1,5 @@ import { + AlertTriangle, LogOutIcon, ShieldCheck, ShieldX, @@ -25,6 +26,7 @@ import VerifyEmailDialog from "@/pages/account_preferences/VerifyEmailDialog"; import ChangeEmailDialog from "@/pages/account_preferences/ChangeEmailDialog"; import { useAppStore } from "@/store/general"; import AccountSubscription from "@/components/AccountSubscription"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; interface PreferencesSectionProps { account: ModelAccount | null; @@ -52,10 +54,13 @@ interface SectionDef { items: SectionItem[]; } +const RESYNC_URL = import.meta.env.VITE_RESYNC_URL || "https://www.ivpn.net/en/account/"; + const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element => { // Local account state to allow refresh post-verification const [currentAccount, setCurrentAccount] = useState(account); const setAccount = useAppStore(s => s.setAccount); + const { isPendingDelete } = useSubscriptionGuard(); // State for error reports consent const [errorReportsConsent, setErrorReportsConsent] = useState( @@ -221,6 +226,21 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element =
+ {isPendingDelete && ( +
+ +
+

+ Your account is pending deletion. +

+

+ To reinstate access add time to your{" "} + IVPN account. +

+
+
+ )} + {/* Alerts + Account Info + Subscription Cards */}
@@ -229,7 +249,8 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element =
- {/* Sections */} + {/* Sections - disabled during pending_delete */} +
{sections.map((section, sectionIndex) => ( @@ -343,6 +364,7 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element = {/* Passkey Management Section */} +
{/* Delete Account Section */} diff --git a/app/src/pages/blocklists/CategoriesContentSection.tsx b/app/src/pages/blocklists/CategoriesContentSection.tsx index 0baf6a92..b95c3df1 100644 --- a/app/src/pages/blocklists/CategoriesContentSection.tsx +++ b/app/src/pages/blocklists/CategoriesContentSection.tsx @@ -139,6 +139,7 @@ interface CategoriesContentSectionProps { onCategoryToggle: (blocklistIds: string[], enable: boolean) => void; updating: string | null; loading: boolean; + restricted?: boolean; } interface PreparedCategory { @@ -159,6 +160,7 @@ export default function CategoriesContentSection({ onCategoryToggle, updating, loading, + restricted = false, }: CategoriesContentSectionProps): JSX.Element { const [expandedCategory, setExpandedCategory] = useState(null); @@ -270,6 +272,7 @@ export default function CategoriesContentSection({ setExpandedCategory(expandedCategory === key ? null : key) } onBlocklistToggle={onToggle} + restricted={restricted} /> )} @@ -320,6 +323,7 @@ interface CategoriesGridProps { onCategoryToggle: (key: string) => void; onExpandToggle: (key: string) => void; onBlocklistToggle: (id: string, checked: boolean) => void; + restricted?: boolean; } function CategoriesGrid({ @@ -331,6 +335,7 @@ function CategoriesGrid({ onCategoryToggle, onExpandToggle, onBlocklistToggle, + restricted = false, }: CategoriesGridProps) { return (
@@ -347,7 +352,7 @@ function CategoriesGrid({ totalEntries={cat.totalEntries} lastUpdated={formatUpdatedRelative(cat.mostRecent)} onToggle={() => onCategoryToggle(cat.key)} - toggleDisabled={updating !== null} + toggleDisabled={updating !== null || restricted} expanded={isExpanded} onExpandToggle={() => onExpandToggle(cat.key)} /> @@ -370,7 +375,7 @@ function CategoriesGrid({ updated={formatUpdatedRelative(bl.last_modified)} onSwitchChange={(checked) => onBlocklistToggle(bl.blocklist_id, checked)} switchChecked={isEnabled} - switchDisabled={updating === bl.blocklist_id} + switchDisabled={updating === bl.blocklist_id || restricted} homepage={bl.homepage} /> ); diff --git a/app/src/pages/blocklists/MainContentSection.tsx b/app/src/pages/blocklists/MainContentSection.tsx index 6881b610..c07e7499 100644 --- a/app/src/pages/blocklists/MainContentSection.tsx +++ b/app/src/pages/blocklists/MainContentSection.tsx @@ -26,6 +26,8 @@ import { } from "@/api/client/api"; import api from "@/api/api"; import { useAppStore } from "@/store/general"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; +import LimitedAccessBanner from "@/components/LimitedAccessBanner"; import { formatDistanceToNow, parseISO } from "date-fns"; import { toast } from "sonner"; import axios from "axios"; @@ -79,6 +81,7 @@ export const formatUpdatedRelative = (isoDate?: string): string => { }; export default function MainContentSection(): JSX.Element { + const { isRestricted } = useSubscriptionGuard(); const [activeTab, setActiveTab] = useState("blocklists"); const blocklistsAlertDismissed = useAppStore((state) => state.blocklistsAlertDismissed); const setBlocklistsAlertDismissed = useAppStore((state) => state.setBlocklistsAlertDismissed); @@ -303,7 +306,9 @@ export default function MainContentSection(): JSX.Element { return (
- + +
+
@@ -393,10 +398,10 @@ export default function MainContentSection(): JSX.Element { aria-label="Enable listed blocklists" variant="outline" size="icon" - className={`w-11 h-11 min-h-11 !bg-[var(--shadcn-ui-app-background)] border-[var(--tailwind-colors-slate-700)] ${enableListedActive ? "opacity-100" : "opacity-50"}`} - disabled={!enableListedActive || updating === "all"} + className={`w-11 h-11 min-h-11 !bg-[var(--shadcn-ui-app-background)] border-[var(--tailwind-colors-slate-700)] ${enableListedActive && !isRestricted ? "opacity-100" : "opacity-50"}`} + disabled={!enableListedActive || updating === "all" || isRestricted} onClick={handleEnableListed} - title="Enable currently listed blocklists" + title={isRestricted ? "Feature unavailable in limited access mode" : "Enable currently listed blocklists"} > @@ -464,10 +469,10 @@ export default function MainContentSection(): JSX.Element { aria-label="Enable listed blocklists" variant="outline" size="icon" - className={`w-11 h-11 md:h-11 lg:h-9 min-h-11 md:min-h-11 lg:min-h-0 !bg-[var(--shadcn-ui-app-background)] border-[var(--tailwind-colors-slate-700)] ${enableListedActive ? "opacity-100" : "opacity-50"}`} - disabled={!enableListedActive || updating === "all"} + className={`w-11 h-11 md:h-11 lg:h-9 min-h-11 md:min-h-11 lg:min-h-0 !bg-[var(--shadcn-ui-app-background)] border-[var(--tailwind-colors-slate-700)] ${enableListedActive && !isRestricted ? "opacity-100" : "opacity-50"}`} + disabled={!enableListedActive || updating === "all" || isRestricted} onClick={handleEnableListed} - title="Enable currently listed blocklists" + title={isRestricted ? "Feature unavailable in limited access mode" : "Enable currently listed blocklists"} > @@ -519,7 +524,7 @@ export default function MainContentSection(): JSX.Element { updated={formatUpdatedRelative(blocklist.last_modified)} onSwitchChange={(checked) => handleBlocklistSwitch(blocklistId, checked)} switchChecked={isEnabled} - switchDisabled={updating === blocklistId} + switchDisabled={updating === blocklistId || isRestricted} homepage={blocklist.homepage} /> ); @@ -532,7 +537,7 @@ export default function MainContentSection(): JSX.Element { - {activeTab === "services" ? : null} + {activeTab === "services" ? : null} @@ -544,10 +549,12 @@ export default function MainContentSection(): JSX.Element { onCategoryToggle={handleCategoryToggle} updating={updating} loading={loading} + restricted={isRestricted} /> ) : null} +
); } \ No newline at end of file diff --git a/app/src/pages/blocklists/ServicesContentSection.tsx b/app/src/pages/blocklists/ServicesContentSection.tsx index f6dc4062..91c78847 100644 --- a/app/src/pages/blocklists/ServicesContentSection.tsx +++ b/app/src/pages/blocklists/ServicesContentSection.tsx @@ -28,7 +28,7 @@ function formatASNsTitle(asns?: Array): string { type StatusFilter = "all" | "blocked" | "unblocked"; -export default function ServicesContentSection(): JSX.Element { +export default function ServicesContentSection({ restricted = false }: { restricted?: boolean }): JSX.Element { const activeProfile = useAppStore((state) => state.activeProfile); const setActiveProfile = useAppStore((state) => state.setActiveProfile); const { theme } = useTheme(); @@ -216,7 +216,7 @@ export default function ServicesContentSection(): JSX.Element { logoSrc={logoSrc} onSwitchChange={(checked) => handleServiceSwitch(id, checked)} switchChecked={isBlocked} - switchDisabled={updating === id || !id} + switchDisabled={updating === id || !id || restricted} /> ); }) diff --git a/app/src/pages/custom_rules/MainContentSection.tsx b/app/src/pages/custom_rules/MainContentSection.tsx index 263a0fe0..56bcd32f 100644 --- a/app/src/pages/custom_rules/MainContentSection.tsx +++ b/app/src/pages/custom_rules/MainContentSection.tsx @@ -6,6 +6,8 @@ import AlertCard from "@/components/general/AlertCard"; import { useNavigate } from "react-router-dom"; import CustomRulesSearch from "@/pages/custom_rules/Search"; import { useAppStore } from "@/store/general"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; +import LimitedAccessBanner from "@/components/LimitedAccessBanner"; import api from "@/api/api"; import { toast } from "sonner"; import type { ModelAccount, ModelCustomRule, ModelProfile, ResponsesCustomRuleBatchSkipped } from "@/api/client/api"; @@ -51,6 +53,7 @@ interface MainContentSectionProps { } export default function MainContentSection({ profiles = [] }: Omit): JSX.Element { + const { isRestricted } = useSubscriptionGuard(); const customRulesAlertDismissed = useAppStore((state) => state.customRulesAlertDismissed); const setCustomRulesAlertDismissed = useAppStore((state) => state.setCustomRulesAlertDismissed); const [showSearch, setShowSearch] = useState(false); @@ -257,7 +260,7 @@ export default function MainContentSection({ profiles = [] }: Omit - +
+ {/* TabsList stays interactive in LA so the user can switch tabs to view their existing rules in either list. */}
+ {/* Mutations (composer / delete / bulk delete) are blocked in LA — gate everything below the tab strip. */} +
+
{/* Page Description */}

@@ -327,7 +334,7 @@ export default function MainContentSection({ profiles = [] }: Omit updateComposerTokens(activeTab, next)} onSubmit={() => handleComposerSubmit(activeTab)} - loading={loading || !activeProfile?.profile_id} + loading={loading || !activeProfile?.profile_id || isRestricted} className="flex-1 min-w-0" />

+
diff --git a/app/src/pages/header/ProfileDropdown.tsx b/app/src/pages/header/ProfileDropdown.tsx index 41ab9a94..f49df1dc 100644 --- a/app/src/pages/header/ProfileDropdown.tsx +++ b/app/src/pages/header/ProfileDropdown.tsx @@ -7,6 +7,7 @@ import { DropdownMenuSeparator } from "@radix-ui/react-dropdown-menu"; import { Check, Plus, Settings } from "lucide-react"; import api from "@/api/api"; import { toast } from "sonner"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; interface ProfileDropdownProps { profiles: ModelProfile[]; @@ -23,6 +24,7 @@ export default function ProfileDropdown({ setProfiles, className = "", }: ProfileDropdownProps) { + const { isRestricted } = useSubscriptionGuard(); const [showCreateDialog, setShowCreateDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false); const [editProfile, setEditProfile] = useState(null); @@ -134,7 +136,7 @@ export default function ProfileDropdown({ return truncated ? {display} : profile.name; })()}
- {isSelected && ( + {isSelected && !isRestricted && (
{ + if (isRestricted) return; setSelectOpen(false); setShowCreateDialog(true); }} + title={isRestricted ? "Feature unavailable in limited access mode" : undefined} > -
+
Create profile
diff --git a/app/src/pages/home/HomeScreen.tsx b/app/src/pages/home/HomeScreen.tsx index 56af2d51..c183f11d 100644 --- a/app/src/pages/home/HomeScreen.tsx +++ b/app/src/pages/home/HomeScreen.tsx @@ -1,9 +1,11 @@ import Home from "@/pages/home/Home"; +import LimitedAccessBanner from "@/components/LimitedAccessBanner"; import type { JSX } from "react"; export default function HomeScreen(): JSX.Element { return (
+
); diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 3bb9f5ad..3273b5b4 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -15,6 +15,8 @@ import api from "@/api/api"; import { useAppStore } from "@/store/general"; import { Skeleton } from "@/components/ui/skeleton"; import { useScreenDetector } from "@/hooks/useScreenDetector"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; +import LimitedAccessBanner from "@/components/LimitedAccessBanner"; const QUERY_LIMIT = 25; @@ -24,6 +26,7 @@ interface QueryLogsProps { } const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { + const { isRestricted } = useSubscriptionGuard(); const [logs, setLogs] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); @@ -95,10 +98,11 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const handleOpenQuickRule = useCallback((domain?: string, defaultAction: QuickRuleAction = "denylist") => { if (!domain) return; + if (isRestricted) return; // POST custom_rules is blocked in Limited Access / Pending Delete setQuickRuleDomain(domain); setQuickRuleDefaultAction(defaultAction); setIsQuickRuleSheetOpen(true); - }, []); + }, [isRestricted]); const handleQuickRuleSheetChange = useCallback((nextOpen: boolean) => { setIsQuickRuleSheetOpen(nextOpen); @@ -323,6 +327,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { return (
+ + {/* GET /profiles/{id}/logs and DELETE /profiles/{id}/logs are LA-allowed; only the per-row Quick rule action (POST custom_rules) is gated below. */}
{/* Page Description */}
@@ -403,6 +409,7 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { isLast={isLast} lastLogRef={isLast ? lastLogRef : undefined} onQuickRule={handleOpenQuickRule} + quickRuleRestricted={isRestricted} /> ); })} diff --git a/app/src/pages/logs/LogsNotActive.tsx b/app/src/pages/logs/LogsNotActive.tsx index a5fb3408..dadba471 100644 --- a/app/src/pages/logs/LogsNotActive.tsx +++ b/app/src/pages/logs/LogsNotActive.tsx @@ -7,13 +7,16 @@ import api from "@/api/api"; import { toast } from "sonner"; import type { ModelProfile } from "@/api/client"; import { useAppStore } from "@/store/general"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; export const Frame = ({ profile }: { profile: ModelProfile }): JSX.Element => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); const { setActiveProfile } = useAppStore(); + const { isRestricted } = useSubscriptionGuard(); const handleEnableLogs = async () => { + if (isRestricted) return; // PATCH /profiles/{id} is blocked in Limited Access / Pending Delete setLoading(true); try { const response = await api.Client.profilesApi.apiV1ProfilesIdPatch(profile.profile_id, { @@ -74,19 +77,24 @@ export const Frame = ({ profile }: { profile: ModelProfile }): JSX.Element => {

- {/* Action button */} - + +
void; onQuickRule?: (domain?: string, defaultAction?: "denylist" | "allowlist") => void; + quickRuleRestricted?: boolean; } -const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule }: QueryLogCardProps): JSX.Element | null => { +const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted }: QueryLogCardProps): JSX.Element | null => { // If domain logging is disabled, dns_request.domain may be absent. Provide a placeholder. const rawDomain = log.dns_request?.domain; const normalizedDomain = rawDomain ? rawDomain.replace(/\.$/, "") : undefined; @@ -23,9 +24,12 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule }: QueryLogCardProp const quickRuleAvailable = Boolean(normalizedDomain); const isBlocked = log.status === "blocked"; const isProcessed = log.status === "processed"; - const quickRuleTooltip = quickRuleAvailable ? "Create a custom rule" : "Domain unavailable"; + const quickRuleDisabled = !quickRuleAvailable || quickRuleRestricted; + const quickRuleTooltip = quickRuleRestricted + ? "Feature unavailable in limited access mode" + : quickRuleAvailable ? "Create a custom rule" : "Domain unavailable"; const handleQuickRule = () => { - if (!quickRuleAvailable) return; + if (quickRuleDisabled) return; const defaultAction = isBlocked ? "allowlist" : "denylist"; onQuickRule?.(normalizedDomain, defaultAction); }; @@ -37,14 +41,15 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule }: QueryLogCardProp const renderQuickRuleButton = (wrapperClassName: string) => (
- + {/* span hosts cursor-not-allowed because the disabled button itself doesn't receive pointer events */} + + +
@@ -455,16 +464,21 @@ export default function MobileconfigGenerator(): JSX.Element {
- + +
diff --git a/app/src/pages/settings/ProfileManagementSection.tsx b/app/src/pages/settings/ProfileManagementSection.tsx index 1e594fe7..41092db1 100644 --- a/app/src/pages/settings/ProfileManagementSection.tsx +++ b/app/src/pages/settings/ProfileManagementSection.tsx @@ -3,6 +3,8 @@ import { type JSX, useState, useEffect } from "react"; import type { ModelProfile } from "@/api/client/api"; import { ModelProfileUpdateOperationEnum, ModelProfileUpdatePathEnum } from "@/api/client/api"; import { useAppStore } from "@/store/general"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; +import LimitedAccessBanner from "@/components/LimitedAccessBanner"; import { toast } from "sonner"; import DeleteProfileDialog from "@/pages/settings/DeleteProfileDialog"; import QueryLogsSection from "./QueryLogsSection"; @@ -16,6 +18,7 @@ interface ProfileManagementSectionProps { } export default function ProfileManagementSection({ profiles }: ProfileManagementSectionProps): JSX.Element { + const { isRestricted } = useSubscriptionGuard(); // Get active profile from store const activeProfile = useAppStore((state) => state.activeProfile); const setActiveProfile = useAppStore((state) => state.setActiveProfile); @@ -381,37 +384,45 @@ export default function ProfileManagementSection({ profiles }: ProfileManagement }, [activeProfile, profiles]); return ( + <> +
- {/* BLOCKLISTS Section */} - - - {/* CUSTOM RULES Section */} - - - {/* LOGS Section */} + {/* BLOCKLISTS + CUSTOM RULES — mutations blocked in LA */} +
+
+ + + +
+
+ + {/* LOGS Section — gates toggles internally; Download/Clear buttons stay active in LA */} - {/* ADVANCED SETTINGS Section */} - - - {/* Delete Profile Section */} - setShowDeleteDialog(true)} /> + {/* ADVANCED + DELETE — mutations blocked in LA */} +
+
+ + + setShowDeleteDialog(true)} /> +
+
{/* Delete Profile Dialog */} {showDeleteDialog && ( @@ -425,5 +436,6 @@ export default function ProfileManagementSection({ profiles }: ProfileManagement /> )}
+ ); } \ No newline at end of file diff --git a/app/src/pages/settings/QueryLogsSection.tsx b/app/src/pages/settings/QueryLogsSection.tsx index 06482a0b..eff8e321 100644 --- a/app/src/pages/settings/QueryLogsSection.tsx +++ b/app/src/pages/settings/QueryLogsSection.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import api from "@/api/api"; import { Tooltip } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; import { Dialog, DialogContent, @@ -36,6 +37,7 @@ const QueryLogsSection: React.FC = ({ }) => { const [showClearDialog, setShowClearDialog] = useState(false); const [clearLoading, setClearLoading] = useState(false); + const { isRestricted } = useSubscriptionGuard(); const handleClearLogs = async () => { setClearLoading(true); @@ -62,6 +64,9 @@ const QueryLogsSection: React.FC = ({
+ {/* Toggle group — PATCH /profiles/{id} blocked in LA, so gate visually */} +
+
Query logs
@@ -138,7 +143,9 @@ const QueryLogsSection: React.FC = ({ />
))} - +
+
+ {/* Action buttons — Download (GET) and Clear (DELETE on logs) are LA-allowed at API level */}
); From 4a09d921be574c9ebeb39f04bda7d90c0bcf2d57 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 13:32:45 +0200 Subject: [PATCH 34/54] fix(app): Bring back proper vertical spacing on custom rules page Signed-off-by: Maciek --- app/src/pages/custom_rules/MainContentSection.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/pages/custom_rules/MainContentSection.tsx b/app/src/pages/custom_rules/MainContentSection.tsx index 56bcd32f..3b6e73f8 100644 --- a/app/src/pages/custom_rules/MainContentSection.tsx +++ b/app/src/pages/custom_rules/MainContentSection.tsx @@ -270,7 +270,7 @@ export default function MainContentSection({ profiles = [] }: Omit {/* TabsList stays interactive in LA so the user can switch tabs to view their existing rules in either list. */}
@@ -291,8 +291,10 @@ export default function MainContentSection({ profiles = [] }: Omit {/* Mutations (composer / delete / bulk delete) are blocked in LA — gate everything below the tab strip. */} -
-
+ {/* Inner uses `flex flex-col gap-2` because shadcn Tabs applies the same on its children — wrapping the children + in plain divs would otherwise drop the inter-section spacing develop relies on. */} +
+
{/* Page Description */}

From 4acc0944917c7a907d6fc10efe96001377a97477 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 13:35:04 +0200 Subject: [PATCH 35/54] chore(app): Remove banner from login page Signed-off-by: Maciek --- app/src/pages/auth/Login.tsx | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/app/src/pages/auth/Login.tsx b/app/src/pages/auth/Login.tsx index 8ce34fc0..d86a784e 100644 --- a/app/src/pages/auth/Login.tsx +++ b/app/src/pages/auth/Login.tsx @@ -3,19 +3,11 @@ import { useNavigate, useLocation } from "react-router-dom"; import api from "@/api/api"; import LoginCard from "@/pages/auth/LoginCard"; import AuthFooter from "@/components/auth/AuthFooter"; -import { Alert } from "@/components/ui/alert"; -import { Info } from "lucide-react"; import { authToasts } from "@/lib/authToasts"; import { AuthContext } from "@/App"; import { authenticateWithPasskey, isWebAuthnSupported } from "@/lib/webauthn"; import SessionLimitDialog from "@/components/dialogs/SessionLimitDialog"; -const infoAlertData = { - title: "Here to try modDNS? You need an active IVPN account.", - linkUrl: "https://ivpn.net/account/", - linkText: "ivpn.net", -}; - export default function Login() { const location = useLocation(); const navigate = useNavigate(); @@ -251,29 +243,6 @@ export default function Login() { showOtp={showOtp} initialPasskeyMode={webAuthnSupported} /> - - {/* Info alert */} - - -

-

- Here to try modDNS? You need an active IVPN account. -

-
- Sign up or log in on{" "} - - {infoAlertData.linkText} - {" "} - and look for "ModDNS" in your account settings. -
-
-
From d20f86b7d90ba88db99213beca7043fc2a296ed6 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 13:53:08 +0200 Subject: [PATCH 36/54] chore(app): Remove Last synced field from Subscription card Signed-off-by: Maciek --- app/src/components/AccountSubscription.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx index a4fc8211..fc041d8d 100644 --- a/app/src/components/AccountSubscription.tsx +++ b/app/src/components/AccountSubscription.tsx @@ -81,10 +81,6 @@ export default function AccountSubscription() { { label: "Active until", value: formatDate(sub.active_until) }, ]; - if (sub.updated_at) { - rows.push({ label: "Last synced", value: formatDate(sub.updated_at) }); - } - return ( <> {/* Alerts — rendered first, meant to be placed above the cards by parent */} @@ -135,7 +131,10 @@ export default function AccountSubscription() {
-
+ {/* min-h preserves the card height it had when a third "Last synced" row was rendered: + mobile rows stack (label+value, ~44px each) → 3*44 + 2*12 ≈ 156px; + desktop rows are single-line (~20px each) → 3*20 + 2*12 ≈ 84px. */} +
{rows.map((item, index) => (
Date: Tue, 5 May 2026 14:36:01 +0200 Subject: [PATCH 37/54] chore(ci): Temporarily disable goconst linter Signed-off-by: Maciek --- .github/golangci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/golangci.yml b/.github/golangci.yml index bedbbe77..d4caa5bf 100644 --- a/.github/golangci.yml +++ b/.github/golangci.yml @@ -3,7 +3,7 @@ linters: default: none enable: - errcheck - - goconst + # - goconst # temporarily disabled — golangci-lint v1.12.1 surfaced ~50 new findings; tracked for cleanup in a separate PR. - gocritic - gocyclo - gosec From 78a1bea68c312b200c3b76ed40272daec9100d0f Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 14:36:53 +0200 Subject: [PATCH 38/54] feat(api): Update subscription expiration logic Signed-off-by: Maciek --- api/model/subscription.go | 47 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/api/model/subscription.go b/api/model/subscription.go index ac84097a..20554d6d 100644 --- a/api/model/subscription.go +++ b/api/model/subscription.go @@ -23,13 +23,13 @@ const Tier1 = "Tier 1" // Subscription represents a subscription with its properties type Subscription struct { // ID is the primary key (UUIDv4) stored in Mongo _id - ID uuid.UUID `json:"-" bson:"_id"` - AccountID primitive.ObjectID `json:"-" bson:"account_id"` - ActiveUntil time.Time `json:"active_until" bson:"active_until"` - IsActive bool `json:"-" bson:"is_active"` - Tier string `json:"tier,omitempty" bson:"tier,omitempty"` - TokenHash string `json:"-" bson:"token_hash,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` + ID uuid.UUID `json:"-" bson:"_id"` + AccountID primitive.ObjectID `json:"-" bson:"account_id"` + ActiveUntil time.Time `json:"active_until" bson:"active_until"` + IsActive bool `json:"-" bson:"is_active"` + Tier string `json:"tier,omitempty" bson:"tier,omitempty"` + TokenHash string `json:"-" bson:"token_hash,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` Notified bool `json:"-" bson:"notified"` NotifiedPendingDelete bool `json:"-" bson:"notified_pending_delete"` Limits SubscriptionLimits `json:"-" bson:"limits"` @@ -39,52 +39,50 @@ type Subscription struct { Outage bool `json:"outage" bson:"-"` } -// Active returns true when the subscription is valid: not expired, not Tier1, and no outage. func (s *Subscription) Active() bool { return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) && !s.IsOutage() } -// GracePeriod returns true during a sync outage when both 3-day grace windows still hold. func (s *Subscription) GracePeriod() bool { return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3) } -// LimitedAccess returns true when at least one 14-day grace period is still active. func (s *Subscription) LimitedAccess() bool { - return s.GracePeriodDays(14) || s.OutageGracePeriodDays(14) + return s.GracePeriodDays(14) || (s.OutageGracePeriodDays(14) && s.IsOutage()) } -// PendingDelete returns true when both 14-day grace periods have been exceeded. func (s *Subscription) PendingDelete() bool { - return !s.GracePeriodDays(14) || !s.OutageGracePeriodDays(14) + if s.UpdatedAt.AddDate(0, 0, 14).Before(time.Now()) { + return true + } + + if s.ActiveUntil.AddDate(0, 0, 14).Before(time.Now()) { + return true + } + + return false } -// ActiveStatus returns true when the subscription permits normal operations (Active or GracePeriod). func (s *Subscription) ActiveStatus() bool { return s.Active() || s.GracePeriod() } -// IsOutage returns true when the subscription hasn't been updated in over 48 hours. -// Returns false for zero UpdatedAt (never-synced pre-ZLA accounts) to avoid -// incorrectly degrading paid subscriptions that haven't gone through ZLA sync yet. func (s *Subscription) IsOutage() bool { if s.UpdatedAt.IsZero() { return false } - return s.UpdatedAt.Add(48 * time.Hour).Before(time.Now()) + + return s.UpdatedAt.Add(time.Duration(48) * time.Hour).Before(time.Now()) } -// GracePeriodDays returns true when ActiveUntil + days is still in the future. func (s *Subscription) GracePeriodDays(days int) bool { - return s.ActiveUntil.AddDate(0, 0, days).After(time.Now()) + return s.ActiveUntil.AddDate(0, 0, days).After(time.Now()) && s.ActiveUntil.Before(time.Now()) } -// OutageGracePeriodDays returns true when UpdatedAt + days is still in the future. func (s *Subscription) OutageGracePeriodDays(days int) bool { - return s.UpdatedAt.AddDate(0, 0, days).After(time.Now()) + return s.UpdatedAt.AddDate(0, 0, days).After(time.Now()) && s.UpdatedAt.Before(time.Now()) } -// GetStatus computes the current lifecycle status. func (s *Subscription) GetStatus() SubscriptionStatus { if s.Active() { return StatusActive @@ -92,6 +90,9 @@ func (s *Subscription) GetStatus() SubscriptionStatus { if s.GracePeriod() { return StatusGracePeriod } + if s.PendingDelete() { + return StatusPendingDelete + } if s.LimitedAccess() { return StatusLimitedAccess } From db4b13d0dc66c6ff3d89db3df0c5fbefb09fba23 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 15:09:01 +0200 Subject: [PATCH 39/54] chore(app): Hide Active until field in Limited Access and Pending Delete states Signed-off-by: Maciek --- app/src/components/AccountSubscription.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx index fc041d8d..68d80ef1 100644 --- a/app/src/components/AccountSubscription.tsx +++ b/app/src/components/AccountSubscription.tsx @@ -78,9 +78,14 @@ export default function AccountSubscription() { const rows: { label: string; value: React.ReactNode }[] = [ { label: "Status", value: statusBadge }, - { label: "Active until", value: formatDate(sub.active_until) }, ]; + // "Active until" is only meaningful while the subscription is still active or in grace — + // hide it once the user lands in Limited Access or Pending Delete. + if (!isLimited && !isPendingDelete) { + rows.push({ label: "Active until", value: formatDate(sub.active_until) }); + } + return ( <> {/* Alerts — rendered first, meant to be placed above the cards by parent */} From 6bcfada44f0dbff128e3f651757c64a2a538b9f8 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 22:38:37 +0200 Subject: [PATCH 40/54] feat(api): Send LA and PD emails after IVPN account is deleted Signed-off-by: Maciek --- api/db/mongodb/subscription.go | 49 +++++++++++++++++++++++++--------- api/internal/cron/jobs.go | 46 +++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/api/db/mongodb/subscription.go b/api/db/mongodb/subscription.go index 81051f9d..1cc10cc3 100644 --- a/api/db/mongodb/subscription.go +++ b/api/db/mongodb/subscription.go @@ -75,9 +75,16 @@ func (r *SubscriptionRepository) Create(ctx context.Context, sub model.Subscript return nil } -// ResetNotifiedForActive sets notified=false for all subscriptions where active_until >= now. +// ResetNotifiedForActive sets notified=false for subscriptions that are genuinely +// Active per model.Subscription.Active(): active_until in the future AND updated_at +// recent enough that IsOutage() returns false (within the last 48h). Tier1 is a +// string check the cron filters in Go via GetStatus(). func (r *SubscriptionRepository) ResetNotifiedForActive(ctx context.Context) error { - filter := bson.M{"active_until": bson.M{"$gte": time.Now()}} + now := time.Now() + filter := bson.M{ + "active_until": bson.M{"$gte": now}, + "updated_at": bson.M{"$gte": now.Add(-48 * time.Hour)}, + } update := bson.M{"$set": bson.M{"notified": false}} _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) if err != nil { @@ -86,12 +93,20 @@ func (r *SubscriptionRepository) ResetNotifiedForActive(ctx context.Context) err return err } -// FindExpiredUnnotified returns subscriptions where notified=false and active_until < now - 24h. +// FindExpiredUnnotified returns subscriptions that may be in LimitedAccess (per +// model.Subscription.LimitedAccess()) and have not been notified yet. It is a +// coarse pre-filter: matches any sub whose active_until elapsed >24h ago OR +// whose updated_at is >48h old (outage-triggered LA path). The caller (the cron) +// must additionally verify sub.GetStatus() == StatusLimitedAccess so GracePeriod +// and PendingDelete subs are not emailed as LA. func (r *SubscriptionRepository) FindExpiredUnnotified(ctx context.Context) ([]model.Subscription, error) { - oneDayAgo := time.Now().Add(-24 * time.Hour) + now := time.Now() filter := bson.M{ - "notified": false, - "active_until": bson.M{"$lt": oneDayAgo}, + "notified": false, + "$or": []bson.M{ + {"active_until": bson.M{"$lt": now.Add(-24 * time.Hour)}}, + {"updated_at": bson.M{"$lt": now.Add(-48 * time.Hour)}}, + }, } cursor, err := r.subscriptionsCollection.Find(ctx, filter) if err != nil { @@ -120,13 +135,18 @@ func (r *SubscriptionRepository) MarkNotified(ctx context.Context, subscriptionI return err } -// FindPendingDeleteUnnotified returns subscriptions where notified_pending_delete=false -// and active_until + 14 days < now (both grace periods exceeded). +// FindPendingDeleteUnnotified returns subscriptions where the model considers +// the sub PendingDelete (active_until + 14d < now OR updated_at + 14d < now) +// and notified_pending_delete is still false. This mirrors model.Subscription.PendingDelete() +// exactly so no Go-side post-filter is required. func (r *SubscriptionRepository) FindPendingDeleteUnnotified(ctx context.Context) ([]model.Subscription, error) { fourteenDaysAgo := time.Now().AddDate(0, 0, -14) filter := bson.M{ "notified_pending_delete": false, - "active_until": bson.M{"$lt": fourteenDaysAgo}, + "$or": []bson.M{ + {"active_until": bson.M{"$lt": fourteenDaysAgo}}, + {"updated_at": bson.M{"$lt": fourteenDaysAgo}}, + }, } cursor, err := r.subscriptionsCollection.Find(ctx, filter) if err != nil { @@ -155,10 +175,15 @@ func (r *SubscriptionRepository) MarkPendingDeleteNotified(ctx context.Context, return err } -// ResetPendingDeleteNotifiedForActive sets notified_pending_delete=false for all subscriptions -// where active_until >= now. +// ResetPendingDeleteNotifiedForActive sets notified_pending_delete=false for +// subscriptions that are genuinely Active again (active_until in future AND +// updated_at within the last 48h, mirroring model.Subscription.Active()). func (r *SubscriptionRepository) ResetPendingDeleteNotifiedForActive(ctx context.Context) error { - filter := bson.M{"active_until": bson.M{"$gte": time.Now()}} + now := time.Now() + filter := bson.M{ + "active_until": bson.M{"$gte": now}, + "updated_at": bson.M{"$gte": now.Add(-48 * time.Hour)}, + } update := bson.M{"$set": bson.M{"notified_pending_delete": false}} _, err := r.subscriptionsCollection.UpdateMany(ctx, filter, update) if err != nil { diff --git a/api/internal/cron/jobs.go b/api/internal/cron/jobs.go index 0bf8b0e4..e041ed38 100644 --- a/api/internal/cron/jobs.go +++ b/api/internal/cron/jobs.go @@ -7,11 +7,18 @@ import ( "github.com/ivpn/dns/api/cache" "github.com/ivpn/dns/api/db/repository" "github.com/ivpn/dns/api/internal/email" + "github.com/ivpn/dns/api/model" "github.com/rs/zerolog/log" ) // NotifyExpiringSubscriptions resets the notified flag for active subscriptions, -// finds expired+unnotified ones, sends notification emails, and marks them as notified. +// finds candidates that may be in LimitedAccess (per the broadened Mongo query), +// filters down to those whose computed status is exactly LimitedAccess, sends the +// expiry email, and marks only the emailed subs as notified. +// +// The Mongo pre-filter is intentionally loose (matches active_until OR updated_at +// past their respective LA thresholds); the precise predicate lives in the model +// (sub.GetStatus()) to avoid duplicating logic. func NotifyExpiringSubscriptions(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, mailer email.Mailer) { ctx := context.Background() @@ -20,21 +27,29 @@ func NotifyExpiringSubscriptions(subRepo repository.SubscriptionRepository, acco log.Error().Err(err).Msg("Cron: failed to reset notified flag for active subscriptions") } - // 2. Find expired+unnotified subscriptions - subs, err := subRepo.FindExpiredUnnotified(ctx) + // 2. Find candidates (sub may be in LA or worse — filtered below) + candidates, err := subRepo.FindExpiredUnnotified(ctx) if err != nil { log.Error().Err(err).Msg("Cron: failed to find expired unnotified subscriptions") return } - if len(subs) == 0 { + if len(candidates) == 0 { return } - log.Info().Int("count", len(subs)).Msg("Cron: notifying expiring subscriptions") + log.Info().Int("candidates", len(candidates)).Msg("Cron: evaluating expiring-subscription candidates") + + // 3. Send notification emails to subs whose computed status is LimitedAccess. + notifiedIDs := make([]uuid.UUID, 0, len(candidates)) + skippedNotLA := 0 + for _, sub := range candidates { + if sub.GetStatus() != model.StatusLimitedAccess { + skippedNotLA++ + log.Debug().Str("subscription_id", sub.ID.String()).Str("status", string(sub.GetStatus())).Msg("Cron: skipping non-LA candidate from expiry notification") + continue + } - // 3. Send notification emails - for _, sub := range subs { account, err := accountRepo.GetAccountById(ctx, sub.AccountID.Hex()) if err != nil { log.Error().Err(err).Str("account_id", sub.AccountID.Hex()).Msg("Cron: failed to get account for expiry notification") @@ -45,15 +60,18 @@ func NotifyExpiringSubscriptions(subRepo repository.SubscriptionRepository, acco log.Error().Err(err).Str("email", account.Email).Msg("Cron: failed to send subscription expiry email") continue } - } - // 4. Mark as notified - ids := make([]uuid.UUID, 0, len(subs)) - for _, sub := range subs { - ids = append(ids, sub.ID) + notifiedIDs = append(notifiedIDs, sub.ID) } - if err := subRepo.MarkNotified(ctx, ids); err != nil { - log.Error().Err(err).Msg("Cron: failed to mark subscriptions as notified") + + log.Info().Int("candidates", len(candidates)).Int("skipped_not_la", skippedNotLA).Int("sent", len(notifiedIDs)).Msg("Cron: expiring-subscription notifications complete") + + // 4. Mark only the actually-emailed subs as notified. Skipped non-LA candidates + // keep notified=false so the email can still fire when they transition into LA later. + if len(notifiedIDs) > 0 { + if err := subRepo.MarkNotified(ctx, notifiedIDs); err != nil { + log.Error().Err(err).Msg("Cron: failed to mark subscriptions as notified") + } } } From 03fb47a3e9b53a85d655015568146fc0ce292a89 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 12:54:20 +0200 Subject: [PATCH 41/54] fix(api): Add fallback mechanism in /api/v1/pasession/rotate endpoint Signed-off-by: Maciek --- api/api/pasession.go | 46 +++++--- api/api/pasession_test.go | 227 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 api/api/pasession_test.go diff --git a/api/api/pasession.go b/api/api/pasession.go index 11c4691f..04e6a0e8 100644 --- a/api/api/pasession.go +++ b/api/api/pasession.go @@ -50,7 +50,11 @@ func (s *APIServer) addPASession() fiber.Handler { } // @Summary Rotate pre-auth session ID -// @Description Rotate pre-auth session ID and set new ID as cookie +// @Description Rotate pre-auth session ID and set new ID as cookie. The endpoint +// @Description is idempotent against an already-rotated session: if the URL +// @Description sessionid is no longer in the cache but the caller already holds +// @Description a valid pa_session cookie, the call succeeds as a no-op so the +// @Description user can continue with their existing session. // @Tags PASession // @Accept json // @Produce json @@ -70,21 +74,35 @@ func (s *APIServer) rotatePASession() fiber.Handler { return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) } - newID, err := s.Service.RotatePASessionID(c.Context(), req.SessionID) - if err != nil { - return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) + if newID, err := s.Service.RotatePASessionID(c.Context(), req.SessionID); err == nil { + setPASessionCookie(c, newID) + return c.SendStatus(fiber.StatusOK) } - c.Cookie(&fiber.Cookie{ - Name: PASessionCookie, - Value: newID, - HTTPOnly: true, - Secure: true, - SameSite: fiber.CookieSameSiteLaxMode, - MaxAge: 900, - Expires: time.Now().Add(15 * time.Minute), - }) + // Fallback: the URL sessionid was already consumed (typical when the + // signup link is opened a second time in the same browser). If the + // existing pa_session cookie still points to a valid cache entry, the + // caller can continue with what they have — no rotate needed. + if existing := c.Cookies(PASessionCookie); existing != "" { + if _, err := s.Service.ValidateAndGetPreauth(c.Context(), existing); err == nil { + return c.SendStatus(fiber.StatusOK) + } + } - return c.SendStatus(fiber.StatusOK) + return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."}) } } + +// setPASessionCookie writes the pa_session cookie used by subsequent /accounts +// and /sub/update calls during the signup / resync flow. +func setPASessionCookie(c *fiber.Ctx, sessionID string) { + c.Cookie(&fiber.Cookie{ + Name: PASessionCookie, + Value: sessionID, + HTTPOnly: true, + Secure: true, + SameSite: fiber.CookieSameSiteLaxMode, + MaxAge: 900, + Expires: time.Now().Add(15 * time.Minute), + }) +} diff --git a/api/api/pasession_test.go b/api/api/pasession_test.go new file mode 100644 index 00000000..2c115877 --- /dev/null +++ b/api/api/pasession_test.go @@ -0,0 +1,227 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/ivpn/dns/api/config" + "github.com/ivpn/dns/api/internal/validator" + "github.com/ivpn/dns/api/mocks" + "github.com/ivpn/dns/api/model" + "github.com/ivpn/dns/api/service" + "github.com/ivpn/dns/api/service/subscription" + "github.com/ivpn/dns/libs/urlshort" +) + +type PASessionAPITestSuite struct { + suite.Suite + mockService *mocks.Servicer + mockDB *mocks.Db + validator *validator.APIValidator + config *config.Config +} + +func (suite *PASessionAPITestSuite) SetupSuite() { + var err error + suite.validator, err = validator.NewAPIValidator() + suite.Require().NoError(err) + suite.config = &config.Config{ + API: &config.APIConfig{ + ApiAllowOrigin: "http://localhost:3000", + ApiAllowIP: "*", + PSK: "test-psk-token", + }, + Server: &config.ServerConfig{Name: "modDNS Test", FQDN: "test.local"}, + Service: &config.ServiceConfig{}, + } +} + +func (suite *PASessionAPITestSuite) SetupTest() { + suite.mockService = mocks.NewServicer(suite.T()) + suite.mockDB = mocks.NewDb(suite.T()) +} + +func (suite *PASessionAPITestSuite) createTestServer() *APIServer { + testService := service.Service{ + Store: suite.mockDB, + SubscriptionServicer: suite.mockService, + } + mockCache := mocks.NewCachecache(suite.T()) + mockIDGen := mocks.NewGeneratoridgen(suite.T()) + mockMailer := mocks.NewMaileremail(suite.T()) + mockShortener := urlshort.NewURLShortener() + + server, err := NewServer( + suite.config, + testService, + suite.mockDB, + mockCache, + mockIDGen, + suite.validator, + mockMailer, + mockShortener, + nil, + ) + suite.Require().NoError(err, "Failed to create test server") + server.RegisterRoutes() + return server +} + +func putRotateRequest(body string, cookies ...*http.Cookie) *http.Request { + req := httptest.NewRequest( + http.MethodPut, + "/api/v1/pasession/rotate", + bytes.NewBufferString(body), + ) + req.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + req.AddCookie(c) + } + return req +} + +// TestRotate_FreshSessionRotates is the happy path: the URL sessionid is +// in the cache, RotatePASessionID returns a new ID, the handler sets the +// pa_session cookie and returns 200. +func (suite *PASessionAPITestSuite) TestRotate_FreshSessionRotates() { + oldID := "c259d7e2-a8c4-4817-aac5-b0cd2fcddfad" + newID := "8f0a1b2c-3d4e-4f5a-6b7c-8d9e0f1a2b3c" + + suite.mockService. + On("RotatePASessionID", mock.Anything, oldID). + Return(newID, nil) + + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest(`{"sessionid":"`+oldID+`"}`), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) + + // New cookie must be set with the rotated ID. + var pa *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == PASessionCookie { + pa = c + break + } + } + suite.Require().NotNil(pa, "expected pa_session cookie to be set") + assert.Equal(suite.T(), newID, pa.Value) + assert.True(suite.T(), pa.HttpOnly) +} + +// TestRotate_AlreadyRotated_NoCookie reproduces the original bug pre-fix: +// the URL sessionid was already consumed and the caller has no pa_session +// cookie. The handler must return 400 — there's no way to recover. +func (suite *PASessionAPITestSuite) TestRotate_AlreadyRotated_NoCookie() { + oldID := "c259d7e2-a8c4-4817-aac5-b0cd2fcddfad" + + suite.mockService. + On("RotatePASessionID", mock.Anything, oldID). + Return("", subscription.ErrPASessionNotFound) + + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest(`{"sessionid":"`+oldID+`"}`), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) +} + +// TestRotate_AlreadyRotated_CookieValid is the fix: URL sessionid is gone +// but the caller still holds a valid pa_session cookie (typical when a +// signup link is reopened in the same browser). The handler must return +// 200 without rewriting the cookie. +func (suite *PASessionAPITestSuite) TestRotate_AlreadyRotated_CookieValid() { + oldID := "c259d7e2-a8c4-4817-aac5-b0cd2fcddfad" + cookieID := "8f0a1b2c-3d4e-4f5a-6b7c-8d9e0f1a2b3c" + + suite.mockService. + On("RotatePASessionID", mock.Anything, oldID). + Return("", subscription.ErrPASessionNotFound) + suite.mockService. + On("ValidateAndGetPreauth", mock.Anything, cookieID). + Return(&model.Preauth{}, nil) + + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest( + `{"sessionid":"`+oldID+`"}`, + &http.Cookie{Name: PASessionCookie, Value: cookieID}, + ), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) + + // The handler must NOT overwrite the existing cookie on the fallback path. + for _, c := range resp.Cookies() { + if c.Name == PASessionCookie { + suite.Failf("unexpected cookie write", "got Set-Cookie pa_session=%q on fallback path", c.Value) + } + } +} + +// TestRotate_AlreadyRotated_CookieAlsoExpired covers the case where the +// caller presents a stale cookie that no longer maps to a cache entry. +// Both paths fail → 400. +func (suite *PASessionAPITestSuite) TestRotate_AlreadyRotated_CookieAlsoExpired() { + oldID := "c259d7e2-a8c4-4817-aac5-b0cd2fcddfad" + staleCookieID := "8f0a1b2c-3d4e-4f5a-6b7c-8d9e0f1a2b3c" + + suite.mockService. + On("RotatePASessionID", mock.Anything, oldID). + Return("", subscription.ErrPASessionNotFound) + suite.mockService. + On("ValidateAndGetPreauth", mock.Anything, staleCookieID). + Return(nil, subscription.ErrPASessionNotFound) + + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest( + `{"sessionid":"`+oldID+`"}`, + &http.Cookie{Name: PASessionCookie, Value: staleCookieID}, + ), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) +} + +// TestRotate_MalformedBody is unchanged from the original behaviour; the +// fallback should not run because the request itself is invalid. +func (suite *PASessionAPITestSuite) TestRotate_MalformedBody() { + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest(`{not json`), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) +} + +// TestRotate_NonUUIDSessionID exercises the validator: the rotate service +// must NOT be called for a malformed sessionid, and the cookie fallback +// must NOT be attempted. +func (suite *PASessionAPITestSuite) TestRotate_NonUUIDSessionID() { + server := suite.createTestServer() + resp, err := server.App.Test( + putRotateRequest(`{"sessionid":"not-a-uuid"}`), + -1, + ) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode) +} + +func TestPASessionAPITestSuite(t *testing.T) { + suite.Run(t, new(PASessionAPITestSuite)) +} From 55a373224fa304577047de981a1644ab346364d2 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 13:55:01 +0200 Subject: [PATCH 42/54] fix(api): Improve signup UX - make welcome email dispatch failure non-fatal Signed-off-by: Maciek --- api/service/account/account.go | 100 +++++++++++++------ api/service/account/service_test.go | 148 ++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 31 deletions(-) diff --git a/api/service/account/account.go b/api/service/account/account.go index 18cb972b..fab216a6 100644 --- a/api/service/account/account.go +++ b/api/service/account/account.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "strings" + "sync" "time" validatorv10 "github.com/go-playground/validator/v10" @@ -46,6 +47,19 @@ type AccountService struct { IDGenerator idgen.Generator Validate *validatorv10.Validate Http client.Http + + // bgTasks tracks fire-and-forget goroutines spawned for best-effort work + // (e.g. welcome email send). Used by tests to deterministically wait for + // completion via WaitBackground(). + bgTasks sync.WaitGroup +} + +// WaitBackground blocks until all in-flight best-effort background tasks +// (currently: welcome email dispatch) have completed. Production code does +// not call this; it exists so tests can deterministically observe the side +// effects of fire-and-forget goroutines before asserting on mocks. +func (a *AccountService) WaitBackground() { + a.bgTasks.Wait() } // NewAccountService creates a new profile service @@ -150,46 +164,70 @@ func (a *AccountService) GetUnfinishedSignupOrPostAccount(ctx context.Context, e return acc, nil } -// CompleteRegistration finalizes registration steps for an account. -// Sends signup webhook, removes PASession cache entry, sends welcome email. +// CompleteRegistration finalizes registration steps for an account. The blocking +// part is just address-level validation; everything else (signup webhook, +// PASession cache cleanup, welcome email) is best-effort and never aborts the +// HTTP signup response — the account row is the durable source of truth and is +// already persisted by the time this is called. func (a *AccountService) CompleteRegistration(ctx context.Context, account *model.Account, subscriptionID string, sessionID string) error { - err := a.Http.SignupWebhook(subscriptionID) - if err != nil { - log.Debug().Err(err).Str("subscription_id", subscriptionID).Msg("Failed to send signup webhook") + // Blocking: invalid email syntax / unreachable MX is a legitimate signup + // failure; better to surface it than to async-send into the void. + if err := a.validateEmailAddress(account.Email); err != nil { + log.Warn().Err(err).Str("email", account.Email).Msg("Email address validation failed during registration") + return err } - if err == nil { - // Remove PASession cache key (idempotent) - if rmErr := a.Cache.RemovePASession(ctx, sessionID); rmErr != nil { - log.Debug().Err(rmErr).Str("session_id", sessionID).Msg("Failed to remove PA session cache entry") - } - err = a.sendWelcomeEmail(ctx, account, account.Email) - if err != nil { - return err - } + // Best-effort: each step logs on failure and continues. Webhook failures + // are warn-level because the external IVPN account state is then out of + // sync until the user makes another request that re-fires it. + if err := a.Http.SignupWebhook(subscriptionID); err != nil { + log.Warn().Err(err).Str("subscription_id", subscriptionID).Msg("Signup webhook failed; will be retried on next user action") } - return err -} -func (a *AccountService) sendWelcomeEmail(ctx context.Context, acc *model.Account, email string) error { - eg, _ := errgroup.WithContext(ctx) - eg.Go(func() (err error) { return a.Mailer.Verify(email) }) - eg.Go(func() error { - return a.sendEmailCategory(acc, EmailCategoryWelcome, func() error { - err := a.Mailer.SendWelcomeEmail(ctx, email, "") - if err != nil { - log.Err(err).Msg("Failed to send welcome email") - } - return err - }) - }) - if err := eg.Wait(); err != nil { - log.Err(err).Msg(ErrFailedToCreateAccount.Error()) - return err + if err := a.Cache.RemovePASession(ctx, sessionID); err != nil { + log.Debug().Err(err).Str("session_id", sessionID).Msg("Failed to remove PA session cache entry (TTL will eventually evict)") } + + a.dispatchWelcomeEmail(account, account.Email) + return nil } +// validateEmailAddress runs the synchronous syntax/MX check. Failure should +// fail signup because the address is unusable for any future communication. +func (a *AccountService) validateEmailAddress(email string) error { + return a.Mailer.Verify(email) +} + +// dispatchWelcomeEmail sends the welcome email in the background. The send +// uses context.Background() so cancellation of the request context (which +// happens as soon as the HTTP handler returns 201) does not abort the SMTP +// call. Errors are logged but never propagated — welcome email is courtesy +// and must not block signup. +func (a *AccountService) dispatchWelcomeEmail(acc *model.Account, email string) { + a.bgTasks.Add(1) + go func() { + defer a.bgTasks.Done() + ctx := context.Background() + err := a.sendEmailCategory(acc, EmailCategoryWelcome, func() error { + return a.Mailer.SendWelcomeEmail(ctx, email, "") + }) + if err != nil { + log.Warn().Err(err). + Str("account_id", acc.ID.Hex()). + Str("email", email). + Str("category", EmailCategoryWelcome). + Msg("Welcome email send failed; signup is unaffected") + return + } + log.Info(). + Str("account_id", acc.ID.Hex()). + Str("email", email). + Str("category", EmailCategoryWelcome). + Msg("Welcome email sent") + }() +} + // RegisterAccountWithPreauth creates a new account with subscription from preauth data. func (a *AccountService) RegisterAccountWithPreauth(ctx context.Context, email, passwordPlain string, preauth *model.Preauth) (*model.Account, error) { // check if given email is already registered (defensive re-check) diff --git a/api/service/account/service_test.go b/api/service/account/service_test.go index 71635baf..1140a509 100644 --- a/api/service/account/service_test.go +++ b/api/service/account/service_test.go @@ -288,6 +288,11 @@ func (suite *AccountTestSuite) TestGetUnfinishedSignupOrPostAccount() { } result, err := suite.service.GetUnfinishedSignupOrPostAccount(context.Background(), targetEmail, password, subID, sessionID) + // Welcome email is dispatched in a fire-and-forget goroutine; drain + // it before the subtest ends so its mailer mock call doesn't leak + // into the next subtest's expectations. + suite.service.WaitBackground() + if tt.expectError != "" { suite.Error(err) suite.Contains(err.Error(), tt.expectError) @@ -2641,6 +2646,149 @@ func (suite *AccountTestSuite) TestUpdateAccountWith2FA() { } } +// TestCompleteRegistration_BlockingVsBestEffort asserts the design contract that +// only address-level validation aborts the signup HTTP response; webhook, +// PASession cache cleanup, and the welcome email send must all be best-effort +// (logged on failure but never returned). +// +// Each subtest covers one row of the spec table T1-T7 in +// /home/maciek/.claude/plans/whimsical-toasting-kahan.md. +func (suite *AccountTestSuite) TestCompleteRegistration_BlockingVsBestEffort() { + const ( + subID = "subscription-id" + sessionID = "session-id" + email = "user@example.com" + ) + mkAcc := func(verified bool) *model.Account { + return &model.Account{ + ID: primitive.NewObjectID(), + Email: email, + EmailVerified: verified, + } + } + + suite.Run("T1 Verify failure aborts registration (syntax error)", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(errors.New("invalid email format")) + + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + + suite.Error(err) + suite.Contains(err.Error(), "invalid email format") + // Webhook / cache / send must not be attempted. + suite.service.WaitBackground() + suite.mockMailer.AssertNotCalled(suite.T(), "SendWelcomeEmail", mock.Anything, mock.Anything, mock.Anything) + suite.mockCache.AssertNotCalled(suite.T(), "RemovePASession", mock.Anything, mock.Anything) + }) + + suite.Run("T2 Verify failure aborts registration (MX not found)", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(errors.New("MX record not found")) + + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + + suite.Error(err) + suite.Contains(err.Error(), "MX record not found") + suite.service.WaitBackground() + }) + + suite.Run("T3 SendWelcomeEmail failure does NOT abort signup", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(nil) + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(nil) + + // Mailer fails, but the goroutine swallows the error. + callObserved := make(chan struct{}) + suite.mockMailer. + On("SendWelcomeEmail", mock.Anything, email, mock.AnythingOfType("string")). + Run(func(args mock.Arguments) { close(callObserved) }). + Return(errors.New("smtp 4xx")) + + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + suite.NoError(err, "signup must succeed even when SendWelcomeEmail fails") + + suite.service.WaitBackground() + select { + case <-callObserved: + default: + suite.Failf("SendWelcomeEmail not invoked", "expected the dispatched goroutine to call SendWelcomeEmail") + } + }) + + suite.Run("T4 SignupWebhook failure does NOT abort signup", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(nil) + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(nil) + suite.mockMailer.On("SendWelcomeEmail", mock.Anything, email, mock.AnythingOfType("string")).Return(nil) + + // SignupWebhook is called via the unconfigured HTTP client which fails + // against a non-existent URL. CompleteRegistration must still return nil. + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + suite.NoError(err) + + // Welcome email still dispatched (independent of webhook outcome). + suite.service.WaitBackground() + }) + + suite.Run("T5 RemovePASession failure does NOT abort signup", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(nil) + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(errors.New("redis nil")) + suite.mockMailer.On("SendWelcomeEmail", mock.Anything, email, mock.AnythingOfType("string")).Return(nil) + + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + suite.NoError(err) + suite.service.WaitBackground() + }) + + suite.Run("T6 Unverified account still allowed for the welcome category (regression guard)", func() { + suite.SetupSuite() + acc := mkAcc(false) // unverified — sendEmailCategory must still permit "welcome" + suite.mockMailer.On("Verify", email).Return(nil) + suite.mockCache.On("RemovePASession", context.Background(), sessionID).Return(nil) + suite.mockMailer.On("SendWelcomeEmail", mock.Anything, email, mock.AnythingOfType("string")).Return(nil) + + err := suite.service.CompleteRegistration(context.Background(), acc, subID, sessionID) + suite.NoError(err) + suite.service.WaitBackground() + }) + + suite.Run("T7 Goroutine uses an uncancelled context.Background()", func() { + suite.SetupSuite() + acc := mkAcc(true) + suite.mockMailer.On("Verify", email).Return(nil) + suite.mockCache.On("RemovePASession", mock.Anything, sessionID).Return(nil) + + // Caller passes a context that we cancel immediately. The dispatched + // SendWelcomeEmail call must observe a fresh, uncancelled context. + reqCtx, cancel := context.WithCancel(context.Background()) + cancel() + + var observedCtxErr error + ctxRecorded := make(chan struct{}) + suite.mockMailer. + On("SendWelcomeEmail", mock.Anything, email, mock.AnythingOfType("string")). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + observedCtxErr = ctx.Err() + close(ctxRecorded) + }). + Return(nil) + + err := suite.service.CompleteRegistration(reqCtx, acc, subID, sessionID) + suite.NoError(err) + + suite.service.WaitBackground() + <-ctxRecorded + suite.NoError(observedCtxErr, "SendWelcomeEmail must run on an uncancelled context.Background()") + }) +} + func TestAccountTestSuite(t *testing.T) { suite.Run(t, new(AccountTestSuite)) } From 8554670fbc51a8ba178a12adeb08abaf430d202b Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 16:28:36 +0200 Subject: [PATCH 43/54] feat(api): Redis-based gocron/v2 locker Signed-off-by: Maciek --- api/cache/redis.go | 21 ++++- api/go.mod | 2 + api/go.sum | 4 + api/internal/cron/cron.go | 10 ++- api/internal/cron/lock.go | 108 ++++++++++++++++++++++++ api/internal/cron/lock_test.go | 147 +++++++++++++++++++++++++++++++++ api/main.go | 18 +++- 7 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 api/internal/cron/lock.go create mode 100644 api/internal/cron/lock_test.go diff --git a/api/cache/redis.go b/api/cache/redis.go index b8f3c7c6..92fcab02 100644 --- a/api/cache/redis.go +++ b/api/cache/redis.go @@ -21,16 +21,31 @@ type RedisCache struct { client *redis.Client } -// NewRedisCache creates a new RedisCache instance +// NewRedisCache creates a new RedisCache instance, opening its own Redis +// connection from the provided config. func NewRedisCache(cfg *cache.Config) (*RedisCache, error) { rdb, err := cache.NewRedisClient(cfg) if err != nil { return nil, err } + return NewRedisCacheFromClient(rdb), nil +} + +// NewRedisCacheFromClient creates a new RedisCache that reuses an +// already-constructed *redis.Client. Use this when the same client must +// be shared with other Redis-backed components (e.g. the cron locker) +// to avoid maintaining multiple connection pools. +func NewRedisCacheFromClient(client *redis.Client) *RedisCache { return &RedisCache{ - client: rdb, - }, nil + client: client, + } +} + +// Client exposes the underlying Redis client so callers that need to +// share the same connection pool (e.g. the cron locker) can reuse it. +func (c *RedisCache) Client() *redis.Client { + return c.client } // Incr atomically increments the integer value of a key by one. diff --git a/api/go.mod b/api/go.mod index 5b0870db..b9e232d3 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,6 +4,7 @@ go 1.25.8 require ( github.com/AfterShip/email-verifier v1.4.0 + github.com/alicebob/miniredis/v2 v2.37.0 github.com/getsentry/sentry-go v0.31.1 github.com/getsentry/sentry-go/fiber v0.31.1 github.com/getsentry/sentry-go/zerolog v0.31.1 @@ -95,6 +96,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect diff --git a/api/go.sum b/api/go.sum index 104c7420..f992c153 100644 --- a/api/go.sum +++ b/api/go.sum @@ -10,6 +10,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= @@ -269,6 +271,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= diff --git a/api/internal/cron/cron.go b/api/internal/cron/cron.go index ee32c299..a983c1ad 100644 --- a/api/internal/cron/cron.go +++ b/api/internal/cron/cron.go @@ -9,8 +9,14 @@ import ( ) // Start initializes the gocron scheduler with all periodic jobs. -func Start(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, profileRepo repository.ProfileRepository, profileCache cache.Cache, mailer email.Mailer) { - s, err := gocron.NewScheduler() +// +// The locker enforces single-flight execution across load-balanced API +// instances: only the instance that acquires the per-job Redis lock for +// a given tick runs the job body; the others silently skip. The MongoDB +// notified flags remain the durable dedup safety net for the rare cases +// where the lock cannot serialise (e.g. Redis failover mid-tick). +func Start(subRepo repository.SubscriptionRepository, accountRepo repository.AccountRepository, profileRepo repository.ProfileRepository, profileCache cache.Cache, mailer email.Mailer, locker gocron.Locker) { + s, err := gocron.NewScheduler(gocron.WithDistributedLocker(locker)) if err != nil { log.Error().Err(err).Msg("Failed to create cron scheduler") return diff --git a/api/internal/cron/lock.go b/api/internal/cron/lock.go new file mode 100644 index 00000000..682166fb --- /dev/null +++ b/api/internal/cron/lock.go @@ -0,0 +1,108 @@ +// Package cron provides scheduled job execution and the distributed +// locking primitives required to run a single scheduler across multiple +// load-balanced API instances. +package cron + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +// lockKeyPrefix is prepended to every job-lock key written to Redis so +// distributed locks live in a clearly identifiable namespace. +const lockKeyPrefix = "cron:lock:" + +// errLockNotAcquired is returned by (*redisLocker).Lock when the lock is +// already held by another scheduler instance. It is a sentinel value so +// callers (and the gocron scheduler) can distinguish "another instance is +// running this tick" from real Redis failures and silently skip the run. +var errLockNotAcquired = errors.New("cron locker: lock not acquired") + +// lockReleaseScript performs a compare-and-delete: it deletes the lock key +// only when the value still matches the token supplied by the caller. This +// prevents an instance from accidentally deleting a lock another instance +// has since acquired (e.g. when the original holder's TTL expired +// mid-run). It is parsed once at package load time. +var lockReleaseScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +else + return 0 +end +`) + +// redisLocker is a gocron.Locker implementation backed by Redis SET NX EX. +// A fresh UUID-v4 token is generated on every successful acquisition so +// the unlock path can guarantee it only releases its own key. +type redisLocker struct { + client *redis.Client + ttl time.Duration +} + +// NewRedisLocker returns a gocron.Locker that coordinates job execution +// across multiple scheduler instances using Redis. The provided ttl is +// the lock's expiry; it should comfortably exceed the longest expected +// job runtime so the lock survives for the duration of one tick but is +// short enough that a crashed holder's lock is reclaimed quickly. +func NewRedisLocker(client *redis.Client, ttl time.Duration) gocron.Locker { + return &redisLocker{ + client: client, + ttl: ttl, + } +} + +// Lock attempts to acquire the named job lock. It returns errLockNotAcquired +// when another instance currently holds the lock — that is the normal, +// expected outcome on losing instances and is intentionally not logged. +// Any other error indicates a real Redis-level failure. +func (l *redisLocker) Lock(ctx context.Context, key string) (gocron.Lock, error) { + token := uuid.NewString() + fullKey := lockKeyPrefix + key + + acquired, err := l.client.SetNX(ctx, fullKey, token, l.ttl).Result() + if err != nil { + return nil, fmt.Errorf("cron locker: setnx %q: %w", fullKey, err) + } + if !acquired { + return nil, errLockNotAcquired + } + + return &redisLock{ + client: l.client, + key: fullKey, + token: token, + }, nil +} + +// redisLock is a gocron.Lock backed by Redis. The sync.Once guarantees that +// the compare-and-delete script runs at most once even if Unlock is called +// repeatedly (gocron's contract leaves the call count up to the scheduler). +type redisLock struct { + client *redis.Client + key string + token string + once sync.Once + err error +} + +// Unlock releases the Redis lock if (and only if) it is still owned by this +// instance. Calling Unlock more than once is safe and returns the result of +// the first call. A failure to talk to Redis is logged at warn level — the +// lock will still be cleaned up by its TTL. +func (l *redisLock) Unlock(ctx context.Context) error { + l.once.Do(func() { + if _, err := lockReleaseScript.Run(ctx, l.client, []string{l.key}, l.token).Result(); err != nil { + log.Warn().Err(err).Str("key", l.key).Msg("cron locker: failed to release lock; relying on TTL") + l.err = fmt.Errorf("cron locker: release %q: %w", l.key, err) + } + }) + return l.err +} diff --git a/api/internal/cron/lock_test.go b/api/internal/cron/lock_test.go new file mode 100644 index 00000000..df98af9f --- /dev/null +++ b/api/internal/cron/lock_test.go @@ -0,0 +1,147 @@ +package cron + +import ( + "errors" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +const testLockKey = "test-job" + +// newTestLocker spins up an in-memory Redis and returns a fresh locker plus +// the miniredis handle (so individual tests can fast-forward time or assert +// on raw key state) and the underlying client. +func newTestLocker(t *testing.T, ttl time.Duration) (*redisLocker, *miniredis.Miniredis, *redis.Client) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + _ = client.Close() + }) + return &redisLocker{client: client, ttl: ttl}, mr, client +} + +func TestRedisLocker_FirstAcquireSucceeds(t *testing.T) { + locker, _, _ := newTestLocker(t, 5*time.Second) + + lock, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NotNil(t, lock) +} + +func TestRedisLocker_SecondAcquireFailsWhileHeld(t *testing.T) { + locker, _, _ := newTestLocker(t, 5*time.Second) + + first, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := locker.Lock(t.Context(), testLockKey) + require.Error(t, err) + require.Nil(t, second) + require.True(t, errors.Is(err, errLockNotAcquired), "expected errLockNotAcquired, got %v", err) +} + +func TestRedisLocker_AfterUnlockReacquireSucceeds(t *testing.T) { + locker, mr, _ := newTestLocker(t, 5*time.Second) + + first, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NoError(t, first.Unlock(t.Context())) + + // L5: directly assert the key is gone after a successful Unlock, + // not just that a subsequent Lock succeeds (which would prove it + // only transitively). + require.False(t, mr.Exists(lockKeyPrefix+testLockKey), "Unlock must delete the lock key") + + second, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NotNil(t, second) +} + +func TestRedisLocker_UnlockOnlyDeletesOwnedKey(t *testing.T) { + locker, mr, _ := newTestLocker(t, 5*time.Second) + + lock, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + + // Simulate the TTL-expired-mid-run case: a peer instance has acquired + // the same lock with a different token. Our Unlock must NOT delete it. + fullKey := lockKeyPrefix + testLockKey + require.NoError(t, mr.Set(fullKey, "different-token-from-another-instance")) + + require.NoError(t, lock.Unlock(t.Context())) + + value, err := mr.Get(fullKey) + require.NoError(t, err) + require.Equal(t, "different-token-from-another-instance", value, "Unlock incorrectly deleted a peer's lock") +} + +func TestRedisLocker_UnlockIsIdempotent(t *testing.T) { + locker, _, _ := newTestLocker(t, 5*time.Second) + + lock, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + + require.NoError(t, lock.Unlock(t.Context())) + require.NoError(t, lock.Unlock(t.Context()), "second Unlock must be a safe no-op") +} + +// TestRedisLocker_UnlockReturnsErrWhenRedisDown covers the release-failure +// branch in (*redisLock).Unlock: if the CAS script cannot be executed, the +// error is wrapped, captured under sync.Once, and returned to subsequent +// callers. The lock will still be cleaned up by its TTL on the real Redis, +// but the caller must learn that release did not succeed (L9). +func TestRedisLocker_UnlockReturnsErrWhenRedisDown(t *testing.T) { + locker, mr, _ := newTestLocker(t, 5*time.Second) + + lock, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + + // Simulate Redis becoming unreachable mid-run by stopping the + // in-memory server. The compare-and-delete script will fail to + // execute, exercising the warn-and-wrap branch. + mr.Close() + + firstUnlockErr := lock.Unlock(t.Context()) + require.Error(t, firstUnlockErr) + require.ErrorContains(t, firstUnlockErr, "cron locker: release") + + // sync.Once means the same error is returned on subsequent calls + // without re-running the script. + secondUnlockErr := lock.Unlock(t.Context()) + require.Equal(t, firstUnlockErr, secondUnlockErr, "Unlock must remain idempotent and return the captured error") +} + +func TestRedisLocker_TTLExpires(t *testing.T) { + ttl := 5 * time.Second + locker, mr, _ := newTestLocker(t, ttl) + + first, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NotNil(t, first) + + // Capture the first holder's token via the underlying key so we can + // later verify the new acquisition got a fresh one (L3). + fullKey := lockKeyPrefix + testLockKey + firstToken, err := mr.Get(fullKey) + require.NoError(t, err) + require.NotEmpty(t, firstToken) + + // Without unlocking, advance miniredis past the TTL. The next Lock + // must succeed because the previous key has expired. + mr.FastForward(ttl + 1*time.Second) + + second, err := locker.Lock(t.Context(), testLockKey) + require.NoError(t, err) + require.NotNil(t, second) + + secondToken, err := mr.Get(fullKey) + require.NoError(t, err) + require.NotEmpty(t, secondToken) + require.NotEqual(t, firstToken, secondToken, "TTL-expiry reacquire must mint a new token") +} diff --git a/api/main.go b/api/main.go index 090e2124..5294e894 100644 --- a/api/main.go +++ b/api/main.go @@ -16,6 +16,7 @@ import ( "github.com/ivpn/dns/api/internal/migrations" "github.com/ivpn/dns/api/internal/validator" "github.com/ivpn/dns/api/service" + libscache "github.com/ivpn/dns/libs/cache" "github.com/ivpn/dns/libs/servicescatalogcache" "github.com/ivpn/dns/libs/store" @@ -29,6 +30,11 @@ import ( const ( shortUrlTTL = 5 * time.Minute shortUrlLogInterval = 1 * time.Hour + // cronLockTTL is the expiry for the Redis lock that serialises cron + // runs across API instances. Comfortably larger than the worst-case + // runtime of any current job; if a holder crashes the lock is + // reclaimed by the next tick after at most this duration. + cronLockTTL = 10 * time.Minute ) // @title modDNS REST API @@ -105,11 +111,15 @@ func main() { } } - // cache create, load data on startup - cache, err := cache.NewCache(appConfig.Cache, cache.CacheTypeRedis) + // Build a single Redis client so the cache and the cron locker share + // one connection pool. The locker uses SET NX EX on the same Redis to + // guarantee that exactly one API instance runs each cron tick. + redisClient, err := libscache.NewRedisClient(appConfig.Cache) if err != nil { - log.Panic().Err(err).Msg("Failed to create cache") + log.Panic().Err(err).Msg("Failed to create Redis client") } + cache := cache.NewRedisCacheFromClient(redisClient) + cronLocker := cron.NewRedisLocker(redisClient, cronLockTTL) idGen, err := idgen.NewGenerator(idgen.TypeSqids, appConfig.API.ProfileIDMinLength) if err != nil { @@ -158,7 +168,7 @@ func main() { } server.RegisterRoutes() - cron.Start(db, db, db, cache, mailer) + cron.Start(db, db, db, cache, mailer, cronLocker) err = server.App.Listen(appConfig.API.Port) log.Panic().Err(err).Msg("Failed to start REST API") From 0b6fa24c05797b5f9a395743d1da9d145f9629d6 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 16:55:00 +0200 Subject: [PATCH 44/54] fix(app): Make preferences dialog unaccessible in LA state Signed-off-by: Maciek --- app/src/pages/header/Header.tsx | 55 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/app/src/pages/header/Header.tsx b/app/src/pages/header/Header.tsx index 313b198b..4c30e00d 100644 --- a/app/src/pages/header/Header.tsx +++ b/app/src/pages/header/Header.tsx @@ -4,6 +4,7 @@ import { useScreenDetector } from "@/hooks/useScreenDetector"; import { Button } from "@/components/ui/button"; import type { ModelProfile } from "@/api/client/api"; import { useAppStore } from "@/store/general"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; import ProfileDropdown from "@/pages/header/ProfileDropdown"; import BlocklistsPreferencesDialog from '@/pages/header/BlocklistsPreferencesDialog'; import LogoutConfirmDialog from "@/components/dialogs/LogoutConfirmDialog"; @@ -56,6 +57,9 @@ export default function Header({ // State to control BlocklistsPreferencesDialog open/close const [showBlocklistsDialog, setShowBlocklistsDialog] = useState(false); + // The Preferences dialog mutates profile settings (PATCH /api/v1/profiles/{id}), + // which is blocked in LA / PD. Disable the trigger and show cursor-not-allowed. + const { isRestricted } = useSubscriptionGuard(); const [showLogoutDialog, setShowLogoutDialog] = useState(false); const [logoutLoading, setLogoutLoading] = useState(false); // Logout handler @@ -121,15 +125,24 @@ export default function Header({ ) : null} {showDialogTrigger && ( <> - - + + + {!isRestricted && ( + + )} )}
@@ -185,21 +198,27 @@ export default function Header({ {currentPageName} {location.pathname === '/blocklists' && ( - + + )}
)} - {/* Settings Dialog for mobile */} - {showDialogTrigger && ( + {/* Settings Dialog for mobile — only mounted when the user can actually use it */} + {showDialogTrigger && !isRestricted && ( Date: Wed, 6 May 2026 17:26:45 +0200 Subject: [PATCH 45/54] fix(app): Display account data as expected in PD state Signed-off-by: Maciek --- app/src/App.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index f8a6c8a8..d91b6446 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -162,14 +162,28 @@ async function rootLoader() { throw redirect("/login"); } - const [accountRes, profilesRes] = await Promise.all([ + // Use allSettled so a partial failure (e.g. /profiles returning 403 in + // pending_delete subscription state, where /accounts/current is still + // allowlisted by the server's subscription guard) does not collapse the + // whole loader. Without this, the AccountInfoCard on /account-preferences + // would lose `account.email` (the modDNS ID) and render an empty value. + const [accountResult, profilesResult] = await Promise.allSettled([ api.Client.accountsApi.apiV1AccountsCurrentGet(), api.Client.profilesApi.apiV1ProfilesGet(), ]); - // Save to Zustand store - const account = accountRes.data as ModelAccount; - const profiles = profilesRes.data as ModelProfile[]; + // Account is the load-bearing fetch — if it fails, fall through to the + // shared catch handler so 401/404/429 are surfaced exactly as before. + if (accountResult.status === 'rejected') { + throw accountResult.reason; + } + + const account = accountResult.value.data as ModelAccount; + // Profiles is best-effort: a non-200 (e.g. 403 in PD) leaves the user with + // an empty profile list rather than a broken account-preferences screen. + const profiles: ModelProfile[] = profilesResult.status === 'fulfilled' + ? (profilesResult.value.data as ModelProfile[]) + : []; // This will run on the client, so we can update the store here: if (typeof window !== "undefined") { From 9c173ce24709764d00cabff8091fadf968fc6f13 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 17:37:27 +0200 Subject: [PATCH 46/54] chore(api): Add necessary subsription fields in migration Signed-off-by: Maciek --- ...riptions_backfill_notified_flags.down.json | 24 +++++++++++++++++++ ...scriptions_backfill_notified_flags.up.json | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.down.json create mode 100644 api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.up.json diff --git a/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.down.json b/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.down.json new file mode 100644 index 00000000..783752b3 --- /dev/null +++ b/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.down.json @@ -0,0 +1,24 @@ +[ + { + "update": "subscriptions", + "updates": [ + { + "q": {}, + "u": { "$unset": { "notified": "" } }, + "multi": true + } + ], + "writeConcern": { "w": "majority" } + }, + { + "update": "subscriptions", + "updates": [ + { + "q": {}, + "u": { "$unset": { "notified_pending_delete": "" } }, + "multi": true + } + ], + "writeConcern": { "w": "majority" } + } +] diff --git a/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.up.json b/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.up.json new file mode 100644 index 00000000..3d8e1124 --- /dev/null +++ b/api/db/mongodb/migrations/017_subscriptions_backfill_notified_flags.up.json @@ -0,0 +1,24 @@ +[ + { + "update": "subscriptions", + "updates": [ + { + "q": { "notified": { "$exists": false } }, + "u": { "$set": { "notified": false } }, + "multi": true + } + ], + "writeConcern": { "w": "majority" } + }, + { + "update": "subscriptions", + "updates": [ + { + "q": { "notified_pending_delete": { "$exists": false } }, + "u": { "$set": { "notified_pending_delete": false } }, + "multi": true + } + ], + "writeConcern": { "w": "majority" } + } +] From 93700a31a286da9ebc49b12915bc7e0c8fffce18 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 21:48:22 +0200 Subject: [PATCH 47/54] chore(app): self-host VT323 + IBM Plex Mono fonts Signed-off-by: Maciek --- app/public/fonts/IBMPlexMono-Regular.woff2 | Bin 0 -> 14708 bytes app/public/fonts/VT323-Regular.woff2 | Bin 0 -> 17936 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/public/fonts/IBMPlexMono-Regular.woff2 create mode 100644 app/public/fonts/VT323-Regular.woff2 diff --git a/app/public/fonts/IBMPlexMono-Regular.woff2 b/app/public/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0804aaff92260b5359330f63e05b9ac25ccabeee GIT binary patch literal 14708 zcmV-)Ig7@3Pew8T0RR9106BC35dZ)H0HGiN067o<0RR9100000000000000000000 z0000QOdEz$9EMf~U;uu?K%G5`TK0we>7U<4oqgE|L^X$*rj z8;_19%I%W^bO(SBuPiMn*f#0ijzrPkb7(X&f{g=!B742;|Gy8w1P?Xm&z;E?bn2ER0SB@N_|ohnR#tmztr{aO7{OhZ>6~^sZ*r{s<4EH4Dw+^f;SyWMD_#BD1JaCUG$UB8U?kpU?cMV6-Uq|O<_fGpiN*+6o|bB1jrJ2rAzlx?#_2c zgagkjBm=2TmlU1Tnn7(K5WM|?iUDakwSHWmB<1+|}P?0FC<>z&ptG-EseJQc5 zFBw?2b#}sz4`c{~Q(|%m&viCct)AK3lD)!xYEuGG z&U7YI6WD%wR{d6~RQ^6aleH;nb8x9zKC&cxVxk+-0+iUo#S7RHwwMeY;_1g)UR)B% zPD;dT!vEYoEZTIUck@nZ)IvxM{X))62l}sFHFvkN8H85e-vo`Q@nyesFCci_p^Z#w zRAl{{|B2Jur{F?pJT){`Wcrn5lnf!f?d9`Ux5YtA1Y{))X@4Mqy8%dq zAnU9M8_1AT$fQE%6tY|)I}~#AzoG!t26*_935S4oEXnXn=ltc2FFl9$Aa9W)`^aDb zigz(=DbWhZa{htLOR-1Mdg5bQa_HEPNOj0m&?O1Py20#1OS5+!pW$A(yCt!ab8 z4Cb(a<=0vf1twYHF{=m^(?UVM#{*CkK(rtSh|cj^u;Ena3&jnK#;OdP7n{d;cd?NS z?AnFxd0B3=p9&2U2bg^ZDO{}Z3|_?n zM;NSeLajE+aWgD|@B_V&6c-^ukTj!VDHAsIg60q)A($Uf*c;Q-Nc*++E2fL0lk*WE zEbKVKz)$F{fDA#9$d)a84@i8O!*$>Oy`rmro{BXHox|hk-b4J^0umfr3^6!wAR>J= zzLm_IJu#@kd&fv2Oqxqtq(??%lDZ^BI4r~vN6xpCBQjQTZ=5g-gV=Hd9VqZk1lkNO zM>u^_Fk%Qlz+`ZB5Y-WbCM8mYd=Dg_L7aJ-LKG*f{Yab`q6Q+^GNnt695LVIr4I!1 zmJ!DwnxZL0RmoU-Pu9LB4m?dE>h6#*XH1_qb;{(S#0%$-#gQdLntnJS)ghr&{t2!Vo~#c?vkiUkKO zC}M6RL5_f^BLerEK8zLkKzcp}fqCp-MXcu>QELL0mSbT8SoR~Z9T)(BIqQOa%1O{; zrc?g^JtzelY>Yv#0a6M72FT$+0y5xXI0QV5k)8!QG4UaQdzf*+6faGkCjU>_hBr;5 zbLn1skWC?!IAoMZjcKZ;Yo^V@5@9pO;tn$moWT-$l3w$($p12`KfJhZmmo zk%}c^@Qm=K22I)&8@g+*UoqlH1kenM`=HncD5^pCjBZhP*KNT6|Hs7qf8*+Kz}3@L z;#JL6^i{%@J6GmiiC(F?TzlCC0n7l_13Q32YCwzvKn$_YA7W5&jsJ6ths6m}p-UE~ zK#7u+tLBfz2gOf-Mh%)IaF;Ayib~$Nrbv}7O;xhRYS*bvw;Z|HniPrF%0q#_W~g<= zRUBW))9nA4&x*x(~_QnM;&v@adl3*;H-1b zyXcHn`t|5FsLy~Q!zPRxGj7(DX){L5TV}zc6_(UnZoM_uT4#gRO!ZOUxvLaeq-gTG zMV4A@iDe!$NyTWZ&2Y>i9iFd`IhI<9y9=qiV~*z7tvMEHj)R(0iJLxgce=?m7_>FC zEv{aSc)8YyVgXQj=3{NhhV_FRVo;uU?5xyd&!k7P_9V4bAJ8Rd@DXK^x!n04+r&VfCmCJcY{VUf8^_5(%)e4Nwg=9oIyYunl zq84-W04ugC;Fmdmn$1*u{&~*dvs}O6vUA^aI61V2uLf%x=xNplWszC$jz(Nq?9X3- z@Kg`p;7zrs)_9e{4Q+$wEvt4^5~v=n!2-bvzNX?G4-k(-8bxMwG3<&b&xV79l}IXWnsD^wo8Pq2W(e zpWDvx+tFE9NUg+-d5N)=)oVwysbamL#0t-HA3=TWWd}hEbf`ZWZT2D_tGA)N@^K zCj0|SfA@2)wO#0(FV5zSMHkw+n`$8o$I(td3PPXWD{Yhtgc7+ddRJ?i^^&%!c8u1s zUh#OIQEFyU7*V*A9!e&_=n8kfWS&i=<~BCgcRX;Op%<)|B-|79EUY5fy}P5pVM6mT z-CJVZ6@a>R18A*;H(`m8hH6-5Z|rV{2ly{h0-1wnq3LRGd0-@$|QmF9w6QWOca#XK$*$R2o$b( z#x-+q%Ww|pG6#m%D^F&s6#gx_*4zlT0B<^x*szT{hBz0FmBh<3aziW}VDR11z9vOJslu3liO3HDTed(Dvz>z8Lu1usSSUT9^ae*W+@j~GV_DVSn4gj#$|1L z9Rq`+I!+nm7Lq3uNfLuajHWb{(o&63Ajd{he^#5SG)3o(R()AB*AaX{ib+etjbTg2 zE!o?$n9BGh<}x*dI}R@F?B{nhtvLWH1B?hrl_|>ehZAQWFm`uM0IGa*Z^>2odiU>% zI1#eZk;Ik^S-K@rWkc7sD|4SbkZjvvQD}@pEXr1!aQiS5yt$43C2pQDnf2+mjo6fS zHx9;F15^$eAyIh4$P1(-u%-a=6wy9$EO8xLiWQ!X3YLTGj^mE6)GE z@>}`C!-)nG2DrO}OHBfIS8vX-lQ!cgGQIplTs4X(`|x6i&4opB(dt*mp%@Wj@PMYO z<1055mTO-v#KPW@_W8+lyZCTOWr;nKOr&fe4%&r+WzkZ6xz(?EYGAl+9S710tCd)_ zIkU|((;Zc`U|=}=w$^1t$>4AU5MF*1IDSsz&Zf`pirG%U+;m{=U;Dw-{9py@*Z+9v zQt3zRTyRvFmauHN>6k!stI?WVoBBZo?pHzbr$YWzx__^IUiM4*Y3_Lkkw>i| zn1QjHS9d9Yw7J?l%p$sZN36yPe+=eN!l$p<;_Gnu;Sv$w%o)pRq6o>CTzgAh6g!LP z`Obnf4}h(1cm9QiVDUz`q$dF`hvqORVM}p4b#JJw9V(i`puoVm+}gwzmTi6Zlo^v# zk))x^6xBh!yuQYktbpbLB_db8lfheI2K&+ywOm)Oc!T6*q1=f^U0?GP8PV^pO`7x-Ay>nD=PLeZO2tb?yBXI!`w|BVMXmAC zKBonLI_`>DY^f`BgcAXhGic|gU$JNj3qtLJVOazlBq};E=QtN^i`=$87}Blu4`(nz z2t(AJZ8|`Cbb)~u)T=>4ZR%m6UenI>mBS{qFUSk-`tKXc7r_I#-*A~VOtQ3)-@dZm z3>`2TgOhV~y826JjEUUdO5Ya!G7Q;48Z@EQ61noKbbV148b?23cimZs{(eMC?*E(u zWIZU@``OyjOYX4cPtH;nj#_1|^Y?tT{tst|%bt|%o!&Ql02l9>+M63TwC`M5z#@3J z>aB>gCg>NSs{u(BbpATLD9|V(B&w!svBp}?g~t(_5Ai_{S;@%wzdVY@mcv_Qh2|;U z?So7MEsi&ad2`6&0EXj87=c7f0K8TToWZ4-P>WYwuy=)?AVY}3no~xPN+__ZCZ@iI z0okA2$hxRs9ImpycyyP+E(?1vB_~k41U3P!q&%xoQVAxMC0vyKQpo;3tBlZ!6GAU& z3e5LvG^{&7#Nw9p@SR`sXl0>I&!lfYdvACSjv}yjTJ67@S!=FF$y%YRxv07n>#Q=? z`XBrmz8!?B-!$o@YBuMJe%F=)2_h6l__F{tuwkoef64FFE9+m9F9}&rLm%boD?GC`o}6 z#C(3IN%972Frwu)(%kc`!zplhAmflqWas8wi+$QYrYRYNqbw&h=SETrw6f?eKw!ta zyNp~Qov*Z?0sC6Xnd|5q4w+s56tb*W5(1R*0>8B7@(mhDf2Qy1)8` zem4~b_if6Iq4|T=4c9qP2Tut9xhG>s>wJ!J%-y>72RB2*-~6yph``7qScc}wMr-n* zvDko};-o~e$P zG3HO|5Cv&0+O1Z0CDa(s9tLTuk-FyRK!2%&`+&(AkQt*BqsCMYf~d7Uh=beq?i_qv0@J zL7%eA$m`#=b^EC@g{)(GZ|j#OGsTig7<-lYr9_b-lH_ZO`s-*AqcC%Bk3yYZ9aA&6 zW0rmC{)xjBnN(YUZ+7Pvh~IPtMmXn#bhAuf><#}QelJb)7E4IG3RsKuSYygpDXh-cur8C<(p>K(Wu$56MDXk!&Q|sCVBj=vrJ)jH4roCbOZ*1MKe~1xFi&Vx3|iczNSxX3{79 z@m8+fIoCRGpjoG8e%zZBnu(&Ddf+dGZV3&ob6WD*@}mj36U4Bwopb`LW6&@}Of@0y60O$Q3OHj+Pw-;aiV_qb4!js)T<$#$rzZuxl!g zh8ghGT1)iX$%JlZpF4p>SafdTd!`lPV~Yx>uK-^@e&69f%rSHu7n>mFYK z547@-?soA|g)zWTEA329*7sZ9X1I{UKdDJf+P9sy47RbBW#lgTuu4HxX&Lm~i1?`^ zk33A&(dlh6D8xFUbuTyv1)>%@j{gAu(9efi{ef2_-9o5Z7u?nG=P1l=xn2A%yk*{T zM`0iqW|DuL5coIPpvFQ(V zLS;h4b+I}y_EV@P;?XEg|H1az>=q{4LIH7f^p71lzzNOa8uHOOUziAk-6@j5Mm%|;Cb}&&zTXo1!D(^63|Ru9@li|l4@Qeo<@l%!F~7skBezaF zvG-`t*leCDpOv%6L>R13xr&FJ0}aihRDbby7HD(p%&UQ?CC<(x$}|-;4hhqQ)hu%$ zV83ubaPWb*Jsv|8VoebnitVcNq*{y9K=|idurjsQuCdn7=cmj<>&9Kt8i*IUP zQvP0D7405}X4)*GbL9`?SJjS1;~!Qy#+!Quu~obEDl%_$VR$-d2bOm#~JfE;tlm-Htez>C@U0RCKF6M=DXE~GI={7~i8D@(IA#U|Sd8dp{3zcdDh#A7zh@|0`wrF$z%_r|AO!z{Om$9kZx@zB;T zP1v)fbPc>>O<+mc^XM&K*fr>13imDb4wl`9x~69QI#L-u25pA>H%E_E{5p~mqQA8* zq4ZjA+5Sh_ZmlH#5uaXo1j$t-)9d={bhwOWCJ(^?8$5TFhEOoYW|h zNkMMlmovd4eI|?T$k|BHF^L8f`Z)Y-X#G+en`nIz#ELn_%*f)`zudT5k1en|0ZqKx z4miB#NblmHmHl<^kM)N7#bp<5sQ z(3-5+`3h|`V+tw*HHBeEk{e;9i5%>n8DsZu=8~f3&=)X%C=|YM9%?X)?8fX zb5X+i=XOah?sKiKMXNVjclNm%Hd!R7v)>*20*xS;Lw>=MrY`f*-=64$H~UQe&G*v` zFekZolnZNM6%`oWAdXT~yvYZP=)n}JULseTbl0rt74jx)J_eLI9Lk?>QJUi>wNKJx zmZ1Afv4t=wZL^haaUZn%<+TN!TE8~H28Y~(?#;IHbGWl-FNv{(^3Cq84lu#3?k%=5 zbogZPygc>w*z0OW=Ns?oKv6GdZYF8aBkz)yz`jhQ8i=4%?F0=(YTB2uCDQ+PqBW$= z%!BGr9gM!0h(`qGFcnT6W)dDDUiL8@pQ=rHZ)Dec=YQ+y-AG8u;_TD&%uP@afi*^FJSf&G={dFA!Dv$;8b^TQ?=|CRtZ&)#-sH# z8uR$5rzRs)F+hAO-cS%bl~;_*!WHwTV#mJ-G4wGX=4TYm&bG0s2CNd57ng@3OMa@^ z{+*xmgya8iPOeV70H$4_&gIUI@xYxMoEzXVp4q*7s|!BLgMV04BwoN4J9%ac#_1sa z_~FXtMVTLAv}T=ZH&uGzJ?4qrM4pO%7{^%8DzkgTJ_riiY)}~TL1C+qeT$`F-D2Ba z`&iS-cYoSUCU5@fF4>FJa9Y^x7S0b9@O_^|Wv#o*+HYFix9glPOLtCl$9l&z8?XCF zY;pgd^wqcjPro8(!F>I4%56*Un1yd*C==DDZ*dboIA%P$+80U`xAF|tI=QkV6Wfw% zznE5Ph$^VEgGbUK#89l4E@n5HAPYiu^#XX|s(sc1O_c&Z0CN#*&iB^yc9=8Qe5wG} zyY1U!#_A(dMBL|3_x7p#B+h>;1FE*X`TqG3hXKXA_PzC1U5Iy?zHF$<1;sRn%w~PR zTWBYjH3l>lVhWo8JHhNTj>(1Kmxx?er*Jjcamq4K-q28P`BVG8uNDd+0=-zP)h`aM z*mcd!nVI=aO{yN6F{5UvEU7_Vr-i$zt5@vUhZrpO=>T}$Zj(ad2`vdpJc_pC z-w6i%!fu`t%*Ex2t`~-H%DyqYAUKpV6P~7YFErqfgMU~CZ4E2PKv6Oc9Wgk!WdTeM z-f`ZK6T9lwQ-2(_7RvN?or95~JMfv|I4KkVtoz~}#2i;NC(#3M(eoyi#XJxZl^V#a za715oWkqbQx)y|2OFcghgRHDqH|Pe^c04KA+)xu&tM2XQEz~Ffc)L4cL!V~sX=TfB1VzW9!&VWV)?4VNjT+_CF;FyAA-$uaX zS|ZI^+}R zB%1rfcTt&oseUh2mFlfiRuCy!*a#sT|GX!M@%_Hwk}Elx%hFQbd;dr>P?}slH2s|a zInnz0X}|JkxudWgHAAT zvvOH<%Yh<#q0!&y4+gqhr0+G+%mE7v`Jpru%lJ$ks~$#!y!7{}#*8YpDkDlY zzE3aIa&Ry&z+l>}ogL!0`DSjAlkti9v1@(pQUPLL?X=w8Yl{tgHUx_IKB1 zVn9@hR_Z~0$geTw17*_MH1NRll#@d@SL%Px?2=qqyMA1ui(^j1&fhi~HvYBK@D?{v zJAH7*Qhoo~-1+wfB2kaJGN-xDZ4%I!Qg>|ISz(3NnqrsM#hf??T;0Tp>s0-8NIMia zorNr&&M7{ksM(Y^+kM>b49#9mnYDATh#2aAvz1~Zo#)us2QPh6{M1_t=JXxdw~w83 zQp-~04am*(`8t|M2yuONp>b&C?nq5zSs4#KvU|ge-J)TxkUK2e9gUp-(xqD3mG=b= zyeNNRLh$)j372rTBk)-7#(4+K{Fa0U@&5+2pr(pndd!?An_GWA1s#h@A&?5Xyzb)o zM>w@k$t@IWw^Uj$2J#E2_FQ;A6Nl}(u`T{IUuNd9$#-AFve9&&U9OkhW<0&wN1{&0 z#!4J)!8BI2K^^jXpf|_}kGCwyqdaA)SWhYa*~t~@A15Xxew_YGJ#S)T#n-{Yz9tEX z_40o$J-lN^NvH^sxf_N0ah+k?DUoauomp(R%a8J32gudJ^?yG(&L&_JbK`Qw1tdO+ z@J)JL)@@YLqLumKLh*m}g26NJmnTSUP}FVQJ**hI?s;6qEz+uH?1s(U)x@ag_uQL2 zn~cR5pkXK#CihUcDQV)0U~7Ck;)6d4oSS?Bpz{`1D*=;|JY6_e9(Y9yYzVrBmy_5d zTmh&%fK{WBXf^iR9TsR=dT2QeJA8L3!^J>_=)6Z_ZeUw!_S1VrUe;7^E3!b+e2*Grd*%1#BmmG&sg z{iZ)LIepWQao4Yq-sH~RaxxEG%3f-~ugglwT8Fn|H*oRTe6s}FWhRowzv*EsCLxvo zGi}+W_L<)=p?a1kZ|l$b@cq0s&nq(n{Ua&|^&{H;7&tQjh*zN}T1bCURk0IwYRRSD z*U2&}`Khj*+glfU|7oGV%gtHcJa9`^*BRMckQvFy^RdS?6x>Poiyh(o%(!UXJv=$w zvHsTnBKIxAvtxXB=fA=DftQs6F`}xRVlgU`$9V3bW zMl^8(RWYZZvTs8}Ty;8SSVhl!)6Y5<3oMZO@Bvz0%<*Efo?-vNz`rR;1RmSL?_Y`F z7eWxy4*!vZaHF6d`R%Dlo#*S(j&3j z47&u79j*nbrG$cZ#09CPgn|JC45cfzlu|*!T)I+A3FWMNxB8;n1Zc|mti?l9BYvd6 z9;W%R{zjPQC;IF98~U4JnxE=#g=zgWey+cvzoowurs+k&8Tr$Flm9ZCImE5Zf8qZ9 zdJF>z{yTleR4niHtKhl*>a7^wN-X+)guLjR=uF9Qxot5IOSk57>`5<#(;KfSkupur z;I_<+Z<^f9WE(KC&8aJN)I4iSReVM2kGM)Z?=SEOEqpCYRD7%Ps`q$mSaUMuL zkWT-)8}O*z_%Gd2)yz@d%*oQvx!W@(UgBW9j-5PWxEn#oHkFQqJg%yPG$A_%9(ivT z_==iGF38ik_*!c&X8g$gfKj9?fgiaaz=l#L|Ac*CCPInvh{4@n9u!LPvBQtt57?e0 zNV8CCDuZbMvL|~qC&s%cqbhLXy}Njn%12RpP?V%<8^)cYBMlfFlSNZ%G(BzF0GS#Ep)EG&?N|SQ>4l9g7MY?v z&ly=E)?+k53;rEj*5NlPOs?uoMXNN?KEf1DTGr3KqP`jnWR7t$H zVDt^)0}UReApIa{mC>8gB{OD%K~JD7y%j2m#j5dyCCNglstVM&sz8!6zf2}c73BT6 zn)y(?LgmRSqCLt`G>)=U0P0DpoGbELz$KP!E0FV`nH9u){1S|3LHr9Pq^v)qc1>$w z@~hU^rmDI}WRLZU%zv(jo~ZaTmak*MJDm`HbaMJHDt_VQV<-4T$rn!0wo9^$w-LEnssgB{hX5igxr?vd512rK=LF1z?r8r^`xb`>a|4*Hwl%0PPhz!-7hir zy*_E7r62e~ba18@B6Vm-#=v=9x>|V>_bT|HR!)bIc@~uB&GDj*9RNA~4k=PBm)xRG z?7*+^&jljLdBU!)MvE}fNM-+NVPd+L^S!oV#t}s9&@d|c3mUWro|$Y#WDKjF5LQTe zsge%~iDE4DV0~zCja@o{f>30`T)2QNlxYfp&WQD6ao>|g$Q#E2g8K+y$nW`llKYey zPW9U`5^m%>@eEQo;#r&7(q%A+-&N#IHnmoX9k4$fIXKQnW)q_gGqY^8^ujj+5(FJ1 z8@Z2_QTG`0{mMxHV7`m->VZ-8H!bE0?Y{l;d+8%?7hRZ`ncE3$&D}}AR z0>C49LE@%fw;-*G_x)alSasB7GF@asyFuwAv$Fow*3^T?AD}q4aIQ57Xy`NywG)rR20c%G>0jKeCX620H z4Pptu8$*n;bXqF`^slR?kr+F`$c^LR>*gHWB}_Q5S_=b@4;VRRl*RpDn4es5AJ?KD znAj%FC0iP<1SUeHp zNi)o%VXB@ju)mu+K5nhMYO zE+_!ZVN{H{d*e2(vK4uIdlE;m4+Pzw*pDV;>xqAxP%F10nfc13jACOco!(Rs8a8{6VEbe*Q?>WDA?GI(xH7|Z3Wk3p=*RTKb)TAX`mLyPHuY3 zW9cjc5^kkf_1RJHOdZ`FV}Z5wBpoE3p6jUufa%I`jCa;6**C`3sSbT3rG^M*WvQq z|HW+1HG`^2 z24a+U%ol~w)jW}o73ys$lO=fsfwGq@9B2`sqR*frUf05x=v813<5Um9oPEWIuG~4f zb|kcJT#?xtgGuVcIEN?mq}NLm9=Jt^TXmyDH#&syPO_%pSe)~O62^@V0V!AqM+^WG z@m3MLiIX_yoio6CS0)*KghqVhL~EyA>ZK)W<7A`t7E@_mS+uP(P2IR#kOpYkHLP1^ zf^je?NzWNwJr7{^{37N!Hjz%Og)L{!X>NVOLOj(h^eV>{2#h@pYphQHq#Li1+;o@2zPRy;4;r6(wq z`%!fEOM_a@Hq)w0EOGx>Iw*V3>+Fyud&)g!aIbvAlM}gPx9*bi@O)P6J(e<#TJuT& zOVRL9@0W}};ftry-H^OmyD%cfo2`w0{J)R90{GT%;gcd6KOqhykPkd5D>*rUaI0y* zh*h*Y>3b(V?$w3fxpy(^7Y?9$-t+^96;>NiELst%$x@ajc+3f63IGSxeybpe?O;}~ z5E2x%&)&p_ej-X9YCxHM@zM6Fsm`BH*PST?G2zV*nV42oTTs1sB=KLyp}>yE7t@3jgKdfks=yB3ZML2}va;3J^Xi ze;~;m;+yic->_IKnIRo>qID;tz|%ueuo6zUBwSGKa-r(ouub_!v)DixMQ%V!#zjRHecxvaUAs*l)qYau)^_n{HkS_={hP zinZ7i{L^^b5HA=0iwwO%{7dF&toJcBLjrW>%=x1EpAESU1;A2*Kx;WpTTaLhxb0xo z%!Ny^x^K71p8UoOp9oCSe9CNK#N0`+#meWLxuMpL>;aen&Sw*Drva8_`aJP=JU3{j z3KE-$dM7|y2yWo%f;I@KbG=&XDZfa)#`zdhs&Cu9%8S*~PfVlt0txzo)~z+7sf3i3 zUqIR>U7XoTvc#?lJl}>~J*#VD&!X0Q7bI+TLXDUBLEMfCM|ZK~wF9t|i<6j!Vj(#E zopOTllxoKMW5~vCzPlQmL`FSzaJ)K=Sk$a!J<3%C)DGu&Bu#PP#GJd=$WwXnQcS%F=E)>*yyP&>PzJ<807pXt(!(v=ou8@FY$!`jOg3Kpj%7zoX()b8Qkb1z7O zHNDjm*FqwNxBNBb~sUVV}Q>_A~r75M{=uJFoGpu8vLR!*y#{zmzPj($a zGDT!P0gG9W5LR;>;ZBOLlNz&t2PL4rzE)?}@yFA-0p{{ahJjZ2d3djGbz07d^Hix0 zaVbJuA9g)8v%i*ch}Uo^s_EL`7s#B4bjTUg-+Ueh40CgX-&W?OR_hTfo({fpgMLn7 zO2@_~E?&dMrjo&~7%XP(y1Hu&ee`yXk6gz!@b-sv z&X}=;?M-*5pa*c{-)?D0IfYMy~4cmMC z{#P`vtat4g%%L*R{j>uY+ ze;sU)AqZpW;!gu+easBNGQO4yC1er|Z&-4=a;@64Ytu`R8o`Qn#iGTbwS;HHJmMZv zYRSmlwrAVR>fI!&RPRpX`ph}bF?sZaI-eo7SHy+oj$SvMU|A&Bp z%HMec`48aIlD$J*`M8aB|MTDWEh>e9HW)yFe_Z-@s%DaZ4EhY2pNhv*5Xla&rxXsf z1jOQ~(x_YmSHwZ|3De`CqA8<=qaxyi7mQIRA&Zm?6e~@4z@VlSn+|sJgOQP-q=!o( zijGHQiB^ncDW5tUEXu?J~L?wCfab3^?B(ARdpm zm~s&iKs!kA_u&xGDhm#k9003vmlZ_GvkFGcjS9}*T!q0umlNJAXAMX#flU&5iV1SX*i|ZYDaV=4m$Chk}F?(0r&Cp<|<4@moB|A@S**3CzT@mR;cox`|K zfzH$j{`NS@-S77I^jV5m-qa7{?=Nj|%fY52ZU2oo);vwd-#UF?m;1CI7tZz%$nNN9 z`|NCb@6KbCt!?LtdQupF*2jMF=QeHUKGo-`HUixs5H%6;L=e;u;Y0)?URCeo9rq3g zj$Clb71un)hNpRkXL)XH=j&^))AwmTI3Yp%W+dYzBrN(uVPOGWnX`5sBt{Vbzy|n; z3`NEPew8T0RR9107ei15dZ)H0V|LI07a|-0RR9100000000000000000000 z0000QR2zpt9EEZQU;u>x2zm*c77+*vgpXi@!zl}cSO5Vw0we>690VW*gD?k$SPX#} zTU-KB!H&HI;tnWC4xf*OJdO@evoP|+Ts@4WjS=o8Q=~pHR6Q&+ONubJ}ERwd~Hkg=G4Aie)f@cn+jJr|ehghZzO4^2d? zN{A$v(8N+AlPTp5v8lPShRR>^P8G()E>>Z4Zj#Ywmi%b4?j{~)n(o6?VQw1UB%gby zz3qkiDhnUNO1cYvZ z(CEsJ@6`HE%xkw;R~xg)7tLC3=H>36v}nS9P_NQFV_-GxyGs>m~%)Y#g*O7G0_{bGu*Z(W6jm0d0lD9>Q4f^ploK zVM5tGUzv2VV_2)1T{|nuR|rJ@K?#cvaUQxbv2<>~-!JnGx~Ik_5B5x{YjA=%BBJ7v z)E*x{_y1#>4`VtrI_$Wa=A3{yk)ePeNADWb|2|3fWp0vOuPN%&jtIyCOVR7|LO=}w z*~lYW7+@H@R5SFSS4A)s8D<1#q*2Hb50OV6;huPkd*L;XfV5!MSjWWN8a7MWi>J{e z0EEC0E_r}KlOs5$?%h*mRytsn4bH_(yAT%B$MSBVJSUM@y8vt2pNhguhD<{BMc^BH_nl+y3l*I1@K@B zjunf{3(o<0mDJHoBlNgZk9|2u-Qr{@X54dEe*g;P-@Z~)*qPlflZSFOp+o*z&3#7i9HXMooUL?J zi5@dHM-}NZWqnkE4ii>K<+K^IJR+mTh{d)J(PX%4VE4VqGhnxvKXsOmPv&11Bmu^m7LJ%8zR@y}LYiENt$ZfHL7SK6R_mvL zM7urG8}MsC+zT3|Hmi3BVIG-Th;urJ2zF@80aGt~)(nAu1H{YNKX+2mC7qiKoQ8@f zW|~iPiq-PF3_1m%BWnYc;eY{qVlj@Yg|uzq`^W?qKn_0yZLmjJ2rK!3nH$Hm*lQl`}iqPY+I%D6aL+$|o*P*l6 zN=qGc#WkyJw#6K$EVSJ+Yb>zZ1?QZ%+*JY{y7U<`W6pvlJN9G_969sm%a6YRfx?7~ z5GmS5b8T|pTA##7kt$uLJoyR~s!*j`jav2Eb?VZsm!iJ`1{!3j_3ql>lEaob;)J74 zy61+EZW?K)%XYZsvpX)DX1aGCx^ABLrW$3JS!SAT1q~vJ78Om}^q4YY%!Dl~)@&GX z;?9LDPj1fe5GqKp5K(;eNDwPdl6Z-drOA~gTaI$YN|ee_r&)tWt(u(Gq93(B`Wmdm z5MJ6m^2#<_?X%xr``9#ZVrDby46Mw}+;$zO^V zJi)sE7?~Y#^qMy+Z@j=Z{BuH&a%l_aJ@gKA$0j2I=Fk98W}60vR^kOhrjO%M^f+Bk zu`Z)WSbF^_dh@QleZtE(WxIZLBdOQcuSpg8%3;NkdZng2G7=*cda;omU6=^D=s8K3(v^b|ES^ zF8TaLy@m)NlM(Roxy?gTLn4&$t2kWU5F^!+?$}O$Whl#T{!cBl%ExMSiR}6kJb4?d ztS0Hyb5CvG)pXbWDn5Dh5OO|t<#jVuiih46i>Cy6AokRnBwMI~z~Oda+k~)8)Q-zU zS|~RsbsoR+_C-1RoCpf+LDAp&)YL@Pw0Rbrtv zVxx89pbg^2j$uA8ij_Ti8%-_voE&2tPEJ&`%YGVG6$fR%@6wytBFA0BTX++fSnxI$ zyn_YrV!?Y@@IDrNfCV37!ADpUJ*XR_sK03@&D7gc>-$kfE_z|Q-2n8R{>qjOAyVO>etD!&2Uv!+?vKKsdd4zSDa5JCeTe=Tc&SLMVB=gz-x9cDoO31 zM8NP}+pd$?YS}wv26dV@wjH_?eit>&(H`v@7Uf#OY4g+Vm#7@JPpdTp+MNI5HPedR0m={`FBm2D zZT!I}2TJ#Y(}C3#7SWVLm0e`3&5~1;oQonaWbB$T0W+@*!C{rC+$9}?IkUyzq`@R) zve~fh42Lt=loFh3R_wFoQk4bt!0T2vpbQ$wGjyEGw*i%VT@4SAF^zf^XSUXdd_i7dk0BqSO+dX zf#;P}mU{vZyH~{>}&UO*IF-Y^c#E&)LvWf z+o17Mo^+vg8cSVn6C_&(4&BkI9hNBZ(YxBzDfDj}Ouvijv@Dbw$`O2@n1?Jl0L#;B zcDC(&a4c-$lYO{%r23){I`>!Qbm8r$;Y6#&e9&)A?&(PLxP9;}nXO8B`1uHxP<)X2 z+81Jsy1Vc}r8k|={*Srpe6 z)!RTVY>Fxiw1&*;59cSq5BT$#DtZa)m;c z=eJa=LPW$JGQH%|f$N&=w0a>3l93FL7|#Voqpu;72n2TC%i+Ga*|!h%Hjz+==l8a7 zl$BbSb={Pu3{9+b`SAX#?pNXW;i*UJhDJAO8Hv<&N!49zv!~X@2YG;I!lyrJPxhKk zX+pEN7L;PN%ef7%EMhuD#p>5ZO)4cRV4GAbXQiZOd(IO-|2IBy&DUa9tp&};Isw)*S=v=O-&vY?k*%C5XrLEjyS`wm; zCzRLnUm`nwJ|7OL!a5*#yD0pzyRbbsO4&m>F`OtAwA+TUv+cpdmwI!5F9_wbp+OZj zBz=jfejre^tCY)$csxic3$gxb4o}1n#10oo)?Cn4e664<7XkOW;$B|j0|Z0t%q32>-U5uL2B*D*$? zU{XP(X*#i9udwg8$VHxw-aTH3!ho$qYi48%5Vs3E^HB zHS^)M^t)rJ;Fkh@?ckfQe{N4-%cl&e>7<|;dOg(qIpxjZC3+I(q!Gn(Mi{LnCYwgD zCoJCVd{CHQ_z*SiW{F5W2Ag_?=x`f#D#2H6#U7jJz`V28lz>)~*v}7bIsF*Bs(ZR& z^ZXh;6sOsLqU;kfvIb9BdTOdP|0$(zPp6A;Iw3@44uKPao^BR!S_tS#sqQ3>pzKRl zc;txd0j~RObP1~eVl73eZe4#ff+&__>P z*QDC4E0U|T;sP_c6i~ag5&g<_d5^ti$Sp#DDPaYUL2~xjqPW1UiMX7sgvvUX$LBEw zg|5y-c)!vP^RxW(m>?X!7}E4i>^gnDHo&9}MH6x2!z+{$A5heahl8qrg4l$$BWPbd>gMQ=t$s=8IRlRTWIZQi}KX zXfnO}xR^wbxW;kXKjRM$qI|>GYxM<5+6DoZ29RCSps8seS)!RWrf#lJn4N61P{sAP z?g-ibJ1(NC?3idY0Ge>W!>a@UL#v^3So6#HoQSBObW=oa9a^}z!AP|%VeSZ>*0p-` zq|i3H?>b!w>?C+~;FWfoZ#>pN)1tRa8AHg$yLZkNPx`aU7Ejoc^ZR_N8ST<14adS| zsvtLQEsL!jdwyp3)lN8;1&0w+fj;7#19#+=bkPgh5mR1(ychc=XbGhG&(Z&V#-#44E`KT9 z`LsjJ*00nbiql^f5?0$qRd(v3M4wj=o3-kQU6h>ZzG+KbDX+NM`;M9vzJf4@_I=== zHsPICb)LD@aGMucR3ZFN=WntAuV`B$=Eiw#uASvs|z>G`NCAxR}XqTTTuG zbI(9et1EyJ`?JjJq*Za<;54B-G&9Cv>vAajqq*HsnW29G#?J%+8NxpAa%m>3Dm6LY z2fL^+~&p~AQ$?yeuIf&Pzpk53Ac#HoQ%J65scFFvgnHMOK>WMcepWf zk_rAABujU$B;HRxjuo;xv@thZk>?}5z~2yXua8Y zbihdaspKGHG2*GHO+b{kAXTgF9&!yphN9k4HQ>V<+-hpraI49@IDT4%5j|#TuBr({ zUFO5p({6}QtsE(%^DtP;`RMKIHF7Zzt@yFB8(&fSHMOQz7=+ke*lS+MwY9f0tmcTDaV;6n(?*IPj4YqjNPi=0K6g^5tUuF=L5Ldz}a zKzf%Lo#DG{+`FWV$D?7F_hao6TgwE*Hgvq;-hwW-!eQNNWFx!hC!$o%1~za1y?YJO z9wUY>&ggz>Y@y0)Jerg@mk*S$vHLI<6!`thW%ID;SgGi9XEQ-3T|) zFr^!&mtC>$`MRzt@a!|2D8+M1w64x8T)c*ks90QY&z1(nbjxl~65-5lQK%b`5fC9_ zCK}=%kO}z}-4{t4$PghWSWgJ%VXB(T1n_>;yYD4g^=p?#KlLCnrGOY$g>}lB#W zNpH(e3hj}RoL3)vuHn;$QVp&X=e?0%)3^x{_Z1|l_|wN)EKvi%v+90_QmqHz=R$XY zuew_`aw_BU7^%_6x?xt$UZ2nq4~m?Ws~W@6xaJN4o&IRXG{e2&>?KGUmnO;lMTlYO zn(NW)iKF?hgsg(rjAVzjV5B@E`)X<~-~!nn^*agvsY zns>Y)1DVF5LRPU8Gx6Y6a-$b_hNHxF11D1ld>NE+7JA2__T5!`)tGsJ0P48~?0e(N ztVwl7rPoX@jC6Nv(^&|2)X`=|O<$fkGcK}QjHIP>#9_Ge2We_9oiR~i$%y+vX%q4a z04zR2H4qbaDNX9=imillT-NWx&AuV#)DwDVrPE=W1J9~IryI6U9B60#(ch{vWg*Mf z{+6K)o8hfK7w#I%vvKDnWX}=Hji8+>p%HQ4BD!8E;x$k^Y8T;AAAZ^!78%aBYtv&hvy@ zXt#IMF^X8YTFvd~DirRi@7~eq%e9*$Mu1Ta0*Dav11`o05Q;dlt?^+*zNZydiZQ^`*e!ZH5le0`JCVNf~q)aSgrG$URg z*p#|wFNa~&UsVjpcrMm|kOa6}i>UL6gR(8%svo$|Ru8nZwE)L&{ zcwgF+_;ZW?M*Ime((E5SWaRy%r`H;YR0D*LHaic7n!{q;*!j-rh$~e8ttd8Fbx?T; z*9C+jTBa^#ei8P@G?9MNgvNOjf zJVhRL*FHAZjHD1qlNRGfyq?8@B;P*~q08O?{Wct(x9i2s6{}b}#MN=&?b-nY?V|jG z#U9CxK7*-~`+aPVQ-!E<1ktLBirYcb4e?)L%ne&L{#e_O4{B*LDqz8AesBu80}-Ji zJDt6jcIfQGmh`VztP=*u8nmjeI?Yd7y?_-BJONJ&!3RNQmi;pX1BdDz+di$Me;%Ts zv*8}+2pZ2AMB_F}#8B?>-0O7kSGYTTlxIPu$}E@kz!$Z$>x#Y&v#*xMnwFqxn# zBU@13p6r-PKS{AOywIlQvBnq>3BjXihJ9tSF|5^gRoh_sMr2z?lx^MxvuXCSYxA~q z;ac1GF=u;iaYVKf1s1wOBtKuu-`H!f8>R+RW;}Qo>4htbDYFYa#4wep6=JIs)unX2-#2D27f6{By5vI15gGPE=R|flHYU9VrvCqRNx@b^Dh;Fwoniw(Su0c%f z%_`tM?QT?aKz6th%WTRaJ`j8eZSYij?}a3hkRoOYu{Gwk4Z&I}Z0*ku@t^oV$1Zw& zGb`)cDk1~lCC#Xz2%0tP4i^yBt^w%sGoKABl%&2#^y57ihS&&@Hb5a_9Y_NiN0gzW z9Oxd!3|-`P56agog|K2!lp{os?b9pRG8*joQjcNP`UsXdZdu>@B|#u~Empb~X$(9= zQyiD^EpJ*lMZK2DPu0Nlm7(vovN4}eMw@kMVr}4%p*ybjgdm|a zvTT32ux?p`USf#ZN>lP~0e+`BLJLYsdnIO7r(mzrqGFs>+?#6gbIjx&S;ibv_u(sF z@3+s$7ByhZE9<|NuQTN&|35NU%P@fG48eI=fRp!F#P0l0HUV~JQ<*vyHh#&C!rTP_ z90-i+0A`Uj(0j-a5IT*G2c2o&?&Wnn;`Vz5GS&9q93dNu_2mu_ZOQ$|m%W5tf97>^ zGP1&T8zOoQ|0T_0IHoBkHTQP&>p{6TZpgHE&Qwe$9j}@JGiJL|q*WNfpu zf`RJN`Oi%X97ZT9{V%DNw8J)#_#o>0a{zU=*!G$LF39~^+kq~cHYk1%_Ug1W8ZQ^7 zMKBHT#idp%UzN-!p^Pm*D1xV2kYWF1xdG%z0G048rnCA*AKD(lu_ZW&_2c_Giz{ ze>4tQsEVieTppJ3H+e{da+w?EnaA1xY4f*$ap*CIkE>@$HVzk?&B^8mW>9C|Gmdd4 zVrMKV9gIU2cQPPNw~^kKxo~N)^>`Cv==kbzE}Kk5!alF62z(PU9gxbs7c1-U4jaWS zxa`IGn&EpQo+`C%{kpwQV&mp*^6bgiZoYmFA)s^+LL0PmO)0L`J=-)o)7s7mr5ube z!tQitdwCn_C2ZqZHqLsyxiQdh1UnBdUpja8vEySi8RJPee~q`e+RZYszSA&GR!e1doD9C^%HJ`Sges=1Y;) zdwxalZAu)y)3A)hr5^F4YEHi{VC+HTlD#aGTMEBWz+5_P8OBaTjeG43Ks%#oGRRr( z=qn@AJ+H*g(+Y(^;R?553{Br0b@Luu*YNs!IqVU27>_EOe5;P@Fceb;c zMBMS)7JdFh^k_uXpU~;09WdQXL6W5!`V#Ch(iLDw%$Z=Rqbg@aibs+%dH+a5^o8c( zc-X9wyx3Rvkh;P--rof)vh*W9iu^XpTQEDSPh=BM&yB@$>2-xmbTc-)y}AvH9>Q0` zSLRJoU@ym`p;=5MUM$hDhsA3Ci9dY+o@ANAnqQXk=aD`Rx1$VAt1^QfLvl;q6(ZVW zp}noqu%4kCz|4}0gju&L54Hp$K1}SNzz!~FVESAu&PK>m2YnUxBA`bMkPPEMC!qJ> z+2y?78R-oDzWi$iPv0#OI{wOtrd-&MrKT9#WqB4oF=5nc?1m(!&ss7foq2g&v|)w) z#nATUp=>4+cKou$fvCIcdK3F@ChX|)r^@uRi`}X-GRvSWBc)+q^o4T7Kp4A5b!Uu* zPsOpVMBHO9jAv8QZroJ1ySbfqRavB&!bK&rnEU9ZkT~-Uo3l@Myqr$j2Jwenu{4RB zIhl|BRQ`gPUE+6==|aSa&CqL;?Ox|B#v(8z$4_&ATru%{mrP8tf5~lmk*KxQD&~f<;ziW<1dvcf4XkYA9-H? zFdp(verI1-&NUrXQniY%uAIn{c~6C+>b=m*)F|-j^(?vSw8e|&&7C=8ic;enKh9l+ zVS8IG_3ITEk%p96xlldF4|7Du4q1+jj2kfT(^zRL$5vnU8=bMrS9)_sKN7GKc29w@ zNT-6Rik&Sdl-)V|w94}CJPb%0eZJi+=1OEI(sA5WpCpXYPuzEE5;s)pP^JdvNd~N* zTD|y2a2hWrS8{T8TAS`}6)-aBkT*@bNE>=F;We#L7>rzjYoW)kyOA&`0M7O(erJ=> z;Mz?4R_gkrH|sFfGEbKjwlZC%&t~}UT{W1lywsWJGh0;5Y+K7!=H9W6NG}wXs8d;C z+-$2>peV4;1BttJeFg3dm@)$tndj*9<6)6Fp?l|cu28WU;X~KK5%_&Q z2E!#DPdUY4%7IBhEm$!y6SkZsih`fudLe=AzR12D_m);DtPQ$?5krd_1sg8RUaBL- zf~uX?U<$&_5z6|v!rlvi14Ls)k2@%;FOrg}!HO)pU`Zm;+^zDjB0c3M1U$7o9L21T zq31T$BxE!q05GD=7@;~>WK3Dyk7OAQ{Z+(C#goc~q@LatM8w8?{s1)|@tt_=xaVvv z@iZc~Sc^=!q!rG1eSmY0VPK(yK5`+!S?4u<|E)dCB0hrr1p2-%)Hm0m`M52RhH{LN zFBBCmvLaRGT`F6B7dB;kz!BM_GeXO#one@L19!&L8tzmA%{N!h$;TB!^#O&`$_IeS zyL`f_lWrdU65oUuHIZyVb{I2UUlF2mq=gG8 z(3rQ^4kILz9#H*ovzWAHkyft+7a1TJ^eJ$k<8>vtt|&gT?`9H_fT0NN*%{$xW-auF z^qCAbg{_$1QmOmcmRwz!vpP!L|F3xGvwZPrZz~-SJ06*5PVXjdt&s;8okb4IE2h^h z6c;0B%WNOu)=f8W=nLcBcDYdXIgug%YVN8+aXL{rlzs|!>ulDWBp7TPY)${6VsDET z&%-&1Te6=FJYoe~y%^ZQcXiT+Cc`SUM75^(O9}fFsU!1x*0h1wx>32p8PgsBOu@A8 zj;E5*2(6^+8f$=`;#1$2R+%C&7Y=u$L7yr@X}Y4q<$V{q{iy`19EBE1mD1SD*E<4j zWFpTFE#?joZCo-QCnCAfnD-eM@U97dW0uxi4R~TQ(Tmj6ot481+aF4xt zWg&rsty5=Io${%u2Xvpo>)ACJ;>~SBt|HUle^QaTfETAvIsht``K&v04xYe{NH;!s zSu@KD$ru{m*{aVZZCylT5LH}@{d~P{HcM-tnQxao{KMr^F4HhtGV;rUknLsX*{9m; zxesU0Feq^axwowo`fS06G_KI3+K|koLm2jX<)u6wy#$=h#bT!)S*i6hOmsk9nqZfT z*}Izgg$#)-oq&31r&gWR1xd|;5rXgJUIkCstFo+6Ky_OI2A4Va;MK#Zi33nn^Zz15 zj?^5v#!5guq~nQHXD8>9STLqc-D;FynV*h4qsfAnKVxI(+R&D6@Y=jn z3scn8gKOdp5Og#)sp35^Gt@a*jg)|cB=+I0X)K+^E$#7z< zS{I@IQPpb|j|9)2qoctO^Da4`q0DHs5p$7%w-74fiL2*Es1J2cDYOY9Z+;fSJ>KP7;3j{jqLqrTJM&Pwi?2f@tGrP&48zW_ zP2IEK6L({j|B!c65UgTf;qhT(uMi)?AK;MCYgz%Mo1!UtGU<2>69V7x6+$SO#SoZy zv?U`n;of9Pbx_L58-da|foq_=TBV{5`&E{slqLjBohpz;jF8nrJ$6xNmP?;1X zxZpWuPb)bSX@=bt$W!$B;Q(2_BFyLX*2jNH{f`Qn#3WGN%eh!6&r{?J2C7}4g8IOm z(@f)IuW#{zse1B6Y&@HUc2_Iz96;I-OhS*1NH3NZpYycBc*3~C*~ZXixOnxLfvtd2 zE6Y7_{r;T5u2a^s6MlT(5m*wxxz;B>Nm0O)b)oe8HV0z>R3%&Py;Nm>WKj4$o*JF+ z*E)E)WgU!1LwvwO5nM>8I@FVN&u7!Fl|?$v4dSFV9Zp1I!3I>)#UlZS3muH=l-^jO zLFt4{>&=tP*@!0}(*a)|K}#5TT&hX(OrHG91(|Kb{d%Q8NSIos>iM`V(iyW;nfKOW zRyhp;CER7`P``l#W#fW4G*E}d`+S}c>0y7t<r!nh1p5MQq0{5sOhkmoaCYC>I{il11gS|# zC(?8?*H2N(KmxcFu+QMNxRz!CO1l}p6pnf5*kE@z8%ZUVKY!73AE+Z%=^YW zIe0eAa6|hAS$gSvVlx%fWgefO+FGa8uK+F;0FS1WQn#LrIi zxkgClV)Ej$5Ye=?j{~J%nXo4=mB3HO`UFKD03inp*iIHOIkS z6va1YN1|6OLSgJO^cT)9ckccK!o&etnMVcQG?nX>{9IF4rIHQ7a@YfErxv9h-#eBg zZlqh5EvlHQNDpa2yenwsbXT;_W(kTq*-aG>QaD|>bRD|m(3Up*3>y#5;U;oJc*>km z;>HqeZS>$Nx(f7ogJ+(L*Dq@l9Bsa&c%EVf=I0%z%heUb#QllgosIP_@kNF8AHr@p zIt3MHR5u`V**pXgSZ_R2M*#<%zmkIrmnNk5 z!M|L^cop#?M5)IK%AlC$YVk4oi4ibIbXNi+u~Vy^9jO$a2+>6}iF_>NIy_luWUkPp zE9hjF7a}QA*`85;d4psP_sG)L6lavXI*xY$v)mEm@hCI68d%$f0GtSk;Hu=2Dd|+F zRBH!B=IMmzSfMazT!FVkeVOVmI*OBuuqKRmrJ-keL4^fqjHXEP>%b8*cEm^}rNalG-6eB4f zb@PHCVf>`S_2dnf;&O4h$J%G$KON%qjgO}B3=P8xN1~?_F;B$k9>+B{K+;G{TuC>9 zJbSnxArTFDjs^?_1(eVW+l`Kwp!T_C3S!lFffB-4CtlMNd7!0$HS;45Y1`;a1yP&Z zgLpnkPDVV&oNx2i`KDYt6R=MMax|EToyu^T0&JmcPT@7D_W`xW5k-JklZW^6X_1-? z`8hw))DY)esS>n)Igad}Phu_9>>Qjmt_W&Qw?H>IdSr^hmuOX_BCX`Oj-c!y7Xl#4PShUYlcQHAH+VMPa!08Wyg9(wINN(BW19!YSYR9@JR zcnuFLILJ$9BvLpQO<+|H4O9s#X4IDB62OHvPO3q?WhL}cGF5hu+A9+mP9ddmpf>z$ zqDEVxP~uzx;+t) z{ai9vBW*LDE`nbbg_|aU?7;hl{EJFst77|xE3Px|CtOo$*Y16uVE6KwZ9d2afpP2{ zTxI?pIqR(vq#2LB+eja*;3C4gx`spLs_-7*HDXvIM0{-YM>Yl<8bEI}tX3wOMdM=m z;vj*BVi;C#&v2H```|ngUOhYGa=hC5wK^?lsVRb#hznsk$3 z*8HFld9N+{LJnp}3WY^h$nk-UR-nzKsW&#)nc=LJuQZ=g&nz}#kdDyKZL%^0RZ5V0 z#7)h0MZhfVWWdh2C+4D0Oujz)uwJVL5PsW5Kz<^!E2G>o&NE5D?<>ntE*cdg6rBJ| zaK8h2LVKt4ZReJYAeRXlj}CL1-OPFn++#@m-mBwm-|Au&$|4-3QzAfXN?i`Lw15)3 zGxq*%t08a#>rxyImT@}txO88AJt~nng-b2;gBY>R6hal`J5uwegon<($4xnQ#kV95 zg^%k}r0Yju{FUQ-xQPZL^6t}(qp)Xk=cxdXd0VZKb3qSVeCS1na5f`*v9EJz&T)(Tq4VA z*4J}H^5`y1JYwK}LQH8B(Sr%Yh^}V}mrYkl#?bU`91a5qkk)PqiR7R4Xe0F1SjNUd zj~+B~20Jq>&(>`@m`dJc!9$8MN>L-$kQ-0QlJnUS3>&onXW2Eg2E!w?+rA}JJ(A#- zyfdK1(7F(rQiD%7iTFO=AN$-8>iKk5S-dDf7Vi2Rzl6=LV-rn zPKJ55PHP`xh(r_f++*Lq$udq>S16!{tpI&*JM%QfrrNY6w#N~DXU{gMwU%(&m$PaZ z*oLm5QASFqR`nQT9A_QdwxH`8Ye2Vlmo%8L_9LnI{4r3EHZ2tBz#^x-O$M)joX%zD z67liX_u)Xp%!X|dqAA)7)7Ez}3In+`K$KJnJy7s0@^;%k!EIQWmghrdN03Gi%_~_V z%L57K$-3PX0utWAPvXhYX&(+A<+YJ1NY$j+X3vqRW6)x{m4L!$-eL3vaPiw^E9JxV zvO=LE>M7u5KD?X?$yEkAlBah7ViZ>O;0STF<$@u5wn;~8F!*tNK%eyA7=ya4zPt{; zCy@CAj!cm;8Af{!Xi!(8)^zSF&{G0(1#XxshhzX}Ka|e5Am!l9MC=>$fHK6v7x=wOg{5sEnNn(@dZzFx^9TIgcD;#nNTcLOicyIgPx9if> zX_B_gq6n}H;oMGr)i_j*%z*O0H2?EHD9HKdZJ}yul!?_# z?Y`7JooE};PA``^ET2)D*11e z2S~=SM%ck}Ii9O))V1?7FHQFDqt*x(!c^AS7R1((CP#FI>ny#l)Paq)bqq8wMx<~)Z3=#<8WxlQy+O|B)^X-H#Bvmc@JsGSM2iKMe7By0S)eF)N``NU09v7V% zz%;HXMGw1INd-G9qNlDVRi1(!ZL}Jqy60m)Eew8}D+vCBQzWKAJcW0E5Na|JFrnLW z-Z{5m&+bqVJ>}_GvFtejV8Psp;JDj63hw2eKF%YK@T}1*d#(ifh&%a+{22N2LnO(Pqyak74Ki}( zTM8)2@2k4!wA%{t7%CQ0WdkWMo>u|2+{9pQ^(eK@mJp=9!_Clf)|%ADRA+$K!D|b0 z$+}3&31w(aNm3x~(k?{@Y6~^CdtN=%zaPL4r1`1PT)aC)M40EPP!(BW4c0BCqw5J^BI$>&|-sEBh^8+8^Y}m@P+B@ba&$#PY<_CF0@Fo4^7q+C! zi$k9Bs~e#kO%_rvIh7=zZ3CXe`v#kV3Q>87oQsSs+J*T{06}C^`s4SP(Qqky29=sB zNMT-dtk0xUKsl+J(oSQR@Az(*P|LxKUA?_lT4pVD_z2C8c^nW8(|KXp>F#`ZD-q`?X;6~jsjMj+1(Auvo)KMDxg&INy=l5s!m9FtAQc0V3=3-l&+&XdIu#TypcA zCX-gRkBZgvFPUCpYO88((XXBTil9Wh;3f@#bOv4JijdtMsTgVwW5fZFSM0qy_eU6o z>*?wO*)8g|s37%XuuQ0-Yj4=9+_6^rhnE7%72th>uwt|e`g%hxfGY+cg z)lfT)li=i|4UtkhW%c_OG<47+t=nKftg^?WNQAO%jdT4~6_0WxJpLY;@(-qsnDC6Y zvGZ~}gOW0Au%GJS-2x8L_hqmt4VFfT608^Oc#Yu(SMG52MFJ%gI~P5#13N|KPV`g) zwYIb0mDUa>Z3`)9JAJ29r zW;j3Q(3r^fpaJ`R?-p2+1%)J)PWq^2Ee7F`q?;AC`6wsw>=89jpe%}*XIe)Md1$#a z0{WP=LQ;2v7?G{GjEf_zn{Ay2kEH(XW`%M)k zGYzhQ?<>Jn7PAz?Yl95P+hR!@YRX`T=i|L6^kk|M7{qf1?AGt$%5z%U{BneC!2CF94R%%hm?W4sE~$>q}cu>lQ|t z#9vn;Lx54f0Y-wJ3%K5w3VzykWf|dR>TALPf6?XBq%C^KyZdfvlaJ;+K4g-4<8AoH ze2Ybd?uIL|X(2!CvUSTxM%XY~_uHb_ux`F&>k+=CdmJUabW+;fhITw6sU$|frM+|e ztVZDFwRPwIQo_2}vlZ{71yH@^lDvY~6T>$VS7_2pVr`u8J+g|P=WlSrqj$!V1s-F>7Z0Zj-_&H!$K^=!H zslF(VJ+&I}%;WcuWfGHzP36;MnPM%C@ApLNKkmp_`dLexnH{mjWLy>G-(d-n3pxS6 z1NQ;+Q@3xXTBfQHwVCU#C=Bk1-1GEvTnA4)K8))x&XF)})YRDov~9xglh~4uTVFhn z;>MT(o1Q6bno)3 zFIV|iIomK;OSw{`HTeaQ3dJy8$*FTi+xRN|n|b4i#;n~Ig>qv{=cc9b9XEygnO&jY z{J;PIdp)n0zN<pA6kE`z-f8ADU^*foxcbadKFPQn@GC6>^Ge7FwU$&ppGZNhgW-RvbJ4f^i0i=lxA z^~Z|Z_`PGDzMtd4-P}r_hyQ$n-Q!5IM2=J*9k7H`Dl1lLT8lJchJCYMQ_SkwSL@CS z@LG3oZlscnx}&8_>vvSr*FCc>%w*m@LM8_bKziIX?{<0~ z-m?c+!|p}|82r7y{$`~IGd|M&2Jq}<#|8pCZ}I=5Ugc|TEFX}>01*62$RQowpx&E z=bmKv$7y)90+zjxo&CsNWNdt;I-nYFa_A>>hZ}$%jqXGdiFk<^EdX%0xRuUEYHPKd2oxKod7oJ z@0Y1X@|&oiCVh2K>#x_RXOrlU5V!PJn5>VIo7a)K)xp|LTtX8z-Yo?-8 zizaN}1OxQfPH~{G%$-_`{tk8cYA5X5EuNx7*VClFnkagBVD_PKNG}!$e8&HQ5)9|; zic1>p&}66CT)1lH<_mW%F1zX)51zbuyY7aYZt?L8X)jc(K)2n|=AJor*)7Nq!9s)z z6YjnT+C3B@Qj}=l#7Ne0+tMl1_gF1ODn**F(y85?PahdFWy$tPe*+DQNCwD>C~|+n z?UupezK0lQxS`eFPmVa}OkzvlHiC5|h8(RW(qhL*V#HIcUaWZ9bnLU2K_sxnRwtcu z!F~rEbjbNgWV{npkG|b1cgP&ro2S8g8k}gFY@%tVnr@srpCv@Ha+Rlir7J_3 zB2-IRGKf^Ra+u+z$7V|M)C*4v+pN#cS=G%a4-cGNTbVTmpFY;wez|AvJ%^87tuOJH z@`dnX`VTPRKk#=qpywSnnqBK^eIbIJQ~x+nkUX}%{d8mS@Q&ld$?t>zghB41f6GA1 zavivI@y=W9KeM;;qI}S?lLyoO*QCdpGbi@#Kckg!r85`*<^!gO#pnB(+g}P~{m|tB z|5xbDimmufdyB38N~-+tbq8UdZ=3&l9Hyru{N1DH?iY!PH1q#^`(pcGG()8Q7&`6r z_3I?R?)7UHRQ{rC-d>?~ Date: Mon, 4 May 2026 21:53:21 +0200 Subject: [PATCH 48/54] feat(app): add public marketing landing page at / Signed-off-by: Maciek --- app/env/.env.production | 3 + app/env/.env.staging | 3 + app/env/.env.test | 3 + app/index.html | 13 +- app/public/og-image.png | Bin 0 -> 55875 bytes app/src/App.tsx | 11 +- .../assets/landing/dashboard-screenshot.png | Bin 0 -> 184131 bytes app/src/pages/landing/Landing.tsx | 407 ++++++++++++++ app/src/pages/landing/SystemClock.tsx | 16 + app/src/pages/landing/TopologyDiagram.tsx | 108 ++++ app/src/pages/landing/landing.css | 505 ++++++++++++++++++ app/src/pages/landing/links.ts | 19 + app/src/vite-env.d.ts | 8 + 13 files changed, 1092 insertions(+), 4 deletions(-) create mode 100644 app/public/og-image.png create mode 100644 app/src/assets/landing/dashboard-screenshot.png create mode 100644 app/src/pages/landing/Landing.tsx create mode 100644 app/src/pages/landing/SystemClock.tsx create mode 100644 app/src/pages/landing/TopologyDiagram.tsx create mode 100644 app/src/pages/landing/landing.css create mode 100644 app/src/pages/landing/links.ts diff --git a/app/env/.env.production b/app/env/.env.production index 74a43254..c2924836 100644 --- a/app/env/.env.production +++ b/app/env/.env.production @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=production VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto VITE_RESYNC_URL=https://www.ivpn.net/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://www.ivpn.net diff --git a/app/env/.env.staging b/app/env/.env.staging index 88dc27ac..d69a2177 100644 --- a/app/env/.env.staging +++ b/app/env/.env.staging @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=staging VITE_DNS_SERVER_LOCATIONS=vm1:Quebec VITE_RESYNC_URL=https://staging.tamazaki.com/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://staging.tamazaki.com diff --git a/app/env/.env.test b/app/env/.env.test index 5cec5c3a..c250e461 100644 --- a/app/env/.env.test +++ b/app/env/.env.test @@ -9,3 +9,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=test VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto VITE_RESYNC_URL=https://www.ivpn.net/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://staging.tamazaki.com diff --git a/app/index.html b/app/index.html index 2fa02940..340f93b7 100644 --- a/app/index.html +++ b/app/index.html @@ -13,7 +13,16 @@ - + + + + + + + + + + @@ -51,7 +60,7 @@ /* Respect reduced motion for any future fade transitions */ @media (prefers-reduced-motion: reduce) { .fade-enter-active, .fade-exit-active { transition: none !important; } } - modDNS + modDNS — Open-source DNS filtering by IVPN
diff --git a/app/public/og-image.png b/app/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..09822e35fb071761db6f53a3b325025ba8f44a9b GIT binary patch literal 55875 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 ~gLfjlH?<<_xG^Lja@ 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%0DgqWuyrs3GB9(i5tznDYMheMS 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 import('@/pages/account_preferenc const MobileconfigPage = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigPage')); const MobileconfigDownload = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigDownload')); const HomeScreen = lazyWithRetry(() => import('./pages/home/HomeScreen')); +const Landing = lazyWithRetry(() => import('./pages/landing/Landing')); import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLoaderData, useLocation, useNavigate, redirect, ScrollRestoration } from 'react-router-dom'; import { ThemeProvider } from "@/components/theme-provider" @@ -75,6 +76,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Public route predicate (keep in sync with router public section) const isPublicPath = (p: string) => ( + p === '/' || p === '/login' || p === '/signup' || p === '/tos' || @@ -503,9 +505,14 @@ function ProtectedLayout() { function RootIndexRedirect() { const { isAuthenticated } = useAuth(); const localAuthed = typeof window !== 'undefined' ? localStorage.getItem(AUTH_KEY) === 'true' : isAuthenticated; - const target = isAuthenticated && localAuthed ? '/home' : '/login'; - return ; + // Authenticated users go straight to the dashboard. Everyone else sees the + // public marketing landing page (full-bleed, manages its own layout — no + // PublicLayout wrapper). + if (isAuthenticated && localAuthed) { + return ; + } + return }>; } function SetupWithLoader() { diff --git a/app/src/assets/landing/dashboard-screenshot.png b/app/src/assets/landing/dashboard-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..355f8c985b96a4c3252c6083446213198c34a232 GIT binary patch literal 184131 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

;`?^?c3qIdc`vU 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 X0$+yvRjY^<@b8q8+OgE5ra}J)w!Yw< literal 0 HcmV?d00001 diff --git a/app/src/pages/landing/Landing.tsx b/app/src/pages/landing/Landing.tsx new file mode 100644 index 00000000..a8745e1b --- /dev/null +++ b/app/src/pages/landing/Landing.tsx @@ -0,0 +1,407 @@ +import { Link } from 'react-router-dom'; +import SystemClock from './SystemClock'; +import TopologyDiagram from './TopologyDiagram'; +import { LINKS } from './links'; +import dashboardScreenshot from '@/assets/landing/dashboard-screenshot.png'; +import './landing.css'; + +export default function Landing() { + return ( +

+
+ + {/* NAV */} +
+
+ + + [01 LOGIN] + + + + + [02 START] + + + + + [03 PRIVACY] + + + + + [04 FAQ] + + +
+
+ STATUS: ONLINE + SYS.TIME: +
+
+ + {/* HERO */} +
+
MODDNS_LOADED
+
+
+

+ RESIST_DNS_SURVEILLANCE_ +

+

+ Block ads and trackers at the DNS level using modDNS, an open-source + service with configurable blocklists and custom rules. +

+ +
+

+ Your DNS queries reveal which domains you visit. ISPs can monitor them, and + websites use them to share data with third-party ad and tracking networks. + While using your VPN provider's DNS resolver and tracker-blocker tool can + address this issue, modDNS offers more visibility and better control over + what is blocked. +

+
+ {/* TODO(landing): pull v.1.0.6 from package.json or a build-time constant */} + v.1.0.6 +
+ + {/* PRODUCT SCREENSHOT */} +
+
[ MODDNS_DASHBOARD ]
+
+ modDNS Dashboard — Custom Rules Interface +
+
+ + {/* WHAT YOU CAN DO */} +
+
DNS_FILTER_MODULES
+

+ Granular DNS Filtering +

+
+
+

01. COMBINE BLOCKLISTS

+

+ Start with Basic protection or Comprehensive/Restrictive presets. Enable + individual lists from Hagezi, OISD, and others. Block specific services + (e.g. Facebook, Google, Amazon) or categories (e.g. adult content, + gambling). +

+
+
+

02. DEFINE CUSTOM RULES

+

+ Override blocklists with allowlist entries for domains you don't want + blocked. Add denylist entries for domains not covered by existing lists. + Use wildcard patterns for wider coverage. +

+
+
+

03. CREATE DNS PROFILES

+

+ Configure different filtering rules for work and personal devices. Each + profile gets a unique identifier for DNS setup. Supports DNS-over-HTTPS, + DNS-over-TLS, and DNS-over-QUIC. +

+
+
+

04. MONITOR QUERIES

+

+ Query logging disabled by default. When enabled, set retention period + and review blocked and allowed requests by device. Download logs for + analysis. +

+
+
+
+ + {/* TECHNICAL SPECIFICATIONS */} +
+
[ TECHNICAL_SPECIFICATIONS ]
+
+
+

// DNS_PROTOCOLS

+
    +
  • DNS-over-HTTPS (DoH)Port 443
  • +
  • DNS-over-TLS (DoT)Port 853
  • +
  • DNS-over-QUIC (DoQ)Port 853
  • +
  • DNSSECEnabled
  • +
+
+
+

// PLATFORM_SUPPORT

+
    +
  • System-wideWin/Mac/Linux/iOS/Android
  • +
  • BrowserAll supported
  • +
  • IVPN AppsCustom DNS
  • +
  • Router/FirewallSupported
  • +
+
+
+

// PRIVACY_ARCHITECTURE

+
    +
  • IP LoggingNone
  • +
  • Query LoggingOff by Default
  • +
  • Device IDOptional
  • +
  • RetentionOptional (1H-30D)
  • +
+
+
+
+ + {/* VERIFIABLE PRIVACY */} +
+
TRUST_SIG
+

Verifiable Privacy

+
+
+

ACCOUNTABLE OPERATORS

+

+ Built by the public team behind IVPN, with a 15-year history in + operating privacy services. +

+ + [ MEET THE TEAM ] + +
+
+

OPEN SOURCE

+

+ The entire modDNS project is open-source. Our implementation is public + and available for review. +

+ + [ REVIEW CODE ] + +
+
+

SECURITY AUDIT

+

+ Independently audited by Cure53 in 2025. Full report available to + review. +

+ + [ READ THE REPORT ] + +
+
+

NO TRACKING

+

+ By default we do not log DNS queries, timestamps, IP addresses and + device identifiers. +

+ [ REVIEW OUR POLICIES ] +
+
+
+ + {/* SERVICE LIMITATIONS */} +
+
+ [ SERVICE_LIMITATIONS ] +
+
    +
  • + modDNS is a DNS resolver, not a comprehensive privacy solution. It filters + DNS queries but does not encrypt other network traffic. +
  • +
  • + Aggressive blocklists may break legitimate services. Start with Basic + protection and adjust as needed. +
  • +
  • + Query logging is off by default. Enable only if you need visibility for + troubleshooting. +
  • +
  • + Not designed for protection against targeted surveillance or advanced + persistent threats. +
  • +
  • + Blocklists update every 1-3 hours. We can't guarantee new malicious domains + are blocked immediately. +
  • +
+
+ + {/* UNLINKED ACCESS */} +
+
+
UNLINKED_SVC
+

UNLINKED ACCESS

+

+ Additional services in the IVPN privacy stack do not receive or store your + IVPN account ID. There is no shared identity layer connecting your accounts + across services. +

+
    +
  • + Subscription access is verified through token-derived hashes, not + account identifiers +
  • +
  • + Ongoing subscription sync requires no knowledge of which IVPN account + you hold +
  • +
  • + Does not prevent all forms of cross-service correlation — see + documentation for the full threat model +
  • +
+

+ {/* TODO(landing): wrap "Read more about Unlinked Access" in + + once the IVPN explainer page is published. Until then the prose stays + unlinked and only the source-code link is active. */} + Read more about Unlinked Access and review the{' '} + + code + + . +

+
+
+ SVC_TOPOLOGY + +
+
+ + {/* GET ACCESS */} +
+
PLAN_INIT
+

GET ACCESS TO MODDNS

+

+ modDNS is included in IVPN Plus and Pro Suite. No standalone plan is available. + Visit{' '} + + ivpn.net + {' '} + for pricing and account setup. +

+
+
+
+
+
IVPN_PLUS
+
$80/YEAR
+
+ + [ START ] + +
+
+
    +
  • IVPN / 5 Devices
  • +
  • Mailx
  • +
  • modDNS
  • +
+
+
+
+
+
IVPN_PRO_SUITE
+
$100/YEAR
+
+ + [ START ] + +
+
+
    +
  • IVPN / 10 Devices
  • +
  • Mailx
  • +
  • modDNS
  • +
  • Portmaster Pro
  • +
+
+
+
+ + {/* FOOTER */} +
+ modDNS ::{' '} + LOGIN + {' '}::{' '} + FAQ + {' '}::{' '} + PRIVACY + {' '}:: EOF. CONNECTION TERMINATED. +
+ +
+
+ ); +} diff --git a/app/src/pages/landing/SystemClock.tsx b/app/src/pages/landing/SystemClock.tsx new file mode 100644 index 00000000..128cf287 --- /dev/null +++ b/app/src/pages/landing/SystemClock.tsx @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +function fmt(d: Date): string { + return d.toISOString().split('T')[1].split('.')[0]; +} + +export default function SystemClock() { + const [time, setTime] = useState(() => fmt(new Date())); + + useEffect(() => { + const id = setInterval(() => setTime(fmt(new Date())), 1000); + return () => clearInterval(id); + }, []); + + return {time}; +} diff --git a/app/src/pages/landing/TopologyDiagram.tsx b/app/src/pages/landing/TopologyDiagram.tsx new file mode 100644 index 00000000..ba0a13d9 --- /dev/null +++ b/app/src/pages/landing/TopologyDiagram.tsx @@ -0,0 +1,108 @@ +// Unlinked Access topology SVG — ported verbatim from the marketing handover HTML. +// Filter IDs (#gn2, #rd2) are namespaced to avoid clashing with any future +// hero-flow SVG that might use #gn / #rd. +export default function TopologyDiagram() { + return ( + + + + + + + + + + + + + + + + + + + + + + + {/* ═══ GREEN GROUP ═══ */} + + + User + + + + + IVPN + + + + + modDNS + + + Mailx + + + Portmaster + + + {/* ═══ UA GROUP ═══ */} + + + UA + + + + + + + ); +} diff --git a/app/src/pages/landing/landing.css b/app/src/pages/landing/landing.css new file mode 100644 index 00000000..0bf82deb --- /dev/null +++ b/app/src/pages/landing/landing.css @@ -0,0 +1,505 @@ +/* + * modDNS landing page — scoped CRT/phosphor terminal aesthetic. + * + * All rules are scoped to .moddns-landing so the global app design system + * (Tailwind, shadcn/ui, --shadcn-ui-* tokens) is unaffected. The :root + * variables defined here use phosphor/CRT-specific names and will not + * collide with existing app variables. + * + * Self-hosted fonts (VT323, IBM Plex Mono) live in app/public/fonts/ and + * are loaded via the @font-face declarations below. Falls back to the + * generic monospace stack while the woff2 files transfer (font-display: swap). + */ + +@font-face { + font-family: 'VT323'; + src: url('/fonts/VT323-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'IBM Plex Mono'; + src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +.moddns-landing { + --bg: #030408; + --phosphor: #4AF6C3; + --phosphor-dim: #154535; + --phosphor-glow: rgba(74, 246, 195, 0.4); + --alert: #FF3366; + --font-display: 'VT323', monospace; + --font-data: 'IBM Plex Mono', monospace; + --grid-gap: 1.5rem; + + background-color: var(--bg); + color: var(--phosphor); + font-family: var(--font-data); + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + position: relative; + min-height: 100vh; + width: 100%; +} + +.moddns-landing *, +.moddns-landing *::before, +.moddns-landing *::after { + box-sizing: border-box; +} + +.moddns-landing ::selection { + background: var(--phosphor); + color: var(--bg); +} + +.moddns-landing::before { + content: " "; + display: block; + position: fixed; + top: 0; left: 0; bottom: 0; right: 0; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.08) 50%), + linear-gradient(90deg, rgba(255, 0, 0, 0.01), rgba(0, 255, 0, 0.005), rgba(0, 0, 255, 0.01)); + z-index: 998; + background-size: 100% 2px, 3px 100%; + pointer-events: none; + opacity: 0.4; +} + +.moddns-landing::after { + content: " "; + display: block; + position: fixed; + top: 0; left: 0; bottom: 0; right: 0; + background: radial-gradient(circle, rgba(0,0,0,0) 75%, rgba(0,0,0,0.6) 100%); + z-index: 999; + pointer-events: none; +} + +@keyframes flicker { + 0% { opacity: 0.95; } + 100% { opacity: 1; } +} + +.moddns-landing .glitch-block { + position: relative; +} + +.moddns-landing .container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.moddns-landing h1, +.moddns-landing h2, +.moddns-landing h3 { + font-family: var(--font-display); + font-weight: normal; + text-transform: uppercase; + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); + letter-spacing: 1px; + margin: 0; +} + +.moddns-landing p { + margin: 0; +} + +.moddns-landing .blink { + animation: blinker 1s step-start infinite; +} + +@keyframes blinker { 50% { opacity: 0; } } + +.moddns-landing .window { + border: 1px solid var(--phosphor-dim); + padding: 1.5rem; + position: relative; + background: rgba(3, 4, 8, 0.8); +} + +.moddns-landing .window::before { + content: '+'; + position: absolute; top: -7px; left: -4px; + background: var(--bg); padding: 0 2px; color: var(--phosphor-dim); +} + +.moddns-landing .window::after { + content: '+'; + position: absolute; bottom: -7px; right: -4px; + background: var(--bg); padding: 0 2px; color: var(--phosphor-dim); +} + +.moddns-landing .window-sealed::after { content: none; } + +.moddns-landing .window-title { + position: absolute; + top: -10px; + left: 1rem; + background: var(--bg); + padding: 0 0.5rem; + font-family: var(--font-data); + font-size: 12px; + color: var(--phosphor); +} + +.moddns-landing .meta-data { + font-size: 10px; + color: var(--phosphor-dim); + position: absolute; +} + +.moddns-landing .meta-tr { top: 0.5rem; right: 0.5rem; } +.moddns-landing .meta-bl { bottom: 0.5rem; left: 0.5rem; } + +.moddns-landing ul { list-style: none; padding: 0; margin: 0; } + +.moddns-landing li::before { + content: "> "; + color: var(--phosphor-dim); +} + +.moddns-landing a.btn, +.moddns-landing .btn { + display: inline-block; + padding: 0.25rem 1rem; + border: 1px solid var(--phosphor); + color: var(--phosphor); + text-decoration: none; + text-transform: uppercase; + cursor: pointer; + transition: all 0.1s; + background: transparent; + font-family: var(--font-data); +} + +.moddns-landing a.btn:hover, +.moddns-landing .btn:hover { + background: var(--phosphor); + color: var(--bg); + /* glow reduced to 40% of original 10px → 4px */ + box-shadow: 0 0 4px var(--phosphor-glow); +} + +.moddns-landing .sys-nav { + display: flex; + justify-content: space-between; + border-bottom: 1px dashed var(--phosphor-dim); + padding-bottom: 0.5rem; + font-size: 14px; +} + +.moddns-landing .sys-nav span { margin-right: 1rem; } + +.moddns-landing .hero { + padding: 4rem 2rem 1.5rem; + text-align: left; +} + +.moddns-landing .hero h1 { + font-size: 4rem; + margin-top: 2rem; + margin-bottom: 2.5rem; + line-height: 1; +} + +.moddns-landing .hero p { + font-size: 1.2rem; + max-width: 600px; + margin-bottom: 3.5rem; +} + +.moddns-landing .hero .btn-group { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + justify-content: flex-start; +} + +.moddns-landing .hero .hero-explain { + font-size: 0.9rem; + color: #888; + max-width: calc(100% - 30px); + margin-top: 2rem; + border-top: 1px dotted var(--phosphor-dim); + padding-top: 1rem; + text-align: left; +} + +.moddns-landing .hero-diagram { + width: 100%; + height: 390px; + border: 1px solid var(--phosphor-dim); + position: relative; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ); +} + +.moddns-landing .hero-diagram svg { + width: 100%; + height: 100%; +} + +.moddns-landing .screenshot-container { + position: relative; + border: 2px solid var(--phosphor-dim); + overflow: hidden; +} + +.moddns-landing .screenshot-container img { + display: block; + width: 100%; + height: auto; +} + +.moddns-landing .screenshot-container::after { + content: ""; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.06) 50%); + background-size: 100% 3px; + pointer-events: none; + opacity: 0.5; +} + +.moddns-landing .features-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .feature-item { + border-left: 1px solid var(--phosphor-dim); + padding-left: 1rem; +} + +.moddns-landing .feature-item h3 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--phosphor-dim); + display: inline-block; + padding-bottom: 2px; +} + +.moddns-landing .specs-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .spec-col ul li { + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + border-bottom: 1px dotted var(--phosphor-dim); +} + +.moddns-landing .spec-col ul li::before { content: none; } + +.moddns-landing .spec-col ul li span:last-child { color: #fff; } + +.moddns-landing .trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .trust-box { + padding: 1.5rem; +} + +.moddns-landing .trust-box h3 { + font-size: 1.8rem; + margin-bottom: 0.75rem; +} + +.moddns-landing .trust-box p { + margin-bottom: 1rem; + color: #aaa; +} + +.moddns-landing .trust-box a { + color: var(--phosphor); + text-decoration: none; + font-size: 12px; +} + +.moddns-landing .trust-box a:hover { + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); +} + +.moddns-landing .constraints-list { + column-count: 2; + column-gap: 2rem; +} + +.moddns-landing .constraints-list li { + color: #aaa; + margin-bottom: 0.5rem; +} + +.moddns-landing .constraints-list li::before { + color: var(--alert); + content: "X "; +} + +.moddns-landing .suite-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: center; +} + +.moddns-landing .vector-diagram { + width: 100%; + height: 300px; + border: 1px solid var(--phosphor-dim); + position: relative; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ); +} + +.moddns-landing .vector-diagram svg { + width: 100%; + height: 100%; +} + +.moddns-landing .node { + fill: var(--bg); + stroke: var(--phosphor); + stroke-width: 1; +} + +.moddns-landing .link { + stroke: var(--phosphor); + stroke-width: 1; + stroke-dasharray: 4; + animation: dash 60s linear infinite; +} + +@keyframes dash { + to { stroke-dashoffset: -1000; } +} + +.moddns-landing .section-title { + font-family: var(--font-data); + font-size: 1rem; + color: #2a7a55; + margin-bottom: 1rem; + letter-spacing: 2px; +} + +.moddns-landing .section-title::before, +.moddns-landing .section-title::after { + content: "---"; + margin: 0 0.5rem; +} + +.moddns-landing .pricing-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .plan-box { + border: 1px solid var(--phosphor-dim); + padding: 1.5rem; + position: relative; + background: rgba(3, 4, 8, 0.8); +} + +.moddns-landing .plan-box .plan-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.moddns-landing .plan-box .plan-name { + font-family: var(--font-data); + font-size: 13px; + color: var(--phosphor); + margin-bottom: 0.25rem; +} + +.moddns-landing .plan-box .plan-price { + font-family: var(--font-display); + font-size: 1.2rem; + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); +} + +.moddns-landing .plan-box .plan-price span { + font-size: 1.2rem; +} + +.moddns-landing .plan-box .plan-divider { + border: none; + border-top: 1px dashed var(--phosphor-dim); + margin: 1rem 0; +} + +.moddns-landing .plan-box ul li { + margin-bottom: 0.5rem; + color: #fff; +} + +.moddns-landing .cmd-label { + display: inline-block; + padding: 0.15rem 0.5rem; + border: 1px solid var(--phosphor-dim); + font-size: 12px; + color: var(--phosphor-dim); + margin-bottom: 1.5rem; +} + +@media (max-width: 768px) { + .moddns-landing .features-grid, + .moddns-landing .specs-grid, + .moddns-landing .trust-grid, + .moddns-landing .suite-section, + .moddns-landing .pricing-grid { + grid-template-columns: 1fr; + } + + .moddns-landing .constraints-list { + column-count: 1; + } + + .moddns-landing .hero h1 { font-size: 3rem; } +} diff --git a/app/src/pages/landing/links.ts b/app/src/pages/landing/links.ts new file mode 100644 index 00000000..5041c819 --- /dev/null +++ b/app/src/pages/landing/links.ts @@ -0,0 +1,19 @@ +// Centralised external URLs surfaced on the marketing landing page. +// +// IVPN paths derive from VITE_IVPN_HOME_URL (set in app/env/.env.*) so prod / +// staging / local can repoint coherently with one env-file edit. GitHub URLs +// are env-invariant — the canonical repos don't change between deployments. + +const ivpnHome = (import.meta.env.VITE_IVPN_HOME_URL || 'https://www.ivpn.net') + .replace(/\/+$/, ''); // tolerate trailing slash in env value + +export const LINKS = Object.freeze({ + ivpnHome, + pricing: `${ivpnHome}/pricing/`, + ivpnTeam: `${ivpnHome}/en/team/`, + auditReport: `${ivpnHome}/resources/IVP-08-report.pdf`, + moddnsRepo: 'https://github.com/ivpn/moddns', + unlinkedRepo: 'https://github.com/ivpn/unlinked-access', +}); + +export type LandingLinks = typeof LINKS; diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 11f02fe2..b7a4a592 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_IVPN_HOME_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From f69eebfb04a10cf0f9f833a8505cf03aa2320fd0 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 May 2026 21:54:33 +0200 Subject: [PATCH 49/54] test(app): align root-redirect tests with landing page mount Signed-off-by: Maciek --- app/src/__tests__/e2e/functional/auth.spec.ts | 11 +++++++---- app/src/__tests__/unit/RootIndexRedirect.test.tsx | 15 +++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index 24d4e09d..2e155bc2 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -86,17 +86,20 @@ test.describe('@functional Authentication', () => { }); test.describe('@functional Root index redirects', () => { - test('unauthenticated visit to root redirects to /login', async ({ page }) => { + test('unauthenticated visit to root shows the landing page', async ({ page }) => { await registerMocks(page, { authenticated: false }); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/login$/); + // Stay on / and render the landing chrome (CRT-themed marketing page). + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); }); - test('stale auth flag with expired session still redirects to /login', async ({ page }) => { + test('stale auth flag with expired session falls back to landing page', async ({ page }) => { await registerMocks(page, { authenticated: false }); await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/login$/); + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); }); test('valid session at root immediately navigates to /home', async ({ page }) => { diff --git a/app/src/__tests__/unit/RootIndexRedirect.test.tsx b/app/src/__tests__/unit/RootIndexRedirect.test.tsx index 57d193c4..e6fdab8a 100644 --- a/app/src/__tests__/unit/RootIndexRedirect.test.tsx +++ b/app/src/__tests__/unit/RootIndexRedirect.test.tsx @@ -13,6 +13,11 @@ vi.mock('react-router-dom', async () => { }; }); +// Stub the lazy-loaded Landing component so the unauth case renders synchronously. +vi.mock('@/pages/landing/Landing', () => ({ + default: () =>
, +})); + describe('RootIndexRedirect', () => { type AuthContextValue = React.ContextType; @@ -43,19 +48,21 @@ describe('RootIndexRedirect', () => { expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/home'); }); - it('navigates to /login when auth state is false', () => { + it('renders the landing page when auth state is false', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: false }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + expect(await screen.findByTestId('landing-page')).toBeInTheDocument(); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('falls back to /login when local storage flag is missing', () => { + it('renders the landing page when local storage flag is missing', async () => { localStorage.removeItem(AUTH_KEY); renderWithAuth({ isAuthenticated: true }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + expect(await screen.findByTestId('landing-page')).toBeInTheDocument(); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); }); From 6143fba3e10aa35c10c330484202c6a7bec0258b Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 08:54:31 +0200 Subject: [PATCH 50/54] test(app): Adjust tests to landing page Signed-off-by: Maciek --- app/src/__tests__/e2e/functional/auth.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index 2e155bc2..51b65cf6 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -94,12 +94,15 @@ test.describe('@functional Root index redirects', () => { await expect(page.locator('.moddns-landing')).toBeVisible(); }); - test('stale auth flag with expired session falls back to landing page', async ({ page }) => { + test('stale auth flag with expired session redirects to /login', async ({ page }) => { + // With AUTH_KEY=true in localStorage, RootIndexRedirect treats the user as + // authenticated and routes to /home; the protected loader then gets a 401 + // from the mocked API and finally bounces to /login. Landing is not shown + // in this flow — only fresh unauthenticated visitors see it. await registerMocks(page, { authenticated: false }); await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/$/); - await expect(page.locator('.moddns-landing')).toBeVisible(); + await expect.poll(async () => page.url()).toMatch(/\/login$/); }); test('valid session at root immediately navigates to /home', async ({ page }) => { From 712ad1dea6abf25509bcdca0906e30201f3435ac Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 5 May 2026 10:06:45 +0200 Subject: [PATCH 51/54] feat(app): Display dashboard button instead of login when user is authenticated Signed-off-by: Maciek --- app/src/App.tsx | 19 ++++++---- app/src/__tests__/e2e/functional/auth.spec.ts | 38 +++++++++++++++---- .../__tests__/unit/RootIndexRedirect.test.tsx | 28 ++++++++++---- app/src/pages/landing/Landing.tsx | 27 +++++++++++-- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 171933d8..cc5d6172 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -503,16 +503,19 @@ function ProtectedLayout() { } function RootIndexRedirect() { + // The public landing page is the canonical face of `/` for everyone — + // authenticated visitors see it too. The auth check below is read at the + // routing layer (with the localStorage belt-and-braces guard against stale + // React state vs. localStorage drift) and passed down so Landing can swap + // [01 LOGIN] for [01 DASHBOARD]. Landing itself stays a "dumb" component. + // + // The function name is kept for backwards-compat with the existing + // unit/e2e tests and the `export { RootIndexRedirect }` at the bottom of + // this file; it no longer actually redirects. const { isAuthenticated } = useAuth(); const localAuthed = typeof window !== 'undefined' ? localStorage.getItem(AUTH_KEY) === 'true' : isAuthenticated; - - // Authenticated users go straight to the dashboard. Everyone else sees the - // public marketing landing page (full-bleed, manages its own layout — no - // PublicLayout wrapper). - if (isAuthenticated && localAuthed) { - return ; - } - return }>; + const authed = isAuthenticated && localAuthed; + return }>; } function SetupWithLoader() { diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index 51b65cf6..a0fc11c9 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -86,28 +86,50 @@ test.describe('@functional Authentication', () => { }); test.describe('@functional Root index redirects', () => { - test('unauthenticated visit to root shows the landing page', async ({ page }) => { + test('unauthenticated visit to root shows the landing page with [01 LOGIN]', async ({ page }) => { await registerMocks(page, { authenticated: false }); await page.goto('/'); // Stay on / and render the landing chrome (CRT-themed marketing page). await expect.poll(async () => page.url()).toMatch(/\/$/); await expect(page.locator('.moddns-landing')).toBeVisible(); + // Unauth nav surfaces the LOGIN entry point, not the dashboard shortcut. + await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toBeVisible(); + await expect(page.getByRole('link', { name: '[01 DASHBOARD]' })).toHaveCount(0); }); - test('stale auth flag with expired session redirects to /login', async ({ page }) => { - // With AUTH_KEY=true in localStorage, RootIndexRedirect treats the user as - // authenticated and routes to /home; the protected loader then gets a 401 - // from the mocked API and finally bounces to /login. Landing is not shown - // in this flow — only fresh unauthenticated visitors see it. + test('stale auth flag at root still shows the landing page', async ({ page }) => { + // `/` is now unconditionally the landing page. Even a stale AUTH_KEY=true + // in localStorage no longer triggers a redirect away from /. The 401 path + // through /home → /login only kicks in when the user actively visits a + // protected route (covered by the protected-route test above). await registerMocks(page, { authenticated: false }); await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/login$/); + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); }); - test('valid session at root immediately navigates to /home', async ({ page }) => { + test('valid session at root stays on the landing page with [01 DASHBOARD]', async ({ page }) => { + // Authenticated visitors see the marketing landing page too. The + // [01 LOGIN] CTA in the nav swaps to [01 DASHBOARD] linking straight to + // /home; [01 LOGIN] should not be shown to a logged-in user. await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); + await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); + const dashboardLink = page.getByRole('link', { name: '[01 DASHBOARD]' }); + await expect(dashboardLink).toBeVisible(); + await expect(dashboardLink).toHaveAttribute('href', '/home'); + await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toHaveCount(0); + }); + + test('authenticated visit to /login redirects to /home', async ({ page }) => { + // Counterpart to the change above: authed users who click LOGIN from the + // landing page (or otherwise land on /login) should still bounce to /home. + await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); + await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); + await page.goto('/login'); await expect.poll(async () => page.url()).toMatch(/\/home$/); }); }); diff --git a/app/src/__tests__/unit/RootIndexRedirect.test.tsx b/app/src/__tests__/unit/RootIndexRedirect.test.tsx index e6fdab8a..84320555 100644 --- a/app/src/__tests__/unit/RootIndexRedirect.test.tsx +++ b/app/src/__tests__/unit/RootIndexRedirect.test.tsx @@ -13,9 +13,12 @@ vi.mock('react-router-dom', async () => { }; }); -// Stub the lazy-loaded Landing component so the unauth case renders synchronously. +// Stub the lazy-loaded Landing component so it renders synchronously and +// surfaces its `isAuthenticated` prop in the DOM for assertion. vi.mock('@/pages/landing/Landing', () => ({ - default: () =>
, + default: ({ isAuthenticated }: { isAuthenticated?: boolean }) => ( +
+ ), })); describe('RootIndexRedirect', () => { @@ -40,29 +43,38 @@ describe('RootIndexRedirect', () => { localStorage.clear(); }); - it('navigates to /home when both auth state and local storage are true', () => { + it('renders the landing page with isAuthenticated=true when both auth state and local storage agree', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: true }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/home'); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveAttribute('data-authed', 'true'); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('renders the landing page when auth state is false', async () => { + it('renders the landing page with isAuthenticated=false when auth state is false', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: false }); - expect(await screen.findByTestId('landing-page')).toBeInTheDocument(); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveAttribute('data-authed', 'false'); expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('renders the landing page when local storage flag is missing', async () => { + it('renders the landing page with isAuthenticated=false when local storage flag is missing', async () => { localStorage.removeItem(AUTH_KEY); renderWithAuth({ isAuthenticated: true }); - expect(await screen.findByTestId('landing-page')).toBeInTheDocument(); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + // Belt-and-braces guard: stale React state without localStorage backing + // does not flip the page into the authenticated UI. + expect(landing).toHaveAttribute('data-authed', 'false'); expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); }); diff --git a/app/src/pages/landing/Landing.tsx b/app/src/pages/landing/Landing.tsx index a8745e1b..eb1e9f59 100644 --- a/app/src/pages/landing/Landing.tsx +++ b/app/src/pages/landing/Landing.tsx @@ -5,7 +5,20 @@ import { LINKS } from './links'; import dashboardScreenshot from '@/assets/landing/dashboard-screenshot.png'; import './landing.css'; -export default function Landing() { +type LandingProps = { + /** + * When true, the page swaps the [01 LOGIN] CTA for [01 DASHBOARD] (linking + * to /home). Authenticated users still see the marketing page at `/`; + * this prop simply gives them a direct path back into the app. + * + * Defaults to `false` so the component stays trivially renderable in + * tests, Storybook, etc. — the auth-aware caller (RootIndexRedirect) is + * responsible for passing the real value. + */ + isAuthenticated?: boolean; +}; + +export default function Landing({ isAuthenticated = false }: LandingProps) { return (
@@ -14,9 +27,15 @@ export default function Landing() {
- - [01 LOGIN] - + {isAuthenticated ? ( + + [01 DASHBOARD] + + ) : ( + + [01 LOGIN] + + )} Date: Tue, 5 May 2026 10:40:36 +0200 Subject: [PATCH 52/54] chore(app): Change login to dashboard button in footer Signed-off-by: Maciek --- app/src/pages/landing/Landing.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/pages/landing/Landing.tsx b/app/src/pages/landing/Landing.tsx index eb1e9f59..b42d4b31 100644 --- a/app/src/pages/landing/Landing.tsx +++ b/app/src/pages/landing/Landing.tsx @@ -406,13 +406,17 @@ export default function Landing({ isAuthenticated = false }: LandingProps) { textAlign: 'center', borderTop: '1px solid var(--phosphor-dim)', paddingTop: '1rem', - color: 'var(--phosphor-dim)', + color: 'var(--phosphor)', fontSize: '12px', marginTop: '2rem', }} > modDNS ::{' '} - LOGIN + {isAuthenticated ? ( + DASHBOARD + ) : ( + LOGIN + )} {' '}::{' '} FAQ {' '}::{' '} From 3b47fb23219b8f89d7dc4a3f2a272328cfe58c44 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 11:46:55 +0200 Subject: [PATCH 53/54] feat(app): Mobile view support Signed-off-by: Maciek --- .../layout/mobile-horizontal-overflow.spec.ts | 2 +- .../landing/dashboard-screenshot-mobile.png | Bin 0 -> 52660 bytes app/src/pages/landing/Landing.tsx | 25 ++- app/src/pages/landing/landing.css | 173 +++++++++++++++++- 4 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 app/src/assets/landing/dashboard-screenshot-mobile.png diff --git a/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts b/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts index 1cb162f3..439fb5df 100644 --- a/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts +++ b/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts @@ -6,7 +6,7 @@ import { expectNoHorizontalOverflow } from '../utils/layoutAssertions'; // Runs only on explicitly mobile projects (chromium-mobile, iphone15pro) to keep suite lean. // Covers both public and protected routes + key interactions that could introduce overflow. -const PUBLIC_ROUTES = ['/login','/signup','/reset-password','/tos','/privacy','/faq']; +const PUBLIC_ROUTES = ['/','/login','/signup','/reset-password','/tos','/privacy','/faq']; const PROTECTED_ROUTES = ['/home','/setup','/settings','/blocklists','/custom-rules','/account-preferences','/mobileconfig','/query-logs']; // Interactions per route to surface latent overflow after dynamic UI changes. diff --git a/app/src/assets/landing/dashboard-screenshot-mobile.png b/app/src/assets/landing/dashboard-screenshot-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..a56bf8b394383efd6549c47725630edfbe3bb7da GIT binary patch literal 52660 zcmce;WpG^TdZuU8)ARJxeNMQXj2JQkJ^};;1hRye$_EYx{0n6((JuJw ztD~TV5*+yD1!oinzQ%JBQFBtXHF0v)cQA%9wXwA}rgb!QFgCVvG_!TOgaYz{8`1oy zk&uJ2zLUAF&37epYhwsiW25g3tly>e-M{}~_`~|0k&%;$m6L__yO4;YlF{n~2L!}- z2nk^UCAZA84OcJa`}d*irB)_3Pazz5Qh5C!p1zdmCKO>2In8F3wVYLz%GJfD)iPy` z(-MuxP0X)K51T*z`M>KMUGiLT1NnM~rEL-j_7dQ74tINm953)do>Q%$M-I^CV`lkR zXy{Tn(jYX`w4!#z??GrgZ$BZ)g3x-&e(D9G3AzP+jY1QIi;eR`7lhkJ$6yeIBjpzS zA_|re6YmCpUcteC14|Q={uiOD|8&)I<>sDog^1L@w;Q;rcXYAy_D`J_2mkc6w6ZL} zG_STja#tAKlf6u^h^XGpbzV_jLfH*Mg^8mp?(r{aTjrhJLrFI!tmymuBpfUk*LWHE zJ@Zk)Fh}w~tT^Qhf6WC1Tygh1uyF9gb*hWEmTNEQ5WagefE)b${YS8OpzkjtLXAdc zkcg<0t2_i)%YKfnee2%6U9xQl6mXB=A$!Kw<~Ky*!EM^N*FR_-+=1#lb9paLHeOR-Vkd%b#=GOm>qPujSAF&cuR0dr5__{R(G;YzO9{Do|2+Ss@~Q}_O1@Q=s019YL< zAz}Zr{P~4~(n_J!j9B{#(S`x?tQ2K9dwtGJsDs)eV#C>g5?PZ=&9=H_<>kKfO%$@( zurd;Mb^_YM=J(~X6fED$M7y(tG>V>*6Mft z+!S=78kD~-*-w3(sgQHb9LYtJCV3|sgg+tzP7WhCrf@xtY{w17@@3aTr#y3V)he>O z=t=v7Mbd{ozIzJW;?re_-IHAOaJu<qlZxOZMx!6t}H0_V{4mf zZSiizv>}tWl;ps^fWcH{ zGTJ^}tX53`)U+-bhNv0~2N)g}hXI?0gTtTT;NXA7#g1Fukj3r>hK6|TcHhIp!vRth z50{{+W{}V6LvpB2wdvV%?N4&@?bB096%`!KW}Cvg$k=K)1O$Vf{_wl=wbb5T!9Pq) z9Clj}tQp9SkzHrl8{qs754O7oXpyX{p;bOe z#}mLoyqS%{Dosu#+T)82a+yk`zAemZuR(J&Y0#QfDfi6;#tYDqmaQW@x-)TwrN^-4 zq$qNw=8PO3K%B41_5ArX#Mg#|>u0|^csmOocQ}Bn+dn;7fQ>_^H9Qzikj7*hGxm|G)9G>je3;{S zT$tMQsM0=ec~0RpZyb`?Y6>ANZMmFRXxv^tM=QvXi3+T{Gp^N_LG(NQ=nJG zEv6vkO-Ow&iMho>8;1{a1D_#2KAxzH*X2wt1eIj-*dyEHgeo8~i)I%5FJzHRrsy%_ zZ%~gdsOnCfRs&=5*FF<1v|Dt)4Gk4;2Py+Afiyqoeot_cy$H zNJ$}~qx0;tY5PRo8UX52Q*oDcJkLvkp{OL2hOs=0`}gBnT&bG2o%i#go}Qk!OW#i} z8so40$E%cov>)PG=gvC&+8a!W5fD% zuU0|qGjb)7@;T2jil8`;||(Ez|ca2Mx;f=c)JZhjFRd zrmC%EkPKP=0BIR=PB+o#bAYpX+CS^pl+?={p|es)IU*<AR^W}ir2dV3TaZ{@{dE?c!_9So!00N#q`W-uQ!k`d}dwF?fEbZ*>f_H1K=C4K` z?Pl2V*+R+ZeR|&!@B}?$8gH3$%F1pA32eazV({^@1Z?Zr{m74$0WEwClf^c)VnDJx z?7>}jz0%QlTz$!4vvp~$1bsX-7pKqJw4bwjd3&#oC&tJ31fuc|E30Y`@I0>frL$PB zdi%-{eQFaHRxmRMOtkK+-?QeOIMa#!GrvWgNl9u<+hq23$dK1PpTWV}zU?v9yKEU) z!yyrS&!;JU8G~Y$mN<8CLzrPf7~~}bps9Al0)fGrHqS)#R@aAh#hcrgjzXGW`JvQx zc?UY#HpXprS7mcDvbU#FL)m~fQrnZNItN7nC!LN%iVGhDmO+~A?S)W^ooWcPV-`E^ zKnvi(wx-SAK|8Tw;rS2ZkH-xp76`MW``ce`&maDT?)N%A?i)~ET71!BpN==qkNXD) z4oB0Z7?_w@f)u>G+5P>(C`6pXHa2S%H#zAx(ZdVns*W4ZOIa_kU^}LurA78G->#bC zef=HF`$7pZmO^WIx!LJ;GxAI3wtsN&3mOj2;C!j#n$;=T^!LHL)(PL4)%8Ma)92N? z*e5wI4y{N!W3bM6OzWZ4>*ct3-DS<1ov@gG736g*P3-fu%^DjHsRg60O34b|6Q|tr zmoH{2R;(SM*PwLI3%jm5IjoavAdIxT$Xc+L(-}e2xuB++)W!}pd#~Uc-JF9xJv}~s zR;V>U25&Vm_D8G{ZnhEcXbcO%vX#FyL9-lwO@OIQYrt|ceIjD6O5DeW!=mY`-Ysv( z(Bz(MV#x0OH3GnmC=%oG`mKZS`a>SJCJjlGaEzoqjpK7=SnL-uWC_l>@x-7s!2jIX zJMCrrCq=*cN+QwNJ`nKt`SkXAO)PEMuK_5`biWone)NgtIoIw&uPq~kGR^IG^e=JS z;ifUC&89Ozz0Ij~4m2<@U}R$A!U=l0Kh2_cGb83jYC%d3ghjpShC*$?n|%X&^P5qc z>V`Dm(IhG`MvPBN+GF0ZVs!+0KQ^wgcJqB)qatv=axBw5D3ytlm8q8V{w&8X%*)#_ zZU`O#qaJ1#>d12EsLcwln)&vr{{{jN3lewB4#n*BKB&sgA>#zyQ zbdi6ui$kpnCtxD~Tz{tWe7O8&_~|p-_0A)7mlqUju1yC@%goGFRQ+?P*}~7J+mn=< z+MK4My7ZuWNll$O)lsQJBP}iM=INP}UbMh^xwYBp*{tsKe6zp2W`6U0kYU?$-n5ok zbbq=OTT|17TFCA7#K6MBLbqgCzp~t9O^~X+C_uh8@M>fw@xCg>b^mW4g)XXN(WalX z26)PU{AIwTuZ9h(ZxgA5Pd9>v19B`ZPYss5psl5J_76AooGK{4)a4xy-oA;0(1r8m z&+<7d|JoZGGW2TperXAjcbb*Unc0jPmK+vyL~_=r&M=AUe3M%6Uu*ldnbeEXkr58( zS&zG;873WBS*OV=JuMdO*7So(Lv1Zu&zs9cfw_e8?_47b%3}Qdeq;AeE{Z1h*DFMI zYs}UL8Rjp)*@z(v%APuI<%?B`vZ{*qWf7OHj+jukZWlwpa^HQ^p;2ko8cbPknp1$A zy82UX4TjYS0Exp-FY51X5qr))A z^W`x5Ujm3oK9vL<{}~@Ny&}pzin4kPX0PL zID}Taip({HFYmlGq*Yz+vFXap&d=|>Z{yB`rtd@LRy`l{w<9F&zCK}I^#k*i6LSsf z^)+e{PPZVfFJkS#!aBQq3bshX1G;y(aJJg((Kd2}*Bu+_*FJ8@R~{yZrkSfe6CdmN zwkF9@6(7$K5TJh9-3BU_zTTj8(K;J`Z+J(`0XBx8e%w$_rNCT2gzPeZ^GrPQ2B4;1 z?2cFKPwH;)Mx0qm%F5a@o!5=gIl9M2$P_z1SJX8$F#na5T+^z(J6RYrZ_tpuzP^?M zFy!s_w$5xh1d~S}a_nW`DLNSZN>)Z4PTyb+#|rglm0A&fgyTv>q=KYyaK8(Yju_%$Qxc ze;|LnUTMu&`;JA&Ru!&RBBH@Q=e&`yeYj(fj)QKhmo|5ji+%dY`+BeA`!)-2FAylm z$N~cbz7%J$J983ArqbCBeUg>49fuyDJ6|o9ntg9e6_-zMPAb}><%q@W9No%Y zJ~}otlZkh-tOKw!+D`GO6|E?1JbS%D_qpCCGrftSV?WgHhTwi&cjW$f+Lf8>?d^2~ zV=_1JX+T9!&&N{H+DPK_nYMMR**Vu{JAsUyHxu2q!* zH}QdZ`W zwtO!zfB@qqn+%YNz4PbDoGjn@oj6`(-#a`^OiLR%StxhEonT92 zxBnp`G6`HrNJ()y`HUuL-Z68xl|cL-@j1S=y2x#;r!$6Vn_;W)vl=K~dO8{U_@_irx6c~qCH zu}$!#bVz^YT)+qjfX_XDh|4FrQPg>OGy524s z55yL;;n%hurSdNQhle0uQqzr%)mfFdcK%vRX#{7Pu!+av0$& zht^mrdhP=IADUu_g@t)mO;=Ve()26B6kKG~zuK11uqFq{-G@4S+Vg05!hg4M;vkN1Da(V|{&USL3wU)$9+ z6omL6W~j|SwHY4R%@1yTgdB1~Hz}LGV2}qMXtk`2c;@$F0(hwWwGtcN99gIFg{i5> zuZR1W;1PWAJuC(&CsB^~aQ}B0r~gMZ)qh{egEtFR-ySL)QK7ByyU&YgSF0oQVr4=uO@bdfPXY$JAJv>9UI`zg!1>}1cm?@U z;l3gB+8d@ZdL!&AiZT7<4T#&~f9vR0P!*BTK~cwRUv9hLQmK$B?w{S*hh`QH$lnQ% zjs4Z7tDDqmdwo5LUkVkBmLF_`{7tA|*v9LK)FwMQ4A^A-+D2D{YwzC~M!=n|zO+8_ zZfxH1-mdB9pzYqQeDJq>%FX3`Zv19$R?7fU^WzOZ=YJ4*i8ct|f^%zKZ`XY<43vgP zBHC_^k^DcF%iCIis7ATrt<%<{32L>pk?VCRTx<=bUYG>wF2U5Upm=C+4Y!(b)89;$ni7N0fde1?)$3Ex7E?c%bJrqDpEQw_!+8R-HyKS6ymBY zZ*8nGn!Gxbmmw#z6Bg<|Y-GNTQFDQ6(c>SFGTEK|P8OPrcg4@oZ@9xYt?1Ew67^Nv z={ef{0Mhp`g!{g*tfEy1gxeSgHxOM@^-ILm;@Uhpr1AL!E7+jw$NLbpOSe=XYs;@5g*~=NlnXake{&FmAeg=t%Ic)C( zzJEX%105$Sry18HI_qWB3*}O0Z0WLNawy}5)azS)b>`H%`7T~MmPnj@VQrgV!SUMj z$J~;;t2+*hwz(y9NCLCNm(pD^CWGbg7`s2`pw9qJyg_IwrgcA`*U>&UAe;E zVJW*b`Jg#B@W*$=liTrqT4j5-P0FKpaAOeh@*^5A&$Pu7i{z!Ss0kkT&^@Yu{1s7l z6L55Z7lKY7STyI49Mk4)w3j)?Di!Q^{dha5`N&l1+|rOPOYkRvAjdZg6#dA>18U`l z32K4Jnxpw<=zst(quHQXW5WX5UI5y(MYuh@7xlCVi0I_Zm?&>{{5KdgL!WdMB!DFI zr^a=Pan)Q4l2GYPb0~)UkZaspEJm*S%TXBc0-iW~0kPOho>mE$uz2}QOex3COQy3# zFDzq9_p>9O$JJL_-7`7eqKNvF{TVyjEWkh_48=}fqD;;5W`si{&^1RmA4*fppHkm9h>I7S+)$kDKV=DRz}`$Xh544;B*2=$)?@=Coe7Q ze#z#@81RE-%9^q_AV3x5&i%Hd_*t<`SnGjMPB4>lGF z%*Hj}W|u2cS8zQ#_~GfS#S7V)1d)_=FaHJgJp>0hrmQNmNN1h&6hGkH0o<)$Aq zrmzdLe|aZ9|0aeKgj1c=$4m=IZO&6>3JDmnI!<}jAY4dR;?l5LN!{kt?Y=_mobzgG zLn!kV-q=a>fX5APcmgblHoc$nel+HV9W0bF6RD5t61yQ5FOqBPnoJRGZQ}W9Om!{k z?uM47G`6RFr>AXPOsqfKtz7Erb5AidD43ch)hjK^{r9kbE@`cMFiy~a>{X+9H;~BP zUb@eZ&g4FpvpqpXOBdfr8byd5n&Rk+1ZSVeS?tY#7xJtu^|+|Eg6SMCu`qV8h41 z>=0J5OG{vnCH3*~O>;8AJI+w1{^DVmU|2=N9w1p z_vnJOM$(O^93LyX+_J7k8-jv@**p0fSyQ!CI|m*FV-7i*Z!86S;p`v#?B^9#LDWqh zNDWqy4)cq%9ypyn&sOinf&_#zB?sgAGh?TJTzxM~962e6N6_P;-uDn2mCtwQk z(Y}CJkXxIdNv8v*72Tz_n15(@G`zsw$EeUKH9Y;A+`c&E+dcOj+VUMNey&_9Z*UyQ z&2R9@JI^@tgA&X5h+U*{YB2A;nO~4RJvVDKeU_VZ^7ds@G`BM2ykBbN0Q&)ktT`Bn zQ~&d`GM6f7X9>hsV-K?>YR<;^qCu5pe_aS4Q#nXxBJG|zwkeaF3f-IH)RY93*)m!2 z-Kn@W;=?A=3DeH9jhIUC9&LM|V>(MpPU4E@oqZZWBiP$9TpB>b-Op@@iBK5z4G>D5 zAA}i~$;l7M0fN+#nZ%Uwl4bn$4wo%e6K8>ZP+V`RQYR#fvvrb%)j6t;>6#oLcW;K3-B zqT1WCE7r11wf2k%6CF!YYw4JRY+&PlN~5r_SmWHdDH#{-Zol0=<{ep@tG!glbF>hh ziD=ZK`AR_4yV3{NWv5&@Wx2yDS3i>Y#VWj?kaN`@HjxkQOXPfdpwU6IlrHVeEzTd7 z>6i(V)2$q$_OW;yZFU$i1mhUe1|VC#fe8L(WLZi}U%B~!gI`{+lcn(-*35sro$#oq zB1&2qLW+lW3q+{5XT_H4j|a&8=8)q?B{UJqUlh?z7^n~u0lYzmjm6Kp@>pyYt|c;z%2^JWq8@vfhV zpBc-q{o2n6I^Oi#va$3OP5aPMw6T|}MlNG$7bFuNVS%BV*$|X&vaRu&Lon^y0_D=k z?7II>@UCyiB-mH*47i5Nlv`y~LsvIbZ(`X#dRPiP4V^eqm;t?sY3#m`m}Ht~=S)2R zW7dbL?F~d8S$uqafvy*L7tC#cXF?h7z^6F)-2kj{CU)HY96}5|BUEn>F?2=c^xiF^ z@UsWL1~o>aI{xbqSX|2qQ>%8#Fi!H5SZ~^cFT$%(BIFX6@nI|+NS^163i?%?aN_i< zGpjCt_F!~8W#Xc9fgM9gMwxY0=hn?(pVDT8`)}o9Y}}@I*sRw?Wg>}*;<<&6yEA>g zFWcKZnFI3&S<89!f>xq$ltDd7d~N5Ia~tBEzD9 z%0W@{()Deo5Y{{m;EM<5T>1OSs=PzgIt*+9(yzmrExv71UQI{nHP9<8(8q<$6XcN= zjYw?h&^_Xg`cB@GIJ#nhY6lN*|C?=le*a3mSWBLO?s`JV6MVtLGZ0KL<59!Nq7q5gbE7eE7C(P;sh9@GQ=$$h3MiM_u zQ$wU%RZu#8_+(=|V?%!hXLrpgF`}VmiSw^j>QCqn7Q}jq_Hg+6Ozss+8vI|i04qD+ zFB46l&8{tfTU@{W5i$J1tq-?-QPJC$sBB5odN&(~ql%0$SYw1Cr1aa5>{F50-H7n= zI)-~5(_1h)OmY3%0`pV(6hY9zx z)(8(FIY-LULa)~=qBl!LswTQeMYC{x?w~jsP-jE|MRfCJdrs`FtSW6v18&P@EeU0V zOO3uZUxKWTj(VN@W6S{eojFBgr5H7b^`z*2He0Ld_2pC1Rv@Q zS*6B=t1S2UP$+>Wtt~m%=~ehIxMeD>7tgBBcXw8-mU<#0Z;Tz;Oy&0$Fld-*y?7ll zYRs;n{}|k{)ioFvNo6oSaSJK_iH?gG18jyQQWcH8Np z;|##)=XVp9HLmP&~i2Sx)S>z?~x@R5D?%Kv6}=g|G2aJ zL;1g|_4w}x(f@C&O8);I=|4ffOvDMn(uN2c(J-GwLwn@+pr`O-Mo1`y@OveU55X`d zQfZ_AUP;XVT6ut@45{Ay{hRwpq849?7!Sh5FF~>XVaXPc8IgWVNGvJ)QZ0{(FA4<> zrrwqjz8G_Ky2FKJf^w5DtHJC*iSA5C1c8F;gCa{4CI)dRvm-|^?%-*O5&QM*)KS74 z2k6=ThHlCEi~Kz8P8c<3^l97sic5a?^^U0TUu|Ic9q%i8F8rD7Nx%HhV1gNl)so}4 zmulbLPZQxe`^@7j*62o_N88Wg(Xy0I5u5S_mju_?YGr3et(V59FD^dT~S|R z;>FWkJmkKAY^1~{S8H!)0NeNBW4ImHe7bbUTj=Za#^=UhzPcg6;jnn`C2EIlYJH+6 z5%*67i4YFoz|iR|yVn^*yK2XH^{PDn1R{+W z&?}L9Fsz$T%wu@+G2U&eHP&AB{zuYcS{0hlxnza*Em5pA6GU!g`T)tMy4c{1Rt(W! z!&}owhFLoYAE@1rw!q#UgNRt*7>mH6=9FK+XN?5X?WE~Ms3j#cios4vl};_y)1a*W zjP1uT=<`|jGURZw^&UDhemPI)!-a*mu2K&hWO*XZLa|NAhFx zUT9Tk!QuzqPSfkYy&w7@0O^~lNsQ0OuWWVYSh{ToI+7w-W?`Ls!b=`^7H`R}!^%%@36~>6Vy}D9 z@2FV_jhCpj9>CY+@#@Wk`^O~(9FD|K&ucs!(Nd#ob=j(usiXOxi3zUNZ=$ggK?%}U z+-9dO?0?btUQZp@c@{$7FZssmtG#*_qqIiPewo&Qv83`xqY=Dh_IrSLG|%q%N5xFg z9}BN9tA<=F*+)#p6X88+=v@!QdatF>8B&i+W9PdGMd*nyKSnSCqL zW`c&{*Yf<=6r8MB(WL3in;QI*PFg=TUhPqcYMtuHgX8AQTTBde=QE?hBs9#!0gk+~ zr$^4RtRJ=q&#H4}e-RK>!(4AvaQ;C=SwsK1fgaC&Gl9y=zn*lO!_UZ@mb>I2R&GQfoUO=2nKwU zqf^Zam<}AD+RX`{@7oq3q1u#(keoZB(cjnO`?Bw-m6WI-E+~eGho|EuT3C>T;D7v? zg~hL1@x2pnw3#^MS3xeLN;pCbZWDrKw_!uaB?;%{^zRD!5_7;AcCmzO`|0))EO6}q zdz4Gsjtkn;MP3ipc-9*ACPSni4`=3@ADNWyC+^>g1xE6u_=24oj++tQ*`WNHT!_Vu zDaTuo&z4hU=->D2zGdyoK7&CRGZy^r@rUY^c;7zc@vMyDi?ETeY}O|GAr|v8ROTe^tJ$%>k)4sh?W|R`F}Z^K1MSL}1#_Jpqk=!2ZBU2#~?<^j7nyt1Zk&LFNDk;O}4vj7AC@{yj zUpRlbZ7`c~x8ZsAYgGY`0yT7H1Ix%eRG-}4ZG%;BC}CbXC@0h=Mw4k|-!-%3Bl?*A zgd0br#b+=$HalcR|B)-X~BMevD`4vQYzao%Y zFyD%b$R#`bodv36rNc0IdSJAjc>g15&e7ta)|+m2r7JR^8_~NLqrZwh>?)RnH+%|X zZ%|{< z@iC3N_Km>Ar#aL>ni4)9*4^QnZ{+K4-lJ3jK*PtbXInns(kv`&VPFUrH#zT(YsUad!3a$BIGi!lGr~M|YbF(7e3Jf{_ zbOXf3WTAF4QoNuNgvFBOEa-YqppqAZvo-62KuFv~W&?&UE*CjP<=C?GsR|KAN_S7% z0p*3|FvFv%D)QnJZI=q&u^D+j!yWnyitjwQEEWt@orHVUYO+Om-2(O+Fe3E_XxIup ztMAXte`(L?v&S+igGcX=33(WC`s6jG8_P)b&%Ee(9&mJ1nts=(l$b9$+6$ArV@sR$ z!D3X{%c*NeT2vppbwxA6T#h8e2xRlM@#_$OW;JXsI2lSdbaFip-l~09wr$ zRdqSjj5g4_e4in!GJR<2i`cROGm|rHw%aGKAOlNjq`9iL>;XO!N&1DTjq?L4E-_x8 zCYYa3^@fmw&XKnT=;PI8<3T$p3TAvo7RZ$8E2=MF#QJ2{gK8wY; zxH3*slfNP8Xy1~A(08W512xy&VSQ1Zc`pdS^~Tz9(ni^Zg`frB1JypMpdy`7q&}68 zyPc36s}?xY{w+%8EcA>3tk99XtRfCLSJ##Ok#tLp`JWQ;NqU~5a4E31&m#YOqyBdb%>Rq3 z|KAA#>ZNAfcMokj-+QP%I=;L!IEkq@Xt24|;8Zr1Tk<9v2hm)d7ue*_(Bvq$a`eapAe8i{L}2MDRDP+NulQJ$9C%F+$eItTOJ&zYcP02?Si&^whcMo_^DAK!d?FXz?r}=6Er^$X8_y&2x>=BtHP>6x)U~ToIS)~A$(;b(F*Of# zWlva9Vua+(5#u*z_O`kMCbhvttWM%AnJL-JpKbNt|HvH%=>Zs?fCbx%!eNw`r$_)B z7rbpJj)=2fGwY0;yp+BQ=j)uKx!G$l$L;KK)HhB^WRb|t627TPg-$q0JQ3YtM zK^``|>PQ~4;QC?kpV{?aJc%40ki);3XXgR>w4T5(@>sB9sMT*P7y0p)?BUed4e0vhJ8(`YR6FZU5}kTpLnR^uiXR=_>Y5@@!}W za=M6}Z}I>gjFc!!LciY?1vRi`B(J9Pf-H&HvFJOZ_!=qDxAAaATQc)mu}5% z&=NY%(>_}TNF*mun)l+hnejrMEM5;BzM63|suQq;OVY{aKAko%J5(%)v|>ojcBmso zq4Z0lF0NwSJlYsMX8#xmb6+o*EEZp=0`uV+{9Czms0+`lSb|VZmizVl?;b=?d@(^Ss6^u}zi3lj^^GeG&P( zdw3cwBVRj@w$F{#H z;e8>NDjwuvpvHB~5BD8*jMotT+4NEx%0UZ-%qDDZGYs%*z`;NC!^y%UQG{(P3+|?6T&#!xjV@}{O{UmNCo*J| z`|=P#h#j-0_|{}mV>kG}e@o&_c;fAGE4^1D=Hh=D)W(|sYnV>ML#>CTqoYSh;d^NR zC1EfjsU%P{ku%4Tb8dd~~vzKC3S9=hFxwm1XVXs-lN^GaeSfzl~>4g3B z-IyBlT=}=A?y@^Ds30?c7a2)B=ErzVtd`{M>qGca^oYEmL~VH>Sixg^l>PI<8H?{F zpLQ6(CPYfzV<$LFy2bnVa+u6({71)m&S-0M5s!sHMo@nv+e9&$lO$4Qzc*a7hc*|< zm%pH;0Hgp#?|$~SctoUz^xOF}kn=``wXU(y)kpq6wVkN^2W8be%fE1Wycz7kJ&qcG zrfkfwAb%xCCcDm$46mNp9sbZJw4BAv zonV%~nBjfrXHtjLtcohQ_*;MLpCp3@uhGT_r;_5A0(xRGHFua}KCJ27C$z04zZFPI z$e)v$6WxAaP^^|kL=sUh(dbWPG8rg+pv1HIqTz1Q|14b=&isn`AS?c(yfoYlA{kg8 zxv5%?e5f;C3cqY<4e-Fm0{v7tl>IduKebJiQKA?Sj>j1>qWV<(QJ`#HuYFhJ$LOA? zHV&{vabPh0SxPnX)#&1%2Bs)5$!l!PGL1G*EaUU+6T z%Js%@C^3yJGjk|-ggH5&EtmPR$VAR{o+3R4s{wO-o0p4kLuTLtb({zXB7nt!L7pHs z!SLA3eJQE!#|?^U^U>ZC2Co;A{NN84;3G0@dAh703X!$mqB0rYA0v8b%`xrle_F#H zN+eM|W)P9k83o}1o$Aa%D7m^pINsxWJMZPgGGl#3>9raHm&Q)5C?fW9sv0rUj0R@S zIfDYy^6)C)TVsGs=VS76^P<#a7CmPDTL%_r?x7>L%ouniGu#b|1fpuT!K=IOS-KKb z2x_Ax6)KEwcyf^1mv8~e8aJre+fE;=4%+E@y=2nt2&jomWun%ui8gcv(Pu({s+je0 z?%%~ltO=>^NRf;QVGo0*88jtTu(rM+MZ@#?w!dYxTl=TG4sThWuT71Mud7n-0vr}k z41+R+{DA7$L$!!}J$jmg)jYkc*=9b`X)QtXV5m+YJ?Hwm*v0JEc&}&rmYoBFr|0}< zsqBO(Rdvn`pq7|_IVRSj>+}IY+3N=!s$W^(%KUQHA&<1AoRY260k=bJ6SuVMB{C(i z{2fBbNhaBRu1LW`{>;3hhFmlTp=cZqVKQ_Q3;r>aJq*+h}6UQhOZNmY z!L4Fjc~)t(&U|mV$e1|QVso8l91Da<21`LWlo_Xvj&^2&yNGKcTK3`EvN>z)n7Tkh zTKVhP@IlSC;I$(##^H2=K`xt!K(4_(Cu{d&7@-=OCwR%DSr(K@gQ_v8_uI_ny2hT~ zBH~KeHhi684qSqNSgEZ-BUJ2$7acF7{ZV{$J19_gBlJck{ ziV|RcvgJTB5H?e?MQPrvOg5TEUy#d&CqGY-j;~=2tR=3u=-0D2BowvYjK$A-xD%_= z0NYdf%v*`~nu(&!t45*6(x1h$QY0eA;4~q{((%oS3+Hw!hxn`XZXcvH_JrkOy^c<- zGh~x#es?k02#Wy|C}s_2O0)G9F&khSeoU46P`m}KizHd}Lh?MT%k&3mgt7cv^f#>J z5K+fad;P0F0j==2cpcCKV`1_$XzUNvRrqTsBAz@RJkxGL=elox#rb*etm6fDUCn7%S^Pt*txHU@`HWb@m>si^_1Mi{9d77FFbnvD3^M`rG> z?J{7XDuobJCwiDo)#yJ6BNrg`4ZCf6-ADh31G3;K1C==gyB$GpApXt^MmDRTVc_pa zgwO<+Y^`Bqpo}d)B-W=uR0dmwUw-GAwohidDC`SwK^psN76D`ijXA`b%ULF+5LXm@ zVj4GN`s&@e)d*d|NENx!vQvnN(|+_S)k==Y`IdkGfXyHwexPd~ql2W@KxE8j>FqbB z?%vu;%N1pxOWKFVg6#Y(kbHwF({`XJlffH1#Z=Sv z`Jwrwv`M-g%tVHS?>pl|HR!KIGIYl)qt(ThOj98%5LyIsV8VNkZsD)BK3%Z!1#;Hu z89+HBouM2-r;7qh>rn_&4L_rMFFF;c%>y&Zg4Ix-Na;eo=b^&m(7MFrL{`gvWfZmU zHuCqZ(95jvjxfw*$5-ihPphd$0Y%186>piz1G|@~?CvZ1`m_q4!5eFnaT9FM1(!t| z?#;3r)!NLrjx|jmNs032g0>GN86T1k=}8dm00F(NTn7FWrPq`zPf55`tWI_AvemCi zfHFSnCD~NIz?>bRPfRANRl$1MIu{@L2twNKo4_h(jP-_~iwtpLFf1BcFnlD;HNe1 z8dKTKUCj5I`(^=>zjkqg3thBEM#KdzHE}!Z3T+;wJBz$GVUw}yjl4NK4$0kn0zu8M z++B^HMlT=bRe z3-2m{Bzv6?_G@G}{SU&xf`r*#(WG3oD3;14Z9(gDlRnn5W~vJ4a&?jWduN*)&TDVE z9|>WW_PJ(47{>%vav2$*{oNhG>4GXfCl9-2t700fTloi{`pn%Jg9zEiIcC@>gV_Sr zZY_DyWU{mt_(ZdHRsO62|0Y#Gf+;zNA{ArD3cH_uLt5j)iWCD2Dp;4_ys%54x&^e& z@l?DCspiD=xi!KE@<_k;Pwyb=6?H8JP?$qdQ5Bv^)Aws>h6-Uye9nK&+i;1CVCJgW z8%@c>OyCIyXiW4hoUb!hEXpgd`k8FOiN?;wscwcL>jp?8%;#k8fA@4nHhKZzjo(H5 zJQRmEi3)nKaHldUc3Q%5z%-3>SiycOG?iB|6(c~4tcJ1B^7(N=JJ<0pAO%NT0)9Bb z|M^@15}wH8Hm4YzG)7U>pQSl5b=7jujee?dweUVA z*s!J6-X=D*#rc1Yr{g4$nvLakOhs7k1~tuj{F)6f&dFr$b^*#X><#98-o7cld(d5k zljDVp`90;b3=K!h^?O#o$f{Ave(6q%=)Vrrns@8NQLJahz=~19HWLP_DWfcoX!?kdlA&YYrC7X#>USZ&ibgUtK-h!vka=l9x@!*G`V zaT(PySIUFKw$q7Tot{E&vuXYN#`gny3B!j}6aD_+ja3OTYiyzi{Y-_U%01vK${@>_ zcv@$YFj-55w$pr){L)V8h{_X{X0HmP>w^|fv3j@h(B^`OqcfArhN9+16>X9*Eg2P4 zTK*Y+I)ZB)%RZ7xaWQ6wGJn0PpcT0%m3hYB^!m62fzV%`2deG&+AahtaVf0{K@iA< zDjse4rItr))H3N!z@E^o$TTKf&UzKo{O#_|6~Xtswmws34^z^d_cfW*2ob5Q`pglW zV4KsT`l6_|#h&Uqq;sb1ecKv3(~C^0g9!!$+v@GT40@Yq%UH z=yJ<})_kVmb*qeSl{95?UNQQO&t8$$kfc1l$`;?A9KfbK&}2k)Tzj(#@IoFu!9X!c+G6D-V2S;vwuP+j z9X-5_WVD+lLE(CJRHz4!Lpu0O0nQ$&_FbTyUW0wqsIYMNtM29+@AHr^X)rEq5lDU| z4c2^~VKD`54cUwMmj+tpqj^m)DzT<|q#mpqu@R{Gej(!2Cn}>kcI0Wj0 zpY~2S63%e1Kh4iA>!N_S@|q1SelEx;I_E}H?@Q_oay(PFNb}l$@1MV&H2yjQ_}K*l z<+NX2rwrI_lX4gM#ignn$c#pZ^gM=~Vjq~;w({W8y}?s`&KS@Y+t%WPq_y2S0ldbm zsEQCA1=xVG~qjL5oO0C-TpA)65gfj1(`S1M;L*+Y507lwJmKAsF%mLTXv z<&W0xv`-syQ;yvI#LV{v_^JlBbQXMhz~7%ZrP!Z*-1EAl{=jL>-Qj9Tx?g{_)8(vd zP2HEYaHt2%$K>aiaGsyIh*|_14DR;;;E;J82VIDw&xG(ejvOS2xR8CSI4u6sFpd}y zvmN+l6RQ)OReS=>C5R@g(96I7wkC4}R{Wm@S#`#E^**|s!5p~Hw>NOyAG=Yt^i@s0d`6-9YM}POX$B`e)Hm6jn;@9Ye0cYG` zD||3a`Ya()lK%@`?t$xOK3_M^cB2xAa=m{9%Er5xjP5CBgv0S;1u-7SFHCeSFY(Y zopfN~O=9wjJSX&pqJq;5Ijj*mJv&xfy22kfmphR>yVNnu0ot;XMo{2X4lWC-P4@KO z4OVqTP3wCsiiWFmrbVSg7)EY$>h9qMwz=_u#h@B_!zrxPgw3vq&+d$vqeX%o-yab# zNy`TO;aK0G+4uS@welV=Nqj?cW{yzq{kURRQuRg)%21~cNwLm{%a*NIGEAd;e`? zPxL9>r9N|ipH#LZmZcS3k=uj=lLO{#=QqTVTm1pj(YjI4H8J=NJN#vFaKvi^AMg=k%+qY+FC8bB2h zJ+#60T?}{tqLdwjBPkcvb_950tnxN`V>~>5r((_;k957BjncJr`H3KJBr`^l1?~M; zb0cI8Gq~^s_lilAs-G+->F_*n!H$ZKGjZj}H@TPlV<5J^4{%W?s-k_<#2(?UsoZEy zpG_B8)E32av8%-Oer7x!72ODDmgSBXwQIX@cwW*v6i7=EoI?z2h{qzV`lh0h%L{$B z$^C3I;{ZOsznJCd2Xpu$aNEqWgPbil*4dPWWx3nc;&d_>*071}g+E4HwJsGmAZN1b zV^;OSR`H(eqro;9K1=qU|4I~F?lk|l7a5^rLcG+B?XzDC!oWAZ>J~c@cxuQhR^ahPu7@waC7w30iM=^ElM@3RuFZqED(_tST6UVTk=wP%RY- zKCl#L{9u!1=d5tI-Tjt`VeG*X8oPOQiP-Wmd*#unsWG#kBWEMZQ_)`RF2pJq&23y; zmACRmM5?AwX-PZk>p^Ffc9uoyW3SyK7ypbXy0kV&NGp7S(-_kxDQ>KtI{{j5qjUiNEY`-NT(fjyt-;B=4Yn#E_B-YZ13jMhTOdMnUEn zih;2+0Fky7e`I+%zL_JYf4SbWqH2iK8l4q`qvCYkV%AZP1=0!~qkyyGY{LQrJep}?uV8O@s4Hi3!ARYhJ2JX>NS-0GreDzc6m zI))hGLMqXo>z0_7`Fv8yg!EDw@Y$8?@V>bwTEhriftT)gu>Wm3D+wJ>V1)9FhKdY7 z3`Y>S{8>SM`xh)Mv3TD32X521NHFUUiyR${;)B7t zZywZAM5dHPWq&=WkRD%L851I;^N$ySNlku!-(9#5oy!cU8EE6bnttWuMT_eCDHbw< zjDsZ*LwTlwzVVEg>!P9wwt(6^n>L zl+1@y2&evIAT56}8`hcl-WitpxC%o^7yL^Jnz|09?bQ19B1()i>0rg8!cH_Udt^@i zWDo>0eEewEUTC}sl80i-q!ZII;qu@>wpB_}lvoF1S8znFQ~1iG5n6;;Ntl0RpN8pk zE&IyA5V4EV>h9T?biD?d_4U&PYA=O&6<`&J)UGyC4|~CqJo)JDv-}2w`Sh{J;gHG} z>)fC6=n>5tev)^Hs)2zABDfo)K=M)D+j+H%NWd5=8zLisS)(lN>4~fNVT7|;@MNx! zyc?^7gKH%zxTm^qyG+^HAP)t9o%TWjqHGETTGDI$5)w?A`HINX?ug5AtR%MSci7vv zq&Tv)5mN&J_nYRptf&O}kaEO$w4R;S5@G{ujfQQ+QO615@EuD|CRVa`Rcc_iOTbWY zboxpCz-4hThj4or<-w9G>&X;8h|kKV|0ZuSlTB;_1)(a z9*Fr7V_Sb!tsUdLNG*l=AI`smL;tPjaz_L??>H<72zcDzeh9Kks517$R`ViPMeRj_p0d>Jtb+=AS;-lK4;(rb4%wh0OP27= z{SrIlgY6(hg~vNcnwQT&w1;rKErM!_WV&lCBi%YZXj#=}6NmT+> zrPI+uJDLlgsO5U9qyuTGdL#eLmE23(Q$KHD;BSCE_m2=YJ>|S;<^G z>OkAIM+)sEehE{WS2SNNNE7MN>#B@wr^*h;D;Fmc^3;GTEn!tYE2ycrJRay(XF;nI zIp%y_=_vh`QGt(7S7}M$5b_@GdilC^0cJC*6Rl-wJ5}d!RHfQ+c zQ`69xhAl=muQ6d#(D1WD2WRS(?NpsiRkMgK-il5(j`Q}|L|7R2>Pn2dNkEhO;g!3d zz2l3m3L)AmYD?4R>pRg$ELc~}Z72SBIZ9(cy7(n@nXynJX2|8@cJNfni2eP5JvsBQ z4x&h;L9VQbxkv1bxa(i`?dgq9+vs`j3gDBH7UnNzSVBSzQ~f0k77gExHSjBRm1GYa zJemsX)u~zfr4J5mbV7fB*PXRe$)>+eh{GtfSe z1bn-ofbYBPW@tDw-=@qxUCshFB{ZoiikXbe7Bz0^RxSTFWLMysvt9My!SOkSD=0ybjD@ve!L^v@gdt8t_{j+HS8m? z(HocnF3{HG?Mtp3^h&aN0yuG?Rh0e))BCW|rbo0Q+in{ku)NUUE9elk&$40v_DGjB zRYg${4M$KT>{b!@!?(uA=V*OXRQ4JfCsj zFU`G`vZN(h$)F<|+A^_@*e4MNXS*V=V6Hb3Pnt(Y=svogU!#p02<(e~6LP5^XJ@mV zwH$1-wmkVQ_KhP^RdhD;RDwoqJZ2?v=6iq4Ei>oKR`rx~3&%9p@@U7CW6MJ45?Hd^R&ia*c+0b?;y8`8gM@ zr=x!FoL(u6l#H(6@PU%+&B&{C4KzecA#gJtXJzHl7+*H(- zbvrt8-X>oo3Hc6m(H$s^_s%>+vwAc8fP_DWLk(r*A7-3E_1l}ylk{q+ZFsgjTXv5c7J%i zAleU4=R|7V!Y{O5L|qC5S0+0Z>boL_0rYV zWoB+BpnAk>mpuY5Xpkp7Rva3ppfbMFo+b&|OA&>m>6_`7#^$(lh!U#&6sCX$OH?fQ zP-P)%_qO~tXat#=maD+I$XWdlX%&#O-isCvc#eZ~7FW!02L7t9&a7)LjcmB1G_8b| z2^7ZeZW#kDZ{4r-Y#8Yqa=`j%Sy|}$8(Mnu%M0{xrp`29h>zVfWBs9DK)GHy z8HIwlsYDfa8*eUHHtt|(uEY1y9tjQfi%>U>;eT1W5n|bzH;p{djR4IExGFS z_i;wi&jG-2dnD;ix;0B*etu(1W2`HlXqF0SLSb8!g8Y;21JD9ZlM4Jhah0`z?T#IY zUpc$wvnbDUylsYojpTSrc83S~>P-O~pq&mz^`fP9c$Y|p@TC# zM^VEQb5aqYuN!%iP`U@YpT};nm+!ESI4K3(O?mPP;>8LBzqov>WC_&wC=5Bs%yXwY zPx8}{5H>FQ503LIiz7no(djDI9G)iv^?tD6vfv{{|7C>Mp(r=cmULQU652EfAz&Gv zHU}0^Kt$uqxbL~G*10v#XleK7&%spH#Opj?rzNJ=Tg{5MJ9GUO>V=#aPLwg zpDph38~(t}(AK&jpChjS)@}Wt(^gaE^M_^YMxD4keg2lTc)?F8c6Hp2AzFx?<%|5` z#3T4=k53Xtu4zCT+w+Fl)rN&d6oG&^1RTZo*z?tMW@9mU;B36c@KDGg!6(_GK`5jI zE9{7cvGBe;$M?VU#DUqYA6a9%|$tX;=7mRb(^2mb5pVIsK?cg@_%LJ-n`W z>fLLIKtaQo(>j4A7ddJ)(#+N_#qXldax-tlT7bsqiLfI9Ye9qx0n=~BWZXBQRP}H` zNCyIW(5UEmVp_kKn4>XE{vsVt*_n@gzz`O8;v1Qi`@(|H;7sD}$tdJ>Uu z>fEi_lye&qFX&R}WnzPVyl&n=UqoH^3Wk;;kIMK<)Vx^&p}vSg;p=lQ$q$`?l0*P$S61w9oW4$}s2YgWOL_3wP`s?>(75N2Gy{eg6A7D>z?jeyX? zH4O5#fjMtdX6S9>EcT`AhZ(|Y&DD>dBay7y?wAv&PmleNQCtAHGbT0BkT-2KZroa$ zle-e5s`kKeJVa?}1ZoN%)IyOF+p8j=5);s~=|0*5ruD?aqCbZ#eH#^ngZQeq3Jo>0 zKRS9;E*$P#gZrGC!M7iFSo&Sn$XGlPmJOvC1Vjo-0cZ#3`F5XF_2bn+P-TICMkAp3 zDn_>e5^@txRFsPSdisIy*Bp}3RC#L=XNc&3L4W){jLFfS&X!i2(hqg>-@}!6Hr-LX zn!fDmZ}A^;=xEWD)Mkh66@MDFpI7F+9k1|HZ)4(0m=$){gSU_-z|X==$2Fo-G3yRh z(e}Bd9x4A)mC5{pUS6reM&8cttd5H2hyizAUf}U5d9F!^2ST%2q&TkXg!(f@1eEljwK zZvXni@AwXd`Eg9bpYTLsd(SvDav1E|twvO{UgV}LyqKD+aMId?yF-ca=ZrGYL#U~! z&@q&l7i&@me!MXkkA1r4B@u%oy1&)c=S-{%O6my)22H479(Bds3FKqk)Z}2&@f@Lu zuI@)R#3nZxD)bY->$0Th-MqWgy#;i1A~3QNGoC*)JE&UD_ni#ttMkhw$7DNh{=9K; zb^YONtpx$8>4gfAccqiXhGCq&tsH5L=u z5RABS)45d@Xv73TqbuL!)q83P%`q0$H_DE+$l}mCE@R!=S5=bx2MK z)tEeM7FTnu$fdd3Epw}LUjqA}awgK-u*de*kDOjWZW@c)rL6V}Y=_P0^)2UxH$0ld z>fex*cV@J;XPMX6T{KhXt6))y_}5dCL*OG=ac!CwxV?Y&Z>Y$NwUBYp25x__H*hI+ z0wKBV*Ttl(gJ#O_v|Ju7i$-9P+vTJ-L~h3}#HYq6m@Kp;-~mVGT{(25$X&Nlx|Cj~ zU%*!P#qCo$4izG>qe*$vzz#~vM=)q4qwvnT&LQ0liNYpBYbMg<5n$Q`^7*q{T|&(6 zkyw|&vlOyu8oSH5LNSD`u~pH@;OoSNbV#D!P#C%kYtUmsOBPVh=> z&Q9~zxPlLVtwh5UWj=&oGL?P_o1dq{SJ0>X8rzBs8CxOQ9)mq08Nj52UqwMp&BEIG zYijAkwe2@ovg*3pRQ`+!Tgo)K;|D_qzPqE#z_^IaUFS-oVEnm{gJgC9s$N z32uZk&ipOYz$WfsOXTtgSZ^- zi1PAG-*y^T`fU0HwrR>)v>9l+AYbe3GI)oTAMQv9!gIQkv_}=JoEm5>PWsA2ezDjS z5BPeXg=cl1FtummTUZg8kz{VfLWc16M)yB@GD;#+6~@jV!sq0NM3a3bjsG4bDr?Yh zIV$zBDXM40Q(Vm$8*ZUf($D*T3fFAJSI(zm3EE^j=+t-y1e+94dVSKrYkCuf^}KN$9I&A zAr%q|ctMz6#Ew=Tq=d@nPM$g4ALK((2O~uTKbC)`p{cr!j#c66f49M`wzJJ^BPyY! zLf1%(qRzz(gqIelCE)yWA%=pBC2k{1QBfM%DHsN`iCg|NB3C%I^%+VOrFrIXX@-NS zrNjR_jhq>~jQbAXlqtElE3$+ufQ)=9+NmX{R|nBCA*tVGFZpk-2lA^T$*>1dbGC%C zP*luunR|D$i)3Vtq`>SnReU3t&> zmHeNHan0q0ioak;XZIwu?Sxr0XTZs3$oMExn|hDMIe!k}%y=K$H075RCFJBQZmCsZ0cC{xZ z%+Ru`u(+BatmX%Drnrw2?&VU@LNkeZn-|LB3N*#w6qTrC5-|PdE~Gr~8$-)hwDTQu z)~7E?S2Hn~{``zeiDBEFck4Kvo;X%vsSR*9e=|x>V>VSY>O#V74xFok zZNo(t2rV?+)Ni=@gcTM}KehsM0@lsUJ+`DTd|AXh~5M=NxtTEf_}& zcxor+Uw9S9Rjz1?^IQ<3O20G-?I7MK(HiUb|4~8MJ1xf5WR|$P6tTAum5qiBIx>K< zbs}rkyHJwJ{BkW{kJdk6RLN6E>akxQ|;0(P}0b-c>uO*$( zH&yl5@D2PKRo(A3)w_eW-0-A>IKKjJU@w_6B4wo()!bxw_>Eh1M+na{TDCMzZ$Q9>xW;cu;1DX5$zlxv3e*EP_}v#P*;cZXF2a_TXHKa zdsJX0FE3aH(W7LI-jr-|!1VXE`gzsLxV!X#lhfm`3~WYK5ul08rTuBh!IBNsfBCf8%?QS zoS9=>vBVD9Yj8J4rS;FEbA#0>7IN154wAK@7N|-ID{Yr`t&VNw@Nq{0b~SuEn)ngVeH=}#Q4t+ zu5jkGyq!9}%g}Pj6I|PaiW*QrX8gk!ZgD_<3(21V zsOS<)$e$8$q{EpX)Dtj4;MY(wYZt_o2?`mCuyTMX2^~7oa8>24n`wY9htL{>k_?FQ zfdUe>l*3;L`^BX9-d5?KW+WfDY(h9J=uDz;tUUgRc)5tUZE4KGs3V7pBj0l?)R>MD zNe6F;JUc>}@6zd9^`A0hKoW|v^gKrouKFRnlnba7BN&&@pU3P)F685P9Y|i$d>&w( zr>46PE6G6>eZ~g}yS?0Jzlr5jQ5V$e2hj^cOIig4)ENI^6z%Fk>#A}xIiv;63Kt6x zkM|I*?2L~p(eiJuY9fZC-=`OYy-&EOId-d*g*r!w?~Q=X*;N1Y-&{(QU{s;e9f#-p zKf1ZcWcJr;-zEG&tC9D56Nf{%e=F>ORoCZkh6b|yBMspq z6gzDAu@4R*zj?|FhKA)rk&v(nV83-l$q~v&nF5=HcV3dwJSv?P<$0p`bG(D;OUG4f zhswor6W|={EP6aJ2dd9l*V~CNQscz*s*c=uY36?7QPO!)Zh2b>F6VFseP5gibI%=# zVP0g}WftS4a60_L&32UDbF-eJ(dnpA98&cAwvpGzDE>^<$j6yEE2!HG-pd15tD2_Q zcPK|E&D3bnx#+#8r6ce;of|$ysV<2!iQd1 z-$YcxKONxP7}hz*CUfU@v$VI&Dtvx-M=~&|zJB}4y%Mh}D!pm9>3{4)Wfg#6Je7=c zI~Uruk8||Er6o@W0et|BidU)__z;= z(vPeMpQY+>{U_@je+Tea;*QL-r@zu&_ao~!Pr?Pc{?OAYtt@hFp`{;w)O94n6Da<= z0iJ!F>kr$)>4w6onSe843RSfF?bo-PKR!5!fCHY30(ZSF&yAcpwLYK7Iq#M6vfVG%^)~p9>GXeWUa7a*&y3rD z9yvTrU+&_hcRbGrx!)Z*4V~M6{N(p@hUM3M4?y9QI`hUX<$FT0`)0cWO-25aVQPUQ z90J+~d{*z105wrIds4$(^0ntPb<+Vxe!6OJ)1P<{-eiM6gk9(Z=EzK7aFY-W2NvKLZEoZ#ot&#L@LrF@unnzdC-G=&w~ zkkEaD$?3RpD(L~aLV3xuD!%f?eV*%7DeCpv)~e6eju}uL8n}|np5^Wo=n_9oRHjC@ zWf3sN>?U?F?WTTOeuHh^hyB&O^Q!0nlDK}G0}1AS0WP#r|An5vEW=I69MBZU{rN8B zO?a*Ci;vU!%86Z!3;a6Hs{%+u9l^o>Sz!(KjT@g*FIEh~49Sv}zV&2tt<}YoF7YR! zOItrHrVa}|q;dm0T=`4zMY0rOb4_%!X#$%=uwaU`44rnlDFuZ*0tTuA!n+ zCKh`>cvm-borYCc?va`ev4*M&4;MS|Y12(`5k1YM=6B-lE>CT3=z9{K%@q5d4R+$K zMa1;Uy*x0LyL>$_hrRG0l={Vr66N3kpr0EJH4dD&t@(96Akos+qqEy0?V-;rt=}Sl zmH{p2bBwM`J}oRLU|8Kd=Wl@1t~f+ZvBr!@^2WVyvHU*TM1mok9p?kfmiLm)J^G@D z62jXb<1G$*h?GdpF2%8RzeQ45uid4b|DGSi4D0~T6!;Y|Wo{+law+^XjE8>7d0Bmh z{k(%>_GyoMd$Xw@=l4QsW1_o5IFZ)tPY5ROCX-?XQnCnAhQ1V#MZSD!>dELYZ^PQ` zHL5cx9+PpGLZlW3WI;$A&V}Rx8o7(>U6grZ+S)kUGe*bD$xZsu=O%qIpc)*ssdSW) zJ)1X4PBsXi?^;?-O%X1@RF|G2w#z&`kOC1W~TFb#`i;V7$> zupn=sfH${8>7yY}2?5@oikf6Q+xK~TxSJdPe|J#cfTXjZIgUX4$os0 zqp@3aR;Frxn-J~(7`Td0`NVYho%>RQ{_#EmtM{1+xb3)o^|Xu2#!KDk`y9U7+3C6H zk4+T(^a#xce8R^GEcAZ9SAyOBv~D|MV<&k(mWb4Q$H#3?4pQ9ek7L{1AKhA?JiNQy zPmIOoBEw>wV8-f8xoro!m~aip$Gzj!Y`jKJdscnTC)Tm7u{>$KNl5S&sON+ zxOt9kYREsktZevZx`F`uo{$?)8&fTyKw>Rns&EtNi@7?dcpbWO3UR}ETK9le+{6t$ zts;8`2p=?Y*IT-AJ35>ZPYLV^<#aJXRX2pble~vk`rlALu7R&FbUljIAa#c2`aY3< zxMc>GWT8A?G(FuMktufcsoxTE>s#-e3wK3~#XqMTePd=bPLf`&RfJInTsE`s$rGaV$i zq7u1h9@`_hcKTO~rl6cgno6r(5Bx3_v44989}+0{+*|-IcOQJ8}k2X4oogC<4fp`dQSEh$VEL>^$no)Ar+W=#FI{ z8}tT*CUbzUy=70_M!N3UkHvRV&6I_-=dwq}pu^Mm7;M&jTLL8`*vnzstmq>ffo(rF^7iRh=tlZU6S6^xm59wg}BngC> zUNqU=!50P)ex%E7gV}Q%_a+WOH7a}CNjt#+FHE|PilGcf?e4#Dd-b`BQG}8sNj*E( z)PM(_^ba65SqO{8u4T;{=N$AMff?5gdoLd!D#MoyuaATY&M`N8*St!!2E;+jk+iHal7cz_zPIWz^_<|J?Kwa7-8UJ%)QsRF!SqO5 zgT}G+?c(!YE`2}oI8J0t>;CfBy|kjy3^ZY~)bWt{llyT8a1xuo`x6p2yB30$wU`o1 znquV7sV%N%R8c}%K=^UH-@k@b?f0e16Mg1$4-|3Eck&)1rPT{>xnAKju2$tT5A65aj1!-gH59 zBIu*VFgjH;^;x3VB7^IzRxqkj=ZSy64GS(9vhdI#0O-ErEiH7Ijg&CJjs~w{d$b!N) zM3|7>w?Ne2;uLffYi}NmOBJ_9g)yz1$UqdJbxzf=2nOz$4-}9KuLQH>bW&zHpz$J= zLFr0k;=uMAW483rcV13jw4wJe7-gTWfq@dCMJBD3*LE{QA;xMybP29S4{S#bV zJ;=C8_$w*n@#`;t^p`=aP>tRKfL!{+XNyJ@gY&*k3%7eKExH=({z6FvB?rUvl@OLM zxW)9&dvli;j6I&EuWv{Pj79#rXQAKz9|EzsehSo<n-@L-37*nxL7Z zVmkb}Nafa9pIkBPfoL^jb@{msZ}#zk&g9>Yx-2~*15L@|Kcu$WVpme;JxGVyU)qK< zB?9Pz5)?qH(!kvA-t8|ean^4}{t2PXXj>-FKjU^q@^0k&%O9}+#2z~6+;%aAm?orC zcWF~x|23{)l}Vxb#%`iDhx+5$S3~L#6S)Fo`%rWwoY>0warK4NXLl2ksdHYx=q%$Q zYuSvuG@==18tC6*%bx9KOjw6kZB=X==ze`<4|V&t_;gdptqb`GDey2k-qi|5I?VmQ z=NR*|%7!*myYmU(r#gWMDH9z@nbVo#2c_O+oR1_B)#2~CN3>(U33s9|ekEw`ld_ms zJ^{&QzpFYETil6@iq26(yoL8ybTwJ?-LXNIBHK@&E4`QN=*Y>$CTF!%RDT--yt#1h zYOVKXz_8v;{T^nCZ)L`5Cf9~^;{YRXEYnD86D#YlyRSaY%(-**VxWXd@75(x)bmH_ z*AlGt9#C;o1>Km?2U-IRzaT5M^OR!FUXUPk686#Wy~z%3A_!Eu7gLn$xe}X5S*Mkc z7jnHGmV|Cb)Yfy@w~LF(^GW*rb0yYSA+ON2niuTPr#GqdQRaM$tr3COjbib!GDp_o z`!cxc3q%T2z4;<&c7%4s%>xurGnx6G(%Vg4jX4|pyD;j_QoCMP-0YI-%+V&j zkw{*%%~dPdpW3Ygi8&Y3@usj+CsG2A%cTq4eBpX7M8X|stae)sk-0XF=)dYCo!L)4 zZuAmToL&=#+phP?WJaBIn zoECXLr*FNZ?%7K%2JB`sKOhgdnB8=Iw#6);y1M9Mm`;&vsQtqma6GK^1^vCBJKo-Y zWtQR6Q*K25b?rF>!s7hCs_SU;i_2b+^`9W}|0I6_YF^t!5XjC6XebB`8gBtv`E8V| zT7fw0FTI39Ugsiq{`G;|rdx+N!Y_Rlb!$6A_Oju7&Y#;b3n}`BYz4L2p^w+9 zWiP{ZOyktjz0nAWh&^|ROf|1W*2^u;!x6&sURs4)ZdrQ7N zkl4;IDnnDR?lWJG7x_Ip+TC~GV~Px}h0YEsfBe-t2BtR!+mhIub#%Go&-VuODUTg; z%Xoh}f{pacdOB|Eku8E}fZrn{H2d`*1}Xfl&+Rqk_t?G%nZ?36#)^hQwe;t@GX@BJlMkJLkj( z>wKj#+1BmvB4=DwDzoV;eeI2MIb!pgmd6PqgC1^Jb4S)JE;;F7o~W3T>ld(N$->}q z$M2xk6Lb)FMmrCHHaI!n-PK-}mX_=tk7hx4KH1@I@t(=bd3%tAVdsomg0Fc#Ct|yR zn&j2&Pvz=Orm~^KEq45d#j{+o_)*p+sWX9K*Z#!Cq@OcZ^c7aW#sMh=dm?BOl!q^< z!ZX(4$VX5F^947Sp~4#$%~@Fya8L@gq)NAR*lyEzd3>Mve@VuwLTo4IK}~J3K^z5N zK?{#N`NJ2!{QP{2uOR7@XKa`;>rHFNuVSR4tr~Q!8PAJ>&(a< zZjb7jf#^}605xTu= z7-hP`y%hm~s!-MQgF^`Ujr4{*F;B`FbTK(u!U>`GF=^ORnJ8MX%uCc#m=H=?xl<)A zW8wT&RKqscBRAI(IB(O^_TmHZR3m@0`wgX>$F*5U4Be4yvetf3N1bqQP8J`cJWo?s z9|v^ghwsKxbo&e}W_4L0H<(S5(fZ(uRp=#SCi8snc{XDLGz3g&H>Bg?2<%^SnrkDs zZLqgquWfBdCtLG;*0ZNsu{3Otko@YpSgwjuYdzcCUHNo#8h#PJX{ZFH6K~bCd~bM7 zV3KdI%!PU#Mwx*d?$gGLkqyAZfw-HV8}H}&Ew>+^yF-XO>>yYkpkF*o! zQh~D%3ltKm_k?kEl)qdd`kb<;ey&E@oSwcrK8*-G2T{|=yfXQF02w#^-ko}lD=R&%qO{%S#02~Ak$ybB?fLK@eC_Fa z^anc6#*57cU$ffgWYRytE01J}$3+mcR9|18aqF}bYbzdapm^pNOuGtP8zZ&UJe{(E zR7g2my_PK~M~paOeR(2~Du}-rX7KVtz-Wq@4gxoEHO3ytqk(ZJ!U+P+{!0JI#H>*0 zUk8r1mJMBqIGDLmI4Q3@KyoC;{xUd767BEy$XPzL2MC~u*~Kp(f`r(;bdY%I0mq^nKA$q|ALI0^yZ}Syu ztWxqH6vX<|MStYP<>j7+?Gq(!bJ{zI+20WaOfrR`CR%YN4#x?!;7AqJg`B?(U!=gQ zfYK65z<-2X2ua!loPT4lwTnmKCP6M-nh;Z}b(JitD5y&|^N0*cA8%Q^XhM7h4MKd} ze#tE;1ghTll^67vHUohnMBkC`fGc815(M098y5ZLf5clORHjr!{pEKuz1|UslDM^t zlwTkeQQs!Ur0P?`;2?oseS7<%nOa=vkM8L<+49<&Rv=L``|e>o1H_ONz@FZWBD zkgvX(!+mxoTah*#s{tXs_#3BQFA4+IaK9_2eSv}O z-6>6brq4xA>&A|1R09!#g6_TCo@NW5X6?MB1ZrADQU%WsABOwUfRnYnfBcf)SWSGG z4F8etjT}C(|559mSOK6pPUgnO3#pgu&46I#9{;V|7r-IA`YGu06Uc+6oKRxv$67IA z9((*RZQtd?Vd6d=;r)m4Ke+iC1gRB4kXl|4l-S_x_B+2n;Rl9^f@;VB9Weij9x!nI zkzcyOpuqFeKKL`C3JMKYIyN?^338=nS2?Qr-@>I-)VpsV4_cegs31|@A#SF^0^xPn z^Y2o2$W5K_KU~QtVPNO_B)&yxR>G~|zR6l(r{4GKPY7aw?ntLEa|*W*Xf(6^Lwe9P zKm`G2O|Qt(gI&tQZ0(#HZV+V|I9vt~QXyY+;!V&cX*=Wl2gu*NM!kWeEaUz;0u}QZ zDCLe)(Qaphc>CjDPZp-kPW!uAcofm|U4|Ek)4~f0nRqZfyW^~se~UV5wBHzAjDnCd zaig;#PThDUnu--3*0fKX0D@W>*_sDCJ7ot+&h^}Ujoa!B{}F@2LE*du>A!<>G?KM{ z8-lC-k|Z7;CGk2`2;$IjJ4+81JwAlHMuI&UQt{aT6NBe>Lof2gF#Dgu?0#HHe{@%d zB??N-6{I*WwlE(ix_|tET@1p^A8K&fNKMyq*&1bvpsi#&v{{zQ$%E6Gd?yj;o9Hp< zKTyQkE_B*seWeZx|<+-grRuw!QW{>=E)&wE^%nim!pxEKks zuBqp-;8HWbB%<`SqWDXbCG6_udm*3-B>^-;Hj12aTX%!rdsWt@>}oaaePy+^gT)zK zWaHrnft!)520qabq`|rqY<4;_B-4g}k^=9qOKwS%fe45aEomcpqFb(X#HW(ko-0Ie z?^GDIZ-vQX#ez^d{;>WZl3%9*z9b~Mbmmk4M)D)=2&ADBYh5Gg@7sA(5 zH4rkg^}u@|$!~_%PnZ8B?sxTYGQQC2Mtx3lgEn%pHay-4MCKYu_3oGs{(kJ%{F|dI zV|nRj@cN)WXsvPa-?acHp~SC|VYE98e)ki)HsiD<4p&kF-fNa}0-ZVu%xLOx6KCtF!55g6LR4U}1@qxHqCNP; zZ_DR*5nu`ZbXApCQ4v`m=Re56dw>r3_CM1FA6b$j?tL9^I}vt%FHdTl-s3VE7q%bQ zgRcujuJGdIz>2NA^SFe+w}0Y1t^=~4LsIWHBytshq&a<2e1BIk681|Y`Dp$}c56NB z^JnYxW8&`NTG&azMtUH*KD+u1J-jy6^BVDqBGL?J%e68t7 zjkQd@n}6`%);utGdhailN{*lRhLy159}>?#8v|3e1IUP-nL(q&CV5AG@gUOn{)0!pxg>9ylT7O=%vO zVzU_a?wqq?tYwaq??~^W(kHeaYg-ZY+__uS1ABfYd?4w{b^?2z%{x-%;AKsQ1ppIl zBYG?3vuJy=EZ>PsR^RFf|H$xOk~3tn1YWuG2ddjd)Q$mi1r);OqS8G1E=odlA4<&I z4v;QicoB`|w0bhpTAGbj!VWRt{e-yh&!y%u5w{}CFv4OnWnhg(V>oGRbc@>y~OmobFLnP(t zf<_@bfoJ%M=7|nAzoXP@A_|^Jj{cXUp5P)%5)JR-a6^H=)q&yhcJi%4xuIgT27WDZ z;6OzZ{&b0cq>b@x#kWpbXr!daef!dfyb1AFQ-Jiq{=_3=@al_lM+W2^RCIRagB6-q z6}EB9rFqrSk4(tD-1;Pkq`Xbj2a`a@;-i%|%U-PvijPrt_X$z~+DhRuUStx1n8GDj zD15g_bB&Jd{QxwyaT-G>86%1j6`^Bsl5q=~@j;HhKj)I7lDBS_xzM(4vx zD-_?UkO-QSmb;TC%?ycO15>>e3^gkpneX~(V_V-6Ym1z}jQ6wo=VT64VmovLA(8wl zi82BDNmC9+FIfuIxLinkGEIpp0r_{f;tU1Se)b8N{WYjTnL1p>T3*#To*7*oWVBAQ z7^ixKfpl_(W)F2CmqzvwtqKy3V`butW7>jUvyvx=jAbY515-2p)L(75AVKcC_B1#$ zivog!Y@| zhik|xIQs|0jpSE}C>o=EI>M+y0iqyJJ{Y#Q-!ypvqoyztx^ae+Q>I9EDqwoAK)*S% zR(}dW*Gf=FCU>D{<*-`!-*&1x(DKLNDhhTgKKW;56>aWUesvBun<>#4QYi&*cI z^#XJ^xhe77zMeiomkJD*(bL}(IY&R7%-*H${oTnI0Uv4LUm$x8f=avBrcx|XR7nwg z+R~Sir-C~EVrT}z=3I!J9agv2>RMMA$WJTYQofeK|3#KC*+o( za=v6G_j~c~n$Am5dZZIX{HcZ9J%flXIKw{#ilJycCP~m(@AQxF%XKJ(W+&F+;W+F_dbJN>PNr;Ims%8$vl!0z1*w~U zQnK>5QQ`lqy|)aC>-pA&LvVK|kPHOZ;I2V}hXBE4aCg_>?rtFjC%C&y(BSS8++|=G z?*9GHIali5y6>s`>HTn~YN}>t@7=R^cdym`tmo-oCWBlxahdUVAD+{uO`m280RtR) zjW|4aeacTVgOC-nCYhD*EfgNEOPz;B@|3HBN6uSq^AZt5Zn%MOwFLVpb@c%_03P{- zC(!t=#`6w0|$+jQSrp~Z*UCRvC@aD(R zKb^HO>%)bCYR&UNprv6h#cZWw%_`9KbAX&@Ct*7z20sBhPW+lyxdL>(56=urH1Hg! zh+g?MS_s^E3R``KJ}!Hx(L;X^9_}R(5WYW_v2O<%3h7odts$#QN%<81x+x2AY+FlJ zT#O3eMJ%#>91D+IK!`3bKVTXGiX#`Y{n>;a7f6hVK|{qp!$tT31UhtR(M>Np#NT?Y zPsC1X*QE1~8k870x_6b4&w*Vfh&?zHbvZV)cKLi1izM+l48yW8WC6iVV3bjAA9wZjb$eh?g;wlyl)Abc6!_R za6lIJX@sb7S^cC(__{Sa5{jR%=O|+q+|V)lrc#nW(rjGs zHBK?X4?2r|l%mOC3yoetjP3|z4<8h!Ts=%Y<@}%v#$e1o+>1h(y*QH{oONzgn$x%FOFyNaxfXKJF#Y`nsFf zZ3a}!=u2hzQX^rdz}&kwB9wI6JK=m1c@1Mqp+w;)0^AYv=&A)Aa9v(sw;dgj zrZ8>$)LU$N_^bBcI;%2rbh*;)Zf?{^D6gG7r58SKLizp-T#%88{p8+f zJI<$lS1GA0(!9J~;JM|@ARkod-$J6A(Me>eotxgW75!sEXnozc%bY5eF3Ur4Io%>& zuRS1%TdUvyWnFcjKGJKFtz0N`C=JnwY8(ly1|b`2l!oK_@ue}{h@hoYm@v(xKj}Qy z7_DzenYa*tkwIL>A{&plQ!Lf)oyd(_c6u?uzI>Y9hfeRniLC66{mFd6FQCck{Myg{ zg6s149)76G;Xt!M1(9rBBc;y2ik55Qx+I6O!|ZZqCd{K@?d9@7t2~MblGgS|F`XsL zkcbKG@{%N=?Fl~3_TXq97Y)%6@Y@g8@QvewPMu%bc2|<1B7^!h!d2~#qH`ajU4xDV zI{Sv=5UO4N;tNe(zSGb4ePw=vEAdT|pX}IL?*c(71i8-Lp8!>heoeIrdxy3|c67Vc zpGA?C0-KuVfxQ@?h3N~8q#2xl^!7&%JUk9C>q{_rFnd5&wwInPT&r7@nDIvcsAu=p zi&;?m(ng$ToiQJdUtqu!5=WuTs|_KMWK+k|rket#Bpv!@BfdOneWr767`6Fy*UDHn z8?@^KSc~&fbsk!r(;T6DLyOJ!yC_qt@|*277DgKSJ-%LzzX1P?tzI5RU#G|W!A3p) zFFE&W*tE!eO7`G6*xNI&B0)tmm%R+{Q74l*qF6yzgV%c1-$fBuVyO8*-@?P;p?A96 z(BsbDJfrJFg8?kQk_B(PuFA!+%4&EDjE%lfNZxdvA1-W7=be=sYFxIm&!z^VwD1VUSXcZmeD3WipD6{Yw0V8QwfI22DO3}i+$#$?ulynU}Srf&kMB# zb^-y1UB8S+bDt?tu#HCZI6aO7$$nN8_MlkxbdPjZTUoPjA@@F*kG%X@oXq3NJE1ul zjU_;c{_K7|pZ&gKjZ)#E2R_)&U}7O?Sw-Y_h@xx7!+ysI1Oo9jpQs=4V??^=C@rR9 zTW(>K3|QXWco5lP(FzF#pO%~dsZx5(OFA*tX5Wn3SgdvzeY&?Z8!36B2g5^|oF_LA znOBH@!{W!8GjH&k(r#Bj&WU%d8(bZKn!H>aF{lfjIv#0CZ?vHHz6+~yzHP$jEU$oh zTrt#1UGB~hW59olJaFUJR}@aCz#*;;zuejO@HSH34*r&nM#5xFc zPfZFK2}ufbOjCT;Bi}pXE%rOn&GmKSuR&y-dR%HE5fO>hbMF{Rl?s}EBWgU6UUZW4 zK>4g8W2oU1-y~DNIhe?^|Aw96X_LeZqq6(Qy}rqA=PPZjhX7psNu23B_(t00QfQaA zMdl4Pn@CH%EGLe;2Z7&0b zVrYYO?BX%&!D<($h~h)@<;WHW8E1G5i~e?;b6QvZuI*XcqjAL&f*;nH#&~s1Znz_e zv1#<`6X1nZ1EeQ0i=B7BE@KIfet&;{`Ki32y;F#1?bo<7ZZHBee%@6>!O7lAbDS+0 zQa0BQe_T-ZXMaf0LVa!`b?Vr&#K7C8OSrGDfCwM_O|^*Zd2Ku?&!Q`^(pSgX%1LJZ z(T!Kha79PmqN4)&^f=bo`gaKKDsI--S}VOzm`DR~cv#hbkWU(Py+`WD;mn=Um(Oh~ zu3H{huhY#JJFNJv-MooFKRGFF2Z3$f(KOE8dNbc3aIJJdxpRRG&(3M!{tfp$l+gLc zaKV9@R1@*eWZf!sM;j%T7BgVDY_ny&%Cn0j#qJ#wyYMea@v28gk8UhIkv-T~J=k;{ zf!sZar47)JkESRwMX1nm5vKt<}dKb;l?34iR?PwRuwho-U9tha=G;;GFX zKP6WO7Vz9!_cT^5IMa4SxXLIV&C`pGx1uldc3*!FT*Dm-{H)3G7c5=pw&!MHkhknd zgFH6(Vbbwo(~_d0l6op&M$6;VcB__8gD-86zk$@ul3K=xQ<-^6#by&G!4$g+`Gj!7 z3em(y_ni{`yw+G>g>?tC$t_1Jy`fe~`>C@*Mn0nRHWLFoC246RA|~;OWDZ&PS=1k6 zVv&S@-e$;}0gv#WBN5C%L$>6`e&SR*fD|I)+VOi;-=4+#K-)$<%(|3 zdcDghe?mMl`($7Go1&_cVlnjj7~bK|*mXVF&SZq+!%IRyi44yXyWA5&!Ebmh`lq2sApr3O`z@#iz4d*~TxQ9X&a2}utFL#Kt7tCV19 zT@U$BK$`BK8riWKj`U*i+cr_=eGXu3VhsB6OVng*k(T+dFE*9UaOkyeb{oWTg6FYa zBvF}q9Xt>0TBE%tqbPXt+9Bj(t-kB;a}TmYWP5CBITns)HAMqj2>bCuj)_}Y1g?u< z2eM1?MNz@S#smG!LI=A)_H%;Dg8%8z&tyLbV(;zTy+CvC>YXP^f-*z%g}BiOrG}Ue zZ9%W~Z4>ul)Wpd7gQ5>S#*&lUwF{f2OQIKJ5bVE;=B&6(MVtpy6i9yjWX>mSCy|#0 zCJ}WNPtWe1kC%u=T@b`7&Rlg4!oTbeZ!!DKr%fUJ(>c6&aswh(y5~3~L{lMc$mP z%xG_Ce{cBth*70TBuD>jV1+Q!s10XiT|Mt|n$AB-wxM3LGj^preCQF6_l7%{awg-m z-$BG8d#oQiZ>`hcf_4X(o<1p03=Ga3MUOkBc%5u?{^2Gv(dS&@Y9z&M1dl}ZaFQUi zuu{J67tQsdpmwVWsq|*ND$$pls>OSQArXi4!YU(f?_Yt;x(;;L%R=4#@S4r~@~!`j zk%RlZv6oVHykBz)%x72VH=YDxM|C-N?il9IJeWffCK*PXUcW?>nUWxGL!g?t-1GwC zj+;9{?K0$~0}=%8!!cje0#sq2$lz|WRnG6kya>u>kOXVgv{f*I`d*P$!{IE%jP)d2 zJe^jSh8LLhs^U-U7rlILwefEyVE2rTN#s7?{&S$C(x9LUWy?04+^F-o+ml0h`T}pO z0-fN^xOSAEuz$(rT{n(esk12Dgf36N$YulolkpBx>ZxdrBm9ZC1j|YnfER#CFqm0O zPGX3MhQ~I2b!C{9C#b&&Q||8~5_shI$x2nYtjIQZ`7j06P{Xg#=FZpkZSa~oMjnWm$VFN_aLj5?K+$B1=XSDXHl#rxtd_8MmCl z@~f$Lkm8QS0Xi_H;u*R=#P(|Bodf}sRXbil!Id?h>f~)iJiX@MrE*2d>L;j%KUe{r6#~Xj;qFq&fi&#)a9QcAPb>TQa7o(w{1+gQ zXO5;4fY!s$)w;vO!`~CIgFwcph|%HUUnStEL7>!&QgxG1BSQe$`LKWziGPY9Qdh0E z_S3x{G^;B_B?8bQ?Jcrd+ZvX`B_nO;?{fIT8L+A{fZnNIZo;Zb-I9;7dd5QiUo>Lego^7Bo*8S#n~{mnSNIhyCQZZW{;KLw-eV_0BS#xFf{PE!Th`KVSOBx zV4&{}!o}75z7}z~BxC~1)j0RIeh$lF=5IMIdNH!=CK{zEd!NAMFoway^xdIPdSTud zuf`Y&IeM`VoI=)R;F5xDOv#-6uAoZIn)IATJ~%C|k>X z(g9Kk*(nf{ek@(CAT5lAj)eAA>e9c>*xA>7ur)(Y6W6#;Rq-dXWb&7WaZIb{(D`W(|Vv9gbJ-_sFa(6LY_)3DCO3 zMS?CDkZiKtcYSK~hXLF4jqWh^*7w};D>EfHmgZRQ^l9n< zdrGR`wau%Qto9BelSX1q{4IKedlc%;xAWU4+0XpWnv`;fDa)m)qo#86i#+Gu4lNF% ze+Zw7lQ`GrZ%5cqqBMh$fv&nX*}} zSCPe=7-mg;^+obucn3D;zl&GzbcimNoB^gHiRx8aBGfIIm`BzK{uvEGgj8*2wJSi%B9W zQ7NZ|J7SzZ6V>nvjji+za&AxQ-9TXd?zT)igpx#8C7mOCs@#@MnvIk!orND1^O1`9>n}QU{GNXeG@iWTP#Lp*EW*3F;DYNH9O0` zRAOvDCRneQuSetG;N!|gF~qA%4$@{|_VC@^g595hs^fy8rf~V{_k}x2%f0>C3+N;f zY^c&YLt^GX8?J{RdVRBzuqaQwGiyZGXq>h{c*I4&c1hhy>WRK0N-mMd5;1%7C=g_( z)Z1PqsMZ(t$8k}{A`0cyWpNUCuFG!*#WjNu9P6+&mhht3&IKm(jWHm- zzM{VyBrf_r#9xQp8+Gdh!!`i4gxZ#GDKe=!h?I8eQWsawzEP9lNqn&aBM?GBpG)2iR2&gYg4JVyZuJ&&+p z55=GMpH?spoz5&!dU}XqEb!ezeP`;UT6Kc&g(+x9@4E)!n-&IY%*d61tOTtb5iFu7 z&Fzke>|j*AuFQtEoa(taB0XAF;{o0Ms_;P*bVO~}ttcJ)B6g!}WoL}C=|Tg+RneQh z4AoKDHNR!}nbJ`|_(_p9tm~mpj0Y~yC&FV8edk}|N8$DGjC0CjlZe;-h*jpd`~G~^ zus%_SC#HV~wC3)ZX8Ulw)tRT2RuvRWaZe(81H8paamuR^>=NJN^e8~+2xI;9SiuP9 z%~z9k=gQ|^IU~q0Ju#zXuk=nm`D){fAfJmqIR!&S2XQMF{{#_t1;g&`pXJ_hO_qmS z>i^#y;~+thkk@?32newt)iC)JC9;4zIq_fEadD(qL*eTPzWN4NJ`f{<4MQN-$LtZ+5E( zknm=q=4^_*zO4o=cHoJ1X@PJZj2rd8aG8I{3^><9=YcfianIfI!gSS4>N;S3z5!g~ zpQMO97&g*)D-thLZPm6l8tu2iCgk^LNfr$Q8 zrSWp$x8T@+ZnjAHVjmqv?Je1&Qbt6WvlSc~st;vj1__vwj3e}=LbHFef4i4#I4a~z zuv(d_b^uKp^q~@Kt{MnAULu7=A(}Cm=)M!Z>WeLNIa-aJcb|QK6IVzvb~a<4D1o|e zuhOMO9cg0ugyR-MGHbsa-kUoFwVJ+}Hl|Hy9e4Z|TIXGUjhi10W86BFdu+7#FCSZ^@3`HLQpYc7X>wn;TSQ|=@MkN6H z2Ws3G#JM(2YrqKrwr5u!5(}jwLYxiy7u)JT+*_mgb?S*yWD!UCqLLlzeQzu_^mTqP zE#G4{kNGP{&=Rsar{VTc^WX&7J6Z0Mk&wU339CG|mET_=^k<^t;nXA29W&}yo2CDs zry%Tf0?#*Uns44#8uD8aq8ZIs39?Nbn`9iLJvpC&HLIhLsXTKh!iezSd5bGF8x z&u3m7LaWZO9ZCC`Ew}o+bPVMwkhseu%sCILBz2i8bZtgHF@D?!#XrM<^{z^tt}A{y zyt_HQCce|-VI&e5ZZA~Im374cX(*UalcjscIf2%}A#gj@xT;#QK zA8d#W{I5XR>tFF!Ag<9K^=t+q3*YZc4#yyO^6Td-p;jN0Haf4FI!#WFKrWQCIY(H$ zGW$Zx_q=Jw*1X}Ub^8~$#Z^xJLA3-eMV!x7)uQ)~em*b0u$!(tRfEaxDBf0Q>z1(z zO0Ec~E!j2xCwzh{^sW>6Ip2{+Ce5~p^rzQrVbF*TUwe+jEc*-Jg)2I;5Ev}imxI` z3(cpEARk}d0QpZ~kDN%i7E+cw7))UzztKhcTg(@^I&*Hw#+4NIO6dlxxka+D@}bB7 z9`PWxGfr(~9dAq=D?Yb4d^8~ml$6#|`O{^*;s55T+bFeDx)WS3wQ){7y`ScG^B@3< zO6$iRdH)ZlfvHp~?616u>KE}cj@7@JUOBfYefMGH9nt{fLZJ^Fa*6=vR?D;~hhc8- zN1p&oV=C@2i=g`;=H@^IAr{c%~3iaWL3Gt?O+TBa96 z{UaR(XdyM97q%#cUIC1B^hIBbZvMS$_wHMrjKS57={U@57%gb6?fY`+Qa)7&S{Uae z8T??Eg0Efqsqs_Unv}PQ3Zd1Aw|{iOT{No@bWcQg0Y)9Z%XB8qYt(4-O{;>we+c~Q z>6`3E}c`>7k zPI@!~z1K5)&i&5~Hv-{LRs_U!15?tMs151D>&LjEl##@pDKG{?zVqkI2Ox z7%|IhN?W(~S2ynYG(?TgP}wpq@&bC6un9BcA$%X&)SkAza%jBXsasWkW7pQ_h7V>ejyUJL|1T)g*N|J3 z4HD&HD5=1*4D&$#Rkpl@r=7v7+|&oHVS^XZ8{{_}Xhn%+dkpc5<2Bx|9)Fb{p-O>* zO1qd*+b7RH#8U#8!Iwr@#xI0xAGW><_R1X?c9)4^&a;Bw9MN*N>mw7AHCk9mnv41prR79R*ELnCzeSlp(H-2RMXYRZ`PXb2-a8GOSzByM#;F^m68?Ts zgiIjVT8L+2URb9@4(`j)X6YJ(nez7Pq3kzes`u}L?4lEnUeb%TuB6?O+If-#+2VFC z-|{&dGEmv+{}#=dFKQUtF8FtKVIZ!nz?$ON=Y_&0jYP=J8x}w?L+vn3kIwk0dGb^R zrs5l$UZ~FYmzS={Qi3EjUG=Y80MW{fon5=Hpl^iy>FEo|EO^I14u zh$XLt312_RdHqS{-KnM&4b^3)x(Qa;&a#H2t^wTgT41@7 zYi;v9FB9VXdtIWc07+cURb60uno$%1EY&|!H|eBeXP9jI#@poxO6-D2bU)nD_@mj2 zbORE)l!dXL=C#(ZDdDmj|ES#v;lL!J$`s7Fxb|CaZ5@6$gG{dn`}UmlJgIRLd?qxP zKUMv+ZpwBkxC7wX3;`-7`oTl7+oRR1GjR+AYU!Dg_IkP?@lfp+yMp6XRQpGghz0B! zV3{d`%erX_w*!>=k9M&X*n1$4l>~dA#OhW6~8p8CK zf-Wh!jR;ZVG; zO)>6Vp7*rv_V5TD+q~k`Z1|}qbrl&c(XZKu==0X_?J1nn8^%tbO+f#bsJYAoEDW2y z`W*-mamVdWi3WfcGw*-WCIXJ*|0G%bub=;S8Rvi1$$!=xbvP&gi! zfWTzm4Zx;}_ZHy_|6TO^pXeRQS-JVb2NQPn6Tl{$)Fjua5 z{dC@yl$@MAn&QdP_J%P9xk~{?fQPrGA~i8blPI(MyU>{NONrQIt}ug6BeSEUBT1;Z znAodV#H_mAr*3M^4o0yQ{Jj-gb$otM52dufYEUS&M6DPLTjb#_!2bfV#@8wxUamh1 z3Nm=@DZTH{)TeZRvv1D9NES4kw~O|lz#Iii;C7BzZf7BRL=uKZe_!~5DP>{WQq#ki z0g#Osm`ob0-m7#`e-XQ;tce^!=gn@oS7l#Hd+#^=-CAarU26myA)_G)K=9N+uSVHymg`XiXcaT3O4aBh2^hXvW=cs(RfC7HQrQeD ztKm=>Lod&))9I9ZRt#l)B zKr-+}HcPebk4NR%{LZ1JYQ;`hyK#(4ihSv1rUY&+(vvzg_oxi0ekZTl25W9mS6w{9oWe(C*d zm39wD0oSAcsxH_k1qBR0Kfjy5e}cNZyP1VW-@OYw?|R8iPbZ0Dy2VUk(kudc!{X^( zxmWJWsaelAL<%8~sUN>^B*OiZFv zt~D9Kdkqh7IrSY5;6D{IJ|Jd|OIPW4R6dFwnC7l9N0al$9?lfkxt%VL_9DIoYJ=N) zp56D+WiW;uS**orL+-V8|49tL91TzmaS+x$|_%&FP%o0@ZQ28W_v#j`ePXrK{^ z=ChE!z&=0Eh(VK`-tT0Fz8@c>_`JA0oAdQMyk1r4wp?wySX&-4B;L&TY{zG(%qOb7ks% zr*3sVkgKRTb-?oK%qJ7m(>*S0oDU`(VNg#-^)i>s8bT%w-PJJJ#x@-6JmtP<0n9kp zPyz1gjS2R*B_#X5UD=<^Nq7lk^Mdij`E{zohn1Ivs*Zt2B=~-C=f-VsaM(8>F54;=L?`j z=~#>eLWa-5z_obf^`yqwqQQaKo3Nf3JOqG*!HRGZY4WFXV-29P|l94g+8QUpL)Bo|c#@Gi2IUt_g^@r@GIkxEb z4-W22jp$EaGgL`M;Ax5A-nJfB%3te_bO-jOm-8Cd+r?@z(XNxt|2a|c`<77G3n}9azYrO^TTAO>Z>+wAFa)>I!&i{VRT@7g1&gVOw zRZ=~?kGrB%|7y4w_3)S&)4^y`pbtFWoyreH5-xT63Ic@{14B(RlV&W?Z>G!CnSj=T z9OU_zd@03K7QQ8LYs&##o)v5ln3&sw1Ozg@&pXn9H}4v%cpv-U*;4$|l_oky#>g0Q z-hGKeUf1uV!?AJZlE9$hQ#VG&wmrm(%5xzA$Vs2@+r@CU+-G2nYze z?TdTZqR{T;hSqdLuTLqA1Nh29;{V(oP#ay(cPj(Rtp9i~@KEfRD*YV5iE)4jF_dxy zQbq}%!oAP>|2h!w(-|1&b_JY{6La_*Afc!2xtW4Qs25llULJPh)QS{HS#_HOEHjDi z<6QwuJ#iC6L?_w>YhX}c&XNuS*up+ ze!hk5*2?JSJmSy|9DEYCJjda$g(3g)`}DVzk8~vh1`V5<9ZMat;2Fg`_%Hp229+ zjl>l%k#W6+6*y#WN{X>~yN1h28w0Ga?6%bSh1$NgxE|a0RqdtWKNC(*!wSB@pB*m; z>Xhrth~Inu=zZA_JB&h&nY5_M49e6)nmJrG5z%r<7V6?#LfPDx(cejs9$>KGbimGxu^b9`FtISrl} zGTK2K6a6-F@m6-9g7@lgTMnO~Bh5AT9;8b*iiE)Gk>I>uv@bcqd5JvlNHD~1`;U0s zcKSW-OcP{3rM-^VFnXKo@Ct?3;34O*z}+bmlw9>SekLNLD!7rKVmB|4To5seXum&^ z!>8qu8L_HdVkdUZlfXP)N6;#89JD6>2#CJ5BVpZlFzG(yLTaDFgq5Rq*pe{_Adh9<)EkC z4-+XIG<{(2AZFS6Ee9~e< zg=H~|C9oQ-x*F4BOFFiMB8N0u*R|4vXkepNH}+lD@SQOh#k&$u^s-7w zL^aKKF-&c4^v#QWX9SPegBr=pwr!VUQQFD%12~O-A{#7=Jq&b#bDDij-JQ@EKAis$T{Ef#Zx_q@7_JQC3te&9;bBx2Em&YjQ@MI{ z%69R4rx7wB8l=9sYshoyp@S^{Rer#kA`ts!85SYn+odI&$|*oQGqAc-;_8 zq*{sJ%jE5@m;8Ye?9-H3VpH1VP4<>jFOlxNopK~19&!KGPbAeRL%FkGuyF~AM9G%U zxG~t!Pe{!nb2d60YH#6B{U#m~em}dlIuYGtP}gU=?Y2^~bP8uh@e+uOe;2RvlMiO@ z%b?4O`xVZP8S_%Gxh!f@qxDU}@h$r`)7UT`Nk?1n=~g-uTK6;NW?V#i)`Yl<_!Z2< z9_hnV$J+T&4EAllUXAM*1!qP<<&g`S1GXs|ImyJ}s<);%Pc-@OGI~MrkE&?N>Iy)#f4uY=A6~+^RMM7Vi*^!XmXWo53_~khE;)L+7^0?)= zM#f_sDkq}jg;DTuyQ<DRLpeQ@np8uQZoJF}zTyOB3u8Jzue zSnG*Hle?KW$YH8T(LhNtJoRqU!9QsF!KhdwC^Q)bk~Glv($yh|9eE|R$p`aI0~4p} zr6TUC{IG~Y;xe2M=O7EpB#D&4NG7=q4xg#dG22#H-nXK&G@DkKmRqS;fIzmDuinJefX&GARK|jcJ2&ReFNsJw8SPyoK0u&=%_C<&kyObuq8L z*#LqdGCE_mF656oIGx0{(8K&4osMGtmxIeC4trzF1dmT}(Ov#D=J#jcA8tGamb3U~ zmRZHD$Qq^Zchk&zb>?YuXz0yg^z8QqgkH3i0B5%DdA zGLzP2Z4S(OASDid>-^q05Ir!fq-ntKv|KF@3l6rrsLryqtiyt- z(R&u$-S*Ef4d?q7lU{y@>o8|eDhgJutBNjSB^R;cP9*4lh)Bp@%aXVBB_A9tIDtYD z#6wYoZ^z?m-KXtp4LQgTZ^-(5@6#F9^dve)6eLG9zwpU$c>To|%s85Vp%q*yWm#0P zHDZ-K&znpv{%}#<`7uU1$O>u$e!;zgs& zt-+cJ7@byV2WCyzvFK5ND+y20csJB5m(?5{pRTqb-z``^D! zy%MT*BTy5Ke`H&wU92X8WK$j7?Jf?I`jRMxH*Pu(n2`D z_f|OJRh95-NL&1UHmnOH<2Xf!Ertq)Mm={>)p?j>Bho+bDnK;${Ju&^V-l9le8m_O z)c&qSqA)o3%&ubYi4Qh|DD)uq_Rnu>n~K9CZwYL)6f}EmOzoH;x1E70*Ys$YM@T`! zW?9zv@&*gxzHq-YMKe8*Pl-0kdi-rSJZ-)ye(uCE$fDqve&TR)!eQLsLo{XGu~wTV5%9fozO))O~c+95|+;uYna=HV(*OQ&xwkIaXn1< zzwK>3({Q$@!z+yOL=$3_VSyT}N%3zOh25EPk$E`;ii#&Uscqyx5GA@<7zCa4c0-Tk)FQ83|$i@$kC{n z@N(;T)A0U=fh#LdVq;(ma8&c2w#I=2adeitsXF)Q`n5J`n3UWT-s^S8n zQo_;_6+s22#SXuv8RNKdICfBrV$r0_F-O{^-By>@JV&y|?QYVbXYI>iG`!Gsg7= zU&_w^JrNe(!3wxTIwI6s!|0Qiqu@kMF~jQmgMTAnQve zSb|vggrAm6`Ft`j4zI(brtonnN3&(5ongbO6TUK-%4d~ZU-jP3>?`ciN!DA`i({ZK zycqVOsH&=Vv*dl;58^QfVGCdJiCeK{r`FB-HN@Hu2{<;3nxFE#A941ZK=*pjYEA9U z`7&s(LHDcBGg+F=WG;%cy6FV`EGSsA){!zjF!J-67S6sUE4GNSto14JYp?sR!Zck- zK_jo)?MGU{W54c%@|TDxN@wEyI?ZdVZ_%Uk5j8cjiE*{`Hf}**WGoL|h(!3aB6%B~ zg}gCacWF|E^TKQE0;yZ9A8s}}Y`f=u$-h>?NUt@7CU+Q-2d`{kou}LWwK?nKsn7Qp z3Riu}FQ(Xp1B42>UGax0=bsmv5+o)&u5-=y9awTFfE_p{P1PDODYywvD$OA~<(^A@ z5~mZ`^k@oZe`ZynG=hWn*hzKAv#+c=bj2HciNm%u>*6ElK}{(=u_&_IA-hcCn#crN zaJ*LulCt;X5}7yLSFqQ+%=YPR6bw`36^g^wq5+k!enLvwCE%6Nlh36qYc~#+J)am} zXS7}s7TpQEY1D2NV$-*owb&NB9GgkxDwym=p~o-l{MBgY+j%|mVn?Ybl2+qoZ(@x< zm*?M$d;Qj~eMk0u7BQ`DJt zcXgW-terbyx{WH0-1vG|KB*PCVR^mME0=HDn|i`8dGe-fz(a`%2|aa_B7(2j7OGIp z_Nz4U6JGGB-cZTf5{cu8_0qjXp)#k_!@{noHm|1nIGW2lueR>I(-A_|1Vke=Ds|=b z^(j3)J>f03Q{JKk1tj8Pvw^pMszBTITzK*}yZ4n#aJD{dew2|(N=Oj5vtxxoI?%6> z^wFhp5X2u;BVuCSv#`VfuwP9@MeNHLg1Nal(bOnCv~O=w;8lS*B$GzPTS39BZ{NPb zI7pgvW|9%bae~EJXlX-lZd`cm*A*NcFHOPhZ(skbJT_qa?NnC1Whzz<4vwFNg?cwyLqG^|7&x(_0wbuW`)Y4=1D=RDiW`$g#jO1hj9-BomAkOjPXPjU77Ui3Rjz=sh=a~{!5b%W^#Z&v& p8}~<&f36poNHsNCiW%M87gd#1R#lTRr+)>!

- RESIST_DNS_SURVEILLANCE_ + RESIST_DNS_SURVEILLANCE_

Block ads and trackers at the DNS level using modDNS, an open-source @@ -111,12 +112,20 @@ export default function Landing({ isAuthenticated = false }: LandingProps) {

[ MODDNS_DASHBOARD ]
- modDNS Dashboard — Custom Rules Interface + + {/* Mobile-tuned screenshot at ≤768 px (smaller, narrower aspect). + Desktop falls back to the wide screenshot below. */} + + modDNS Dashboard — Custom Rules Interface +
@@ -328,7 +337,7 @@ export default function Landing({ isAuthenticated = false }: LandingProps) { .

-
+
SVC_TOPOLOGY
diff --git a/app/src/pages/landing/landing.css b/app/src/pages/landing/landing.css index 0bf82deb..1830e9fc 100644 --- a/app/src/pages/landing/landing.css +++ b/app/src/pages/landing/landing.css @@ -353,14 +353,20 @@ text-shadow: 0 0 3px var(--phosphor-glow); } +/* Two-column grid with an explicit 3-row track and column-flow. Items 1-3 + land in the left column, items 4-5 in the right. Deterministic — does not + rely on the browser's column-balancing algorithm, which was overriding the + earlier `break-before: column` hint. */ .moddns-landing .constraints-list { - column-count: 2; - column-gap: 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: repeat(3, auto); + grid-auto-flow: column; + gap: 0.5rem 2rem; } .moddns-landing .constraints-list li { color: #aaa; - margin-bottom: 0.5rem; } .moddns-landing .constraints-list li::before { @@ -399,6 +405,12 @@ height: 100%; } +/* Desktop offset: pushes the diagram down so its rows align with the + text-column body copy instead of the section title. Removed on mobile (see + ≤768px override) where the diagram stacks below the text and any extra top + margin only widens the gap. */ +.moddns-landing .suite-section .vector-diagram { margin-top: 50px; } + .moddns-landing .node { fill: var(--bg); stroke: var(--phosphor); @@ -489,6 +501,68 @@ } @media (max-width: 768px) { + .moddns-landing .container { + padding: 1rem; + gap: 1.25rem; + } + + /* Stack the two sys-nav rows; status/clock falls below the link row, right-justified. */ + .moddns-landing .sys-nav { + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + } + .moddns-landing .sys-nav > div { + display: flex; + flex-wrap: wrap; + column-gap: 0.75rem; + row-gap: 0.25rem; + } + /* STATUS: ONLINE + SYS.TIME are decorative chrome — hide on mobile to give + the link grid the full width and keep the nav focused on actions. */ + .moddns-landing .sys-nav > div:last-child { display: none; } + .moddns-landing .sys-nav span { margin-right: 0; } + + /* Top nav link group becomes a 2×2 grid of bordered tap targets: + row 1: [01 LOGIN] [02 START], row 2: [03 PRIVACY] [04 FAQ]. Each link + gets a real frame (matching the dashed sys-nav rule colour) plus 44px + minimum height for comfortable tapping. */ + .moddns-landing .sys-nav > div:first-child { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + .moddns-landing .sys-nav > div:first-child span { + margin: 0; + display: block; + } + .moddns-landing .sys-nav > div:first-child a { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 0.5rem; + border: 1px solid var(--phosphor-dim); + min-height: 44px; + text-align: center; + } + + /* Hero: smaller padding and fluid h1 that allows the underscore-separated + word to wrap (which is on-brand for the snake_case identifier feel). */ + .moddns-landing .hero { + padding: 2rem 1rem 1.25rem; + } + .moddns-landing .hero h1 { + font-size: clamp(1.75rem, 8vw, 4rem); + margin-top: 1.25rem; + margin-bottom: 1.5rem; + overflow-wrap: anywhere; + } + .moddns-landing .hero p { + font-size: 1rem; + margin-bottom: 2rem; + } + + /* All section grids collapse to single column. */ .moddns-landing .features-grid, .moddns-landing .specs-grid, .moddns-landing .trust-grid, @@ -496,10 +570,97 @@ .moddns-landing .pricing-grid { grid-template-columns: 1fr; } - .moddns-landing .constraints-list { - column-count: 1; + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-auto-flow: row; } - .moddns-landing .hero h1 { font-size: 3rem; } + /* Vector diagram: hug content rather than holding a fixed 300px slot. */ + .moddns-landing .vector-diagram { + aspect-ratio: 450 / 308; + height: auto; + } + /* Tight stacking in the Unlinked Access section: drop the desktop 50px + diagram offset and shrink the section gap from 3rem to 1.5rem so the + text and diagram sit close together when stacked in a single column. */ + .moddns-landing .suite-section { gap: 1.5rem; } + .moddns-landing .suite-section .vector-diagram { margin-top: 0; } + + /* Verifiable Privacy: each trust-box's bottom link ([ MEET THE TEAM ], + [ REVIEW CODE ], [ READ THE REPORT ], [ REVIEW OUR POLICIES ]) becomes a + full-width bordered tap target so the section reads as four equally- + weighted CTAs instead of inline footnote links. */ + .moddns-landing .trust-box a { + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid var(--phosphor-dim); + min-height: 44px; + text-align: center; + font-size: 14px; + } +} + +@media (max-width: 480px) { + .moddns-landing .container { padding: 0.75rem; } + + .moddns-landing .hero { padding: 1.5rem 0.75rem 1rem; } + /* Bigger headline because it's the page's main statement, and the two + intentional halves (RESIST_DNS_ / SURVEILLANCE_) each fit on their own + line at this width. */ + .moddns-landing .hero h1 { + font-size: clamp(3rem, 16vw, 4.5rem); + line-height: 1.1; + } + .moddns-landing .hero h1 .hero-h1-part { display: block; } + + /* Hero CTAs: stack vertically and span the full width of the hero so each + button is a generous tap target on phones. */ + .moddns-landing .hero .btn-group { + flex-direction: column; + gap: 0.5rem; + } + .moddns-landing .hero .btn-group .btn { + width: 100%; + text-align: center; + } + + /* Spec rows: label on top, value indented below — preserves the data-row + terminal feel without the right-side overflow on long values like + "Win/Mac/Linux/iOS/Android". */ + .moddns-landing .spec-col ul li { + flex-direction: column; + gap: 0.15rem; + align-items: flex-start; + padding-bottom: 0.4rem; + } + .moddns-landing .spec-col ul li span:last-child { + padding-left: 0.75rem; + border-left: 1px dotted var(--phosphor-dim); + } + + /* Section title decorations are too noisy at this width; keep the bare label. */ + .moddns-landing .section-title::before, + .moddns-landing .section-title::after { content: none; } + + /* Plan-box header: allow the [ START ] button to wrap below the price block. */ + .moddns-landing .pricing-grid .plan-box .plan-header { + flex-wrap: wrap; + gap: 0.75rem; + } +} + +/* Touch-device tap targets — meets WCAG AA 44x44 minimum. */ +@media (hover: none) and (pointer: coarse) { + .moddns-landing a.btn, + .moddns-landing .btn { + padding: 0.5rem 1rem; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } } From 82d8d2cf2b2430e70a44f28d7107f673125f29de Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 6 May 2026 11:58:35 +0200 Subject: [PATCH 54/54] chore(app): Improve text in input field Signed-off-by: Maciek --- app/src/pages/custom_rules/RuleComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/pages/custom_rules/RuleComposer.tsx b/app/src/pages/custom_rules/RuleComposer.tsx index 0a01d98f..7f9067c4 100644 --- a/app/src/pages/custom_rules/RuleComposer.tsx +++ b/app/src/pages/custom_rules/RuleComposer.tsx @@ -320,7 +320,7 @@ export function RuleComposer({ Input: CustomInput, }} classNamePrefix="rule-composer" - placeholder="Paste or type domains, IPs, or ASNs" + placeholder="Domain, IP, or ASN" value={tokens} inputValue={inputValue} onChange={handleChange}