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 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 . 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/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/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/pasession.go b/api/api/pasession.go new file mode 100644 index 00000000..04e6a0e8 --- /dev/null +++ b/api/api/pasession.go @@ -0,0 +1,108 @@ +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. 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 +// @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."}) + } + + if newID, err := s.Service.RotatePASessionID(c.Context(), req.SessionID); err == nil { + setPASessionCookie(c, newID) + return c.SendStatus(fiber.StatusOK) + } + + // 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.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)) +} 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..71832f83 100644 --- a/api/api/requests/subscription.go +++ b/api/api/requests/subscription.go @@ -5,11 +5,3 @@ import "github.com/ivpn/dns/api/model" 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..b6e7087f 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 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 (no auth, rate limited only) + v1.Put("/pasession/rotate", middleware.NewLimit(10, 1*time.Minute), s.rotatePASession()) accounts := v1.Group("/accounts") profiles := v1.Group("/profiles") @@ -142,9 +145,11 @@ 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()) + 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 6298cc37..e3de9af1 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 @@ -75,3 +30,30 @@ 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. Requires pa_session cookie (set by prior PASession rotation). +// @Tags Subscription +// @Produce json +// @Security ApiKeyAuth +// @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) + + 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, 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/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..c8320330 100644 --- a/api/api/webauthn.go +++ b/api/api/webauthn.go @@ -58,12 +58,13 @@ 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()) } - 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") } @@ -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/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/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..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. @@ -281,42 +296,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) hashCmd := c.client.Del(ctx, customRuleHash) diff --git a/api/config/config.go b/api/config/config.go index 869c1528..d5ac1d1e 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -39,16 +39,18 @@ 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 - SubscriptionCacheExpiration time.Duration - 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 + + // Startup migrations (removable after all environments are migrated) + MigrateSubscriptionUUIDSubtype bool } // SentryConfig represents the Sentry configuration @@ -82,6 +84,9 @@ type APIConfig struct { SignupWebhookURL string SignupWebhookPSK string DisableRateLimit bool + PreauthURL string + PreauthPSK string + PreauthTTL time.Duration } type EmailSenderConfig struct { @@ -114,8 +119,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 +178,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 +204,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"), @@ -237,16 +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, - SubscriptionCacheExpiration: subCacheExp, - 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/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" } + } +] 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/mongodb/subscription.go b/api/db/mongodb/subscription.go index 123397ae..1cc10cc3 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" @@ -48,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} @@ -86,3 +74,120 @@ func (r *SubscriptionRepository) Create(ctx context.Context, sub model.Subscript } return nil } + +// 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 { + 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 { + log.Error().Err(err).Msg("Failed to reset notified flag for active subscriptions") + } + return err +} + +// 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) { + now := time.Now() + filter := bson.M{ + "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 { + 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 +} + +// 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, + "$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 { + 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 +// 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 { + 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 { + log.Error().Err(err).Msg("Failed to reset notified_pending_delete flag for active subscriptions") + } + return err +} 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/db/repository/subscription.go b/api/db/repository/subscription.go index 97954aa7..7668c8a7 100644 --- a/api/db/repository/subscription.go +++ b/api/db/repository/subscription.go @@ -3,13 +3,19 @@ package repository import ( "context" + "github.com/google/uuid" "github.com/ivpn/dns/api/model" ) // 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 + 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/docs/docs.go b/api/docs/docs.go index 92206c61..73a3a15a 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,35 +1849,21 @@ const docTemplate = `{ } } }, - "/api/v1/subscription/add": { - "post": { + "/api/v1/sub/update": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Add subscription and cache its presence", - "consumes": [ - "application/json" - ], + "description": "Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation).", "produces": [ "application/json" ], "tags": [ "Subscription" ], - "summary": "Add subscription", - "parameters": [ - { - "description": "Subscription request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionReq" - } - } - ], + "summary": "Update subscription via PASession", "responses": { "200": { "description": "OK", @@ -1803,8 +1877,8 @@ const docTemplate = `{ "$ref": "#/definitions/api.ErrResponse" } }, - "500": { - "description": "Internal Server Error", + "401": { + "description": "Unauthorized", "schema": { "$ref": "#/definitions/api.ErrResponse" } @@ -2540,7 +2614,10 @@ const docTemplate = `{ }, "intensity": { "description": "basic, comprehensive, restrictive", - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "kind": { "description": "general, category, security", @@ -2895,20 +2972,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 +3539,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 +3583,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" } } @@ -3620,6 +3729,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 68f76bdc..a8832127 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,35 +1841,21 @@ } } }, - "/api/v1/subscription/add": { - "post": { + "/api/v1/sub/update": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Add subscription and cache its presence", - "consumes": [ - "application/json" - ], + "description": "Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation).", "produces": [ "application/json" ], "tags": [ "Subscription" ], - "summary": "Add subscription", - "parameters": [ - { - "description": "Subscription request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/requests.SubscriptionReq" - } - } - ], + "summary": "Update subscription via PASession", "responses": { "200": { "description": "OK", @@ -1795,8 +1869,8 @@ "$ref": "#/definitions/api.ErrResponse" } }, - "500": { - "description": "Internal Server Error", + "401": { + "description": "Unauthorized", "schema": { "$ref": "#/definitions/api.ErrResponse" } @@ -2532,7 +2606,10 @@ }, "intensity": { "description": "basic, comprehensive, restrictive", - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "kind": { "description": "general, category, security", @@ -2887,20 +2964,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 +3531,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 +3575,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" } } @@ -3612,6 +3721,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 3cf259c0..7cb94149 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: @@ -940,6 +963,10 @@ definitions: items: type: integer type: array + domains: + items: + type: string + type: array id: type: string logo_key: @@ -1426,6 +1453,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,18 +2189,10 @@ 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' + /api/v1/sub/update: + put: + description: Resync subscription using a pre-auth session. Requires pa_session + cookie (set by prior PASession rotation). produces: - application/json responses: @@ -2129,13 +2204,13 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.ErrResponse' - "500": - description: Internal Server Error + "401": + description: Unauthorized schema: $ref: '#/definitions/api.ErrResponse' security: - ApiKeyAuth: [] - summary: Add subscription + summary: Update subscription via PASession tags: - Subscription /api/v1/verify/email/otp/confirm: diff --git a/api/go.mod b/api/go.mod index 9f66adf0..b9e232d3 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,10 +4,11 @@ go 1.25.8 require ( github.com/AfterShip/email-verifier v1.4.0 - github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de + 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 + 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 +65,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 +87,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 @@ -93,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 @@ -119,7 +123,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..f992c153 100644 --- a/api/go.sum +++ b/api/go.sum @@ -10,10 +10,10 @@ 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/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 +70,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 +130,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 +161,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 +204,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 +236,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= @@ -268,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/client/http.go b/api/internal/client/http.go index e14f3593..fc472c98 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() @@ -41,7 +42,35 @@ 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 } + +// 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/internal/cron/cron.go b/api/internal/cron/cron.go new file mode 100644 index 00000000..a983c1ad --- /dev/null +++ b/api/internal/cron/cron.go @@ -0,0 +1,45 @@ +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. +// +// 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 + } + + _, 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 + } + + _, 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 new file mode 100644 index 00000000..e041ed38 --- /dev/null +++ b/api/internal/cron/jobs.go @@ -0,0 +1,145 @@ +package cron + +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/ivpn/dns/api/model" + "github.com/rs/zerolog/log" +) + +// NotifyExpiringSubscriptions resets the notified flag for active subscriptions, +// 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() + + // 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 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(candidates) == 0 { + return + } + + 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 + } + + 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 + } + + notifiedIDs = append(notifiedIDs, sub.ID) + } + + 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") + } + } +} + +// 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/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/internal/email/content/content.go b/api/internal/email/content/content.go index 7057c540..637adcaa 100644 --- a/api/internal/email/content/content.go +++ b/api/internal/email/content/content.go @@ -29,6 +29,24 @@ 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

", + } +} + +// 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 ec97e1d8..07554c98 100644 --- a/api/internal/email/mailer.go +++ b/api/internal/email/mailer.go @@ -20,6 +20,8 @@ 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 + 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 e11ce709..aa9f027e 100644 --- a/api/internal/email/mailpit/mailpit.go +++ b/api/internal/email/mailpit/mailpit.go @@ -90,6 +90,30 @@ 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) +} + +// 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 22383589..5797b716 100644 --- a/api/internal/email/mailtrap/mailtrap.go +++ b/api/internal/email/mailtrap/mailtrap.go @@ -101,6 +101,32 @@ 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) +} + +// 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 f22fb839..5863ddb4 100644 --- a/api/internal/email/sendgrid/sendgrid.go +++ b/api/internal/email/sendgrid/sendgrid.go @@ -74,6 +74,18 @@ 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) +} + +// 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/internal/middleware/subscription.go b/api/internal/middleware/subscription.go new file mode 100644 index 00000000..2ea7bc79 --- /dev/null +++ b/api/internal/middleware/subscription.go @@ -0,0 +1,158 @@ +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"}, + // 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"}, + } + + 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 (GET passkeys moved to alwaysAllowed) + {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..8c375662 --- /dev/null +++ b/api/internal/middleware/subscription_test.go @@ -0,0 +1,110 @@ +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}, + {"GET", "/api/v1/webauthn/passkeys", 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 /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}, + {"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/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 6cbef5a0..5294e894 100644 --- a/api/main.go +++ b/api/main.go @@ -9,11 +9,14 @@ 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" + "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" @@ -27,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 @@ -91,11 +99,27 @@ func main() { log.Panic().Err(err).Msg("Failed to run migrations") } - // cache create, load data on startup - cache, err := cache.NewCache(appConfig.Cache, cache.CacheTypeRedis) + 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") + } + } + + // 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 { @@ -143,6 +167,9 @@ func main() { log.Panic().Err(err).Msg("Failed to create API server") } server.RegisterRoutes() + + 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") } 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.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/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/db.go b/api/mocks/db.go index 7dc89d1c..af41a8d1 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,130 @@ 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 +} + +// 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) @@ -2270,55 +2395,101 @@ 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) +// 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 GetSubscriptionById") + panic("no return value specified for MarkNotified") } - 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) + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) + 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 +} + +// 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") } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// Db_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type Db_GetSubscriptionById_Call struct { +// 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 } -// GetSubscriptionById is a helper method to define mock.On call +// MarkPendingDeleteNotified 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)} +// - 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_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *Db_GetSubscriptionById_Call { +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 string + var arg1 []uuid.UUID if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].([]uuid.UUID) } run( arg0, @@ -2328,12 +2499,12 @@ func (_c *Db_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscri return _c } -func (_c *Db_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *Db_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) +func (_c *Db_MarkPendingDeleteNotified_Call) Return(err error) *Db_MarkPendingDeleteNotified_Call { + _c.Call.Return(err) return _c } -func (_c *Db_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *Db_GetSubscriptionById_Call { +func (_c *Db_MarkPendingDeleteNotified_Call) RunAndReturn(run func(ctx context.Context, subscriptionIDs []uuid.UUID) error) *Db_MarkPendingDeleteNotified_Call { _c.Call.Return(run) return _c } @@ -2508,6 +2679,108 @@ 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 +} + +// 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) @@ -2572,16 +2845,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) } @@ -2599,11 +2872,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 { @@ -2625,12 +2899,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 @@ -2641,7 +2920,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/mailer_email.go b/api/mocks/mailer_email.go index e5e81417..5a91b817 100644 --- a/api/mocks/mailer_email.go +++ b/api/mocks/mailer_email.go @@ -163,6 +163,120 @@ 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) + + 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/passkey_servicer.go b/api/mocks/passkey_servicer.go index c94e4ac9..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 } @@ -435,16 +441,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 +466,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 +485,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 +504,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..566a756d 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 } @@ -263,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") @@ -273,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) } @@ -304,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 { @@ -318,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 @@ -331,22 +331,22 @@ 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 } // 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 +362,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 +381,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 +400,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 +706,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 +746,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 +1871,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 +1896,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 +1915,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 +1934,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 } @@ -2918,8 +2924,8 @@ func (_c *Servicer_GetSubscription_Call) RunAndReturn(run func(ctx context.Conte } // 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 +2933,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 +2961,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 +2984,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 +3004,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 +3072,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 +3111,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 +3185,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 } @@ -3261,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) } @@ -3288,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 { @@ -3314,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 @@ -3330,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 } @@ -3894,6 +3898,137 @@ 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, 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) error); ok { + r0 = returnFunc(ctx, sub, 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 +// - sessionID string +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, 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) + } + run( + arg0, + arg1, + arg2, + ) + }) + 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, 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) + + 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/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/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 8cff14d9..3cc5a25c 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,130 @@ 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 +} + +// 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) @@ -163,55 +288,101 @@ 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) +// 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 GetSubscriptionById") + panic("no return value specified for MarkNotified") } - 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) + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.Subscription) + 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 +} + +// 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") } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, subscriptionId) + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []uuid.UUID) error); ok { + r0 = returnFunc(ctx, subscriptionIDs) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// SubscriptionRepository_GetSubscriptionById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSubscriptionById' -type SubscriptionRepository_GetSubscriptionById_Call struct { +// 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 } -// GetSubscriptionById is a helper method to define mock.On call +// MarkPendingDeleteNotified 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)} +// - 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_GetSubscriptionById_Call) Run(run func(ctx context.Context, subscriptionId string)) *SubscriptionRepository_GetSubscriptionById_Call { +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 string + var arg1 []uuid.UUID if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].([]uuid.UUID) } run( arg0, @@ -221,12 +392,114 @@ func (_c *SubscriptionRepository_GetSubscriptionById_Call) Run(run func(ctx cont return _c } -func (_c *SubscriptionRepository_GetSubscriptionById_Call) Return(subscription *model.Subscription, err error) *SubscriptionRepository_GetSubscriptionById_Call { - _c.Call.Return(subscription, err) +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) + + 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 +} + +// 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_GetSubscriptionById_Call) RunAndReturn(run func(ctx context.Context, subscriptionId string) (*model.Subscription, error)) *SubscriptionRepository_GetSubscriptionById_Call { +func (_c *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call) RunAndReturn(run func(ctx context.Context) error) *SubscriptionRepository_ResetPendingDeleteNotifiedForActive_Call { _c.Call.Return(run) return _c } diff --git a/api/mocks/subscription_servicer.go b/api/mocks/subscription_servicer.go index eb118b5e..67403c41 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,72 @@ func (_c *SubscriptionServicer_GetSubscription_Call) RunAndReturn(run func(ctx c 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 +365,134 @@ func (_c *SubscriptionServicer_UpdateSubscription_Call) RunAndReturn(run func(ct _c.Call.Return(run) return _c } + +// UpdateSubscriptionFromPASession provides a mock function for the type SubscriptionServicer +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) error); ok { + r0 = returnFunc(ctx, sub, 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 +// - sessionID string +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, 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) + } + run( + arg0, + arg1, + arg2, + ) + }) + 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, 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) + + 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/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/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/model/subscription.go b/api/model/subscription.go index 9da28e08..20554d6d 100644 --- a/api/model/subscription.go +++ b/api/model/subscription.go @@ -1,31 +1,102 @@ 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"` - Limits SubscriptionLimits `json:"-" bson:"limits"` + 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"` + + // Computed fields (not persisted) + Status SubscriptionStatus `json:"status" bson:"-"` + Outage bool `json:"outage" bson:"-"` +} + +func (s *Subscription) Active() bool { + return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) && !s.IsOutage() +} + +func (s *Subscription) GracePeriod() bool { + return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3) +} + +func (s *Subscription) LimitedAccess() bool { + return s.GracePeriodDays(14) || (s.OutageGracePeriodDays(14) && s.IsOutage()) +} + +func (s *Subscription) PendingDelete() bool { + 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 +} + +func (s *Subscription) ActiveStatus() bool { + return s.Active() || s.GracePeriod() +} + +func (s *Subscription) IsOutage() bool { + if s.UpdatedAt.IsZero() { + return false + } + + return s.UpdatedAt.Add(time.Duration(48) * time.Hour).Before(time.Now()) +} + +func (s *Subscription) GracePeriodDays(days int) bool { + return s.ActiveUntil.AddDate(0, 0, days).After(time.Now()) && s.ActiveUntil.Before(time.Now()) +} + +func (s *Subscription) OutageGracePeriodDays(days int) bool { + return s.UpdatedAt.AddDate(0, 0, days).After(time.Now()) && s.UpdatedAt.Before(time.Now()) } -func (s *Subscription) IsActive() bool { - return s.ActiveUntil.After(time.Now()) +func (s *Subscription) GetStatus() SubscriptionStatus { + if s.Active() { + return StatusActive + } + if s.GracePeriod() { + return StatusGracePeriod + } + if s.PendingDelete() { + return StatusPendingDelete + } + if s.LimitedAccess() { + return StatusLimitedAccess + } + return StatusPendingDelete } type SubscriptionLimits struct { diff --git a/api/service/account/account.go b/api/service/account/account.go index dbbc225a..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 @@ -65,10 +79,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 +97,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 +120,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 +128,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 +140,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 +148,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,99 +164,72 @@ 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 { - 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") - } - err = a.sendWelcomeEmail(ctx, account, account.Email) - if err != nil { - return err - } - } - 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 +// 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 { + // 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 } - err = a.SubscriptionService.CreateSubscription(ctx, acc.ID.Hex(), subscriptionID, activeUntil) - if err != nil { - return nil, 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") } - if err = a.Cache.RemoveSubscription(ctx, subscriptionID); err != nil { - return nil, 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)") } - // eg, _ := errgroup.WithContext(ctx) - // eg.Go(func() (err error) { - // return a.Mailer.Verify(email) - // }) + a.dispatchWelcomeEmail(account, account.Email) - err = a.sendWelcomeEmail(ctx, acc, email) - if err != nil { - return nil, err - } + return nil +} - return acc, 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) } -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 +// 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 := eg.Wait(); err != nil { - log.Err(err).Msg(ErrFailedToCreateAccount.Error()) - return err - } - return nil + 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") + }() } -// 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 +245,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/account/reauth_test.go b/api/service/account/reauth_test.go index 75a0a36f..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{}) + 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 0cf2c028..1140a509 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.mockProfileRepo, 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.mockProfileRepo, 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,12 @@ 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) + + // 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) @@ -398,20 +302,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 }) } } @@ -2756,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)) } diff --git a/api/service/account/verify_test.go b/api/service/account/verify_test.go index e0bc7461..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{}) + 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, diff --git a/api/service/service.go b/api/service/service.go index b7bbce6c..c3d67116 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, 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{ @@ -85,8 +85,8 @@ 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 + 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) GetPasskeys(ctx context.Context, account *model.Account) ([]model.Credential, 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 @@ -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) + UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, sessionID string) error } // DeleteAccount deletes account with all connected data including sessions 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/subscription/service.go b/api/service/subscription/service.go index 1f6ea048..a207b14b 100644 --- a/api/service/subscription/service.go +++ b/api/service/subscription/service.go @@ -2,34 +2,51 @@ 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 + ProfileRepository repository.ProfileRepository 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, 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: 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 +56,14 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, accountId str return nil, err } + subscription.Status = subscription.GetStatus() + // Outage UI flag: true when never synced (zero UpdatedAt) OR genuinely stale (>48h) + subscription.Outage = subscription.UpdatedAt.IsZero() || subscription.IsOutage() + return subscription, nil } -// UpdateSubscription updates subscription data +// 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 +74,136 @@ 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) + sub := model.Subscription{ + ID: uuid.New(), + AccountID: accOID, + ActiveUntil: preauth.ActiveUntil, + IsActive: preauth.IsActive, + Tier: preauth.Tier, + TokenHash: preauth.TokenHash, + UpdatedAt: time.Now(), + Limits: model.SubscriptionLimits{ + MaxQueriesPerMonth: 0, + }, + } + + return s.SubscriptionRepository.Create(ctx, sub) +} + +// 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) +} + +// 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 { - return err + 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") } - subUUID, err := uuid.Parse(subscriptionId) + 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 err + log.Warn().Err(err).Str("session_id", sessionID).Msg("ValidateAndGetPreauth: PASession not found in cache") + return nil, ErrPASessionNotFound } - subscription := model.Subscription{ - ID: subUUID, - AccountID: accOID, - Type: model.Managed, - ActiveUntil: activeUntilTime, - Limits: model.SubscriptionLimits{ - MaxQueriesPerMonth: 0, // default - }, + + 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 + } + + 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). + Str("preauth_id", paSession.PreauthID). + Str("computed_hash", tokenHashStr). + Str("preauth_hash", preauth.TokenHash). + Msg("ValidateAndGetPreauth: token hash mismatch") + return nil, ErrTokenHashMismatch } - return s.SubscriptionRepository.Create(ctx, subscription) + return &preauth, nil } -// 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) +// UpdateSubscriptionFromPASession validates the PASession, updates subscription fields from preauth, and persists. +func (s *SubscriptionService) UpdateSubscriptionFromPASession(ctx context.Context, sub *model.Subscription, 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 + } + + // 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") + return err + } + + return 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) +// 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") + } + } } 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) - } -} diff --git a/api/service/webauthn.go b/api/service/webauthn.go index 6605470e..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) } @@ -49,7 +51,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 { @@ -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()); 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) } diff --git a/app/env/.env.production b/app/env/.env.production index 39fee2d1..c2924836 100644 --- a/app/env/.env.production +++ b/app/env/.env.production @@ -7,3 +7,7 @@ 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/ + +# 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 cbc39e4e..d69a2177 100644 --- a/app/env/.env.staging +++ b/app/env/.env.staging @@ -7,3 +7,7 @@ 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/ + +# 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 4b1a196e..c250e461 100644 --- a/app/env/.env.test +++ b/app/env/.env.test @@ -8,3 +8,7 @@ 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/ + +# 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/fonts/IBMPlexMono-Regular.woff2 b/app/public/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 00000000..0804aaff Binary files /dev/null and b/app/public/fonts/IBMPlexMono-Regular.woff2 differ diff --git a/app/public/fonts/VT323-Regular.woff2 b/app/public/fonts/VT323-Regular.woff2 new file mode 100644 index 00000000..fd760b5b Binary files /dev/null and b/app/public/fonts/VT323-Regular.woff2 differ diff --git a/app/public/og-image.png b/app/public/og-image.png new file mode 100644 index 00000000..09822e35 Binary files /dev/null and b/app/public/og-image.png differ diff --git a/app/src/App.tsx b/app/src/App.tsx index bc8da336..cc5d6172 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -27,6 +27,7 @@ const AccountPreferences = lazyWithRetry(() => 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" @@ -34,6 +35,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"; @@ -74,9 +76,9 @@ 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' || - // Dynamic signup route requires subid; plain /signup should not be treated as public and will fall through to 404 - p.startsWith('/signup/') || + p === '/signup' || p === '/tos' || p === '/privacy' || p === '/faq' || @@ -107,6 +109,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') => { @@ -161,14 +164,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") { @@ -278,6 +295,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(); @@ -372,6 +414,7 @@ function ProtectedLayout() { return ( <> + {navDesktop &&
} {isDesktop && connectionStatusVisible && ( @@ -460,11 +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; - const target = isAuthenticated && localAuthed ? '/home' : '/login'; - - return ; + const authed = isAuthenticated && localAuthed; + return }>; } function SetupWithLoader() { @@ -555,7 +606,7 @@ const router = createBrowserRouter([ element: , children: [ { path: "login", element: }, - { path: "signup/:subid", element: }> }, + { path: "signup", element: }> }, { path: "tos", element: }> }, { path: "privacy", element: }> }, { path: "faq", element: }> }, diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index 24d4e09d..a0fc11c9 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -86,22 +86,50 @@ 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 with [01 LOGIN]', 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(); + // 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 still redirects to /login', async ({ page }) => { + 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__/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/__tests__/unit/RootIndexRedirect.test.tsx b/app/src/__tests__/unit/RootIndexRedirect.test.tsx index 57d193c4..84320555 100644 --- a/app/src/__tests__/unit/RootIndexRedirect.test.tsx +++ b/app/src/__tests__/unit/RootIndexRedirect.test.tsx @@ -13,6 +13,14 @@ vi.mock('react-router-dom', async () => { }; }); +// 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: ({ isAuthenticated }: { isAuthenticated?: boolean }) => ( +
+ ), +})); + describe('RootIndexRedirect', () => { type AuthContextValue = React.ContextType; @@ -35,27 +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('navigates to /login when auth state is false', () => { + it('renders the landing page with isAuthenticated=false when auth state is false', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: false }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveAttribute('data-authed', 'false'); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('falls back to /login when local storage flag is missing', () => { + it('renders the landing page with isAuthenticated=false when local storage flag is missing', async () => { localStorage.removeItem(AUTH_KEY); renderWithAuth({ isAuthenticated: true }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + 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/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..933179a7 --- 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 + */ + 'tier'?: string; + /** + * + * @type {string} * @memberof ModelSubscription */ - 'type'?: ModelSubscriptionType; + '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; } /** * @@ -1769,6 +1808,12 @@ export interface ServicescatalogService { * @memberof ServicescatalogService */ 'asns'?: Array; + /** + * + * @type {Array} + * @memberof ServicescatalogService + */ + 'domains'?: Array; /** * * @type {string} @@ -3702,6 +3747,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 @@ -5300,16 +5526,13 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + * @summary Update subscription via PASession * @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`; + 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); let baseOptions; @@ -5317,18 +5540,15 @@ export const SubscriptionApiAxiosParamCreator = function (configuration?: Config baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + 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), @@ -5358,16 +5578,15 @@ export const SubscriptionApiFp = function(configuration?: Configuration) { 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 + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + * @summary Update subscription via PASession * @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); + 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.apiV1SubscriptionAddPost']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['SubscriptionApi.apiV1SubUpdatePut']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, } @@ -5390,14 +5609,13 @@ export const SubscriptionApiFactory = function (configuration?: Configuration, b return localVarFp.apiV1SubGet(options).then((request) => request(axios, basePath)); }, /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + * @summary Update subscription via PASession * @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)); + apiV1SubUpdatePut(options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { + return localVarFp.apiV1SubUpdatePut(options).then((request) => request(axios, basePath)); }, }; }; @@ -5421,15 +5639,14 @@ export class SubscriptionApi extends BaseAPI { } /** - * Add subscription and cache its presence - * @summary Add subscription - * @param {RequestsSubscriptionReq} body Subscription request + * Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). + * @summary Update subscription via PASession * @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)); + public apiV1SubUpdatePut(options?: RawAxiosRequestConfig) { + return SubscriptionApiFp(this.configuration).apiV1SubUpdatePut(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/assets/landing/dashboard-screenshot-mobile.png b/app/src/assets/landing/dashboard-screenshot-mobile.png new file mode 100644 index 00000000..a56bf8b3 Binary files /dev/null and b/app/src/assets/landing/dashboard-screenshot-mobile.png differ diff --git a/app/src/assets/landing/dashboard-screenshot.png b/app/src/assets/landing/dashboard-screenshot.png new file mode 100644 index 00000000..355f8c98 Binary files /dev/null and b/app/src/assets/landing/dashboard-screenshot.png differ diff --git a/app/src/components/AccountSubscription.tsx b/app/src/components/AccountSubscription.tsx new file mode 100644 index 00000000..68d80ef1 --- /dev/null +++ b/app/src/components/AccountSubscription.tsx @@ -0,0 +1,167 @@ +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"; +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/"; + +export default function AccountSubscription() { + const [sub, setSub] = useState(null); + const [error, setError] = useState(""); + const [syncing, setSyncing] = useState(false); + const [searchParams] = useSearchParams(); + const setSubscriptionStatus = useAppStore(s => s.setSubscriptionStatus); + + const sessionid = searchParams.get("sessionid") || ""; + + const fetchSubscription = async () => { + try { + const res = await api.Client.subscriptionApi.apiV1SubGet(); + setSub(res.data); + setSubscriptionStatus(res.data.status ?? null); + } catch { + setError("Failed to load subscription."); + } + }; + + const resync = async () => { + if (!sessionid) return; + + setSyncing(true); + setError(""); + try { + 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 { + setSyncing(false); + } + }; + + useEffect(() => { fetchSubscription(); }, []); + + useEffect(() => { + if (sessionid) { resync(); } + }, [sessionid]); // 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 }, + ]; + + // "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 */} + {hasAlerts && ( +
+ {isLimited && ( +
+ +
+

+ Limited Access Mode +

+

+ Your modDNS account is in limited access mode. To regain full 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 +

+
+ + {/* 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) => ( +
+ + {item.label} + +
+ {typeof item.value === "string" ? ( + + {item.value} + + ) : ( + item.value + )} +
+
+ ))} +
+
+
+ + ); +} 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/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/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/pages/account_preferences/Account.tsx b/app/src/pages/account_preferences/Account.tsx index f359adb2..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, @@ -24,6 +25,8 @@ 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"; +import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; interface PreferencesSectionProps { account: ModelAccount | null; @@ -51,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( @@ -220,17 +226,31 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element =
- {/* Account Info Card */} -
-
+ {isPendingDelete && ( +
+ +
+

+ Your account is pending deletion. +

+

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

+
+
+ )} + + {/* Alerts + Account Info + Subscription Cards */} +
+ +
- {/* Sections */} + {/* Sections - disabled during pending_delete */} +
{sections.map((section, sectionIndex) => ( @@ -344,6 +364,7 @@ const PreferencesSection = ({ account }: PreferencesSectionProps): JSX.Element = {/* Passkey Management Section */} +
{/* Delete Account Section */} 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. -
-
-
diff --git a/app/src/pages/auth/Signup.tsx b/app/src/pages/auth/Signup.tsx index a1fb6db4..9c696475 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 { useState, useEffect, useRef } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "@/App"; const isUUIDv4 = (id: string): boolean => { @@ -16,11 +16,17 @@ import NotFound from "@/pages/NotFound"; export default function Signup() { const navigate = useNavigate(); - const { subid } = useParams(); + const [searchParams] = useSearchParams(); const { isAuthenticated } = useAuth(); - const validSubId = (subid && isUUIDv4(subid)); + + const subid = searchParams.get("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); + const submittingRef = useRef(false); useEffect(() => { if (isAuthenticated) { @@ -28,12 +34,30 @@ 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 ; } 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); @@ -46,7 +70,6 @@ export default function Signup() { if (response.status === 201) { navigate("/login", { replace: true }); - // Use unified toast helper authToasts.accountCreatedSuccess(); } } catch (err) { @@ -65,18 +88,19 @@ 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); 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(); @@ -93,6 +117,7 @@ export default function Signup() { setError(errorMessage); authToasts.unexpectedError(errorMessage); + submittingRef.current = false; } finally { setLoading(false); } @@ -106,7 +131,7 @@ export default function Signup() {
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..3b6e73f8 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. */} + {/* 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 */}

@@ -327,7 +336,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/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} 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 && ( (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/landing/Landing.tsx b/app/src/pages/landing/Landing.tsx new file mode 100644 index 00000000..daf60137 --- /dev/null +++ b/app/src/pages/landing/Landing.tsx @@ -0,0 +1,439 @@ +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 dashboardScreenshotMobile from '@/assets/landing/dashboard-screenshot-mobile.png'; +import './landing.css'; + +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 ( +
+
+ + {/* NAV */} +
+
+ + {isAuthenticated ? ( + + [01 DASHBOARD] + + ) : ( + + [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 ]
+
+ + {/* Mobile-tuned screenshot at ≤768 px (smaller, narrower aspect). + Desktop falls back to the wide screenshot below. */} + + 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 ::{' '} + {isAuthenticated ? ( + DASHBOARD + ) : ( + 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..1830e9fc --- /dev/null +++ b/app/src/pages/landing/landing.css @@ -0,0 +1,666 @@ +/* + * 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); +} + +/* 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 { + 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; +} + +.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%; +} + +/* 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); + 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 .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, + .moddns-landing .suite-section, + .moddns-landing .pricing-grid { + grid-template-columns: 1fr; + } + .moddns-landing .constraints-list { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-auto-flow: row; + } + + /* 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; + } +} 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/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 */}
); 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", 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; +} 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= 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 } 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") +} 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 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..e37ded62 --- 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,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_subscription_add_post**](docs/SubscriptionApi.md#api_v1_subscription_add_post) | **POST** /api/v1/subscription/add | Add subscription +*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 @@ -163,7 +165,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 +195,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 index 4eaa57b1..52e6d1dd --- 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/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..73390e99 --- a/tests/moddns_client/docs/SubscriptionApi.md +++ b/tests/moddns_client/docs/SubscriptionApi.md @@ -5,7 +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_subscription_add_post**](SubscriptionApi.md#api_v1_subscription_add_post) | **POST** /api/v1/subscription/add | Add subscription +[**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** @@ -75,19 +75,18 @@ 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) +# **api_v1_sub_update_put** +> Dict[str, object] api_v1_sub_update_put() -Add subscription +Update subscription via PASession -Add subscription and cache its presence +Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). ### Example ```python import moddns -from moddns.models.requests_subscription_req import RequestsSubscriptionReq from moddns.rest import ApiException from pprint import pprint @@ -102,25 +101,21 @@ configuration = moddns.Configuration( 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") + # 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_subscription_add_post: %s\n" % e) + print("Exception when calling SubscriptionApi->api_v1_sub_update_put: %s\n" % e) ``` ### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **body** | [**RequestsSubscriptionReq**](RequestsSubscriptionReq.md)| Subscription request | +This endpoint does not need any parameter. ### Return type @@ -132,7 +127,7 @@ No authorization required ### HTTP request headers - - **Content-Type**: application/json + - **Content-Type**: Not defined - **Accept**: application/json ### HTTP response details @@ -141,7 +136,7 @@ No authorization required |-------------|-------------|------------------| **200** | OK | - | **400** | Bad Request | - | -**500** | Internal Server Error | - | +**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/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..3c9b8d6f --- a/tests/moddns_client/moddns/api/subscription_api.py +++ b/tests/moddns_client/moddns/api/subscription_api.py @@ -16,11 +16,8 @@ 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 @@ -295,9 +292,8 @@ def _api_v1_sub_get_serialize( @validate_call - def api_v1_subscription_add_post( + def api_v1_sub_update_put( self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -311,12 +307,10 @@ def api_v1_subscription_add_post( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> Dict[str, object]: - """Add subscription + """Update subscription via PASession - Add subscription and cache its presence + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). - :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 @@ -339,8 +333,7 @@ def api_v1_subscription_add_post( :return: Returns the result object. """ # noqa: E501 - _param = self._api_v1_subscription_add_post_serialize( - body=body, + _param = self._api_v1_sub_update_put_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -350,7 +343,7 @@ def api_v1_subscription_add_post( _response_types_map: Dict[str, Optional[str]] = { '200': "Dict[str, object]", '400': "ApiErrResponse", - '500': "ApiErrResponse", + '401': "ApiErrResponse", } response_data = self.api_client.call_api( *_param, @@ -364,9 +357,8 @@ def api_v1_subscription_add_post( @validate_call - def api_v1_subscription_add_post_with_http_info( + def api_v1_sub_update_put_with_http_info( self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -380,12 +372,10 @@ def api_v1_subscription_add_post_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[Dict[str, object]]: - """Add subscription + """Update subscription via PASession - Add subscription and cache its presence + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). - :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 @@ -408,8 +398,7 @@ def api_v1_subscription_add_post_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._api_v1_subscription_add_post_serialize( - body=body, + _param = self._api_v1_sub_update_put_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -419,7 +408,7 @@ def api_v1_subscription_add_post_with_http_info( _response_types_map: Dict[str, Optional[str]] = { '200': "Dict[str, object]", '400': "ApiErrResponse", - '500': "ApiErrResponse", + '401': "ApiErrResponse", } response_data = self.api_client.call_api( *_param, @@ -433,9 +422,8 @@ def api_v1_subscription_add_post_with_http_info( @validate_call - def api_v1_subscription_add_post_without_preload_content( + def api_v1_sub_update_put_without_preload_content( self, - body: Annotated[RequestsSubscriptionReq, Field(description="Subscription request")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -449,12 +437,10 @@ def api_v1_subscription_add_post_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Add subscription + """Update subscription via PASession - Add subscription and cache its presence + Resync subscription using a pre-auth session. Requires pa_session cookie (set by prior PASession rotation). - :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 @@ -477,8 +463,7 @@ def api_v1_subscription_add_post_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._api_v1_subscription_add_post_serialize( - body=body, + _param = self._api_v1_sub_update_put_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -488,7 +473,7 @@ def api_v1_subscription_add_post_without_preload_content( _response_types_map: Dict[str, Optional[str]] = { '200': "Dict[str, object]", '400': "ApiErrResponse", - '500': "ApiErrResponse", + '401': "ApiErrResponse", } response_data = self.api_client.call_api( *_param, @@ -497,9 +482,8 @@ def api_v1_subscription_add_post_without_preload_content( return response_data.response - def _api_v1_subscription_add_post_serialize( + def _api_v1_sub_update_put_serialize( self, - body, _request_auth, _content_type, _headers, @@ -525,8 +509,6 @@ def _api_v1_subscription_add_post_serialize( # 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` @@ -537,27 +519,14 @@ def _api_v1_subscription_add_post_serialize( ] ) - # 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', + method='PUT', + resource_path='/api/v1/sub/update', path_params=_path_params, query_params=_query_params, header_params=_header_params, 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 index 19b23094..0c86c17c --- 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/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 index 2bf71616..5c78e085 --- 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 old mode 100755 new mode 100644 index 8074f04a..b7e13b00 --- 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_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..588bbb72 --- a/tests/moddns_client/test/test_subscription_api.py +++ b/tests/moddns_client/test/test_subscription_api.py @@ -33,10 +33,10 @@ 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 + def test_api_v1_sub_update_put(self) -> None: + """Test case for api_v1_sub_update_put - Add subscription + Update subscription via PASession """ pass 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