From 93eeda99a363880e0db26aba565d792aca9c7bb8 Mon Sep 17 00:00:00 2001 From: Savolro Date: Tue, 15 Oct 2024 16:33:28 +0300 Subject: [PATCH] Add subscription data Signed-off-by: Savolro --- ci/test.sh | 7 +- cmd/daemon/events_linux.go | 63 ++++----- cmd/daemon/events_moose.go | 21 +-- cmd/daemon/main.go | 2 +- core/core.go | 32 +++++ core/models.go | 67 +++++++++- core/models_test.go | 47 +++++++ core/urls.go | 6 + daemon/events/events.go | 8 +- daemon/events/events_test.go | 52 +++---- daemon/jobs.go | 5 + daemon/rpc_account.go | 4 +- daemon/rpc_login.go | 6 +- events/moose/moose.go | 253 ++++++++++++++++++++++++++++++++++- events/moose/moose_test.go | 127 ++++++++++++++++++ events/subs/subs.go | 6 +- 16 files changed, 623 insertions(+), 83 deletions(-) create mode 100644 events/moose/moose_test.go diff --git a/ci/test.sh b/ci/test.sh index 75a01c8d5..e4aae4a49 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -12,6 +12,8 @@ excluded_packages=$excluded_packages"\|events\/moose" excluded_packages=$excluded_packages"\|pb\|magefiles" excluded_categories="root,link,firewall,route,file,integration" +tags="internal" + # In case 'full' was specified, do not exclude anything and run # everything if [ "${1:-""}" = "full" ]; then @@ -24,6 +26,7 @@ if [ "${1:-""}" = "full" ]; then excluded_packages="thisshouldneverexist" excluded_categories="root,link" + tags="internal,moose" fi # Execute tests in all the packages except the excluded ones @@ -37,7 +40,7 @@ mkdir -p "${WORKDIR}"/coverage/unit export LD_LIBRARY_PATH="${WORKDIR}/bin/deps/lib/amd64/latest" # shellcheck disable=SC2046 -go test -tags internal -v -race $(go list -buildvcs=false ./... | grep -v "${excluded_packages}") \ +go test -tags "$tags" -v -race $(go list -tags "$tags" -buildvcs=false ./... | grep -v "${excluded_packages}") \ -coverprofile "${WORKDIR}"/coverage.txt \ -exclude "${excluded_categories}" \ -args -test.gocoverdir="${WORKDIR}/coverage/unit" @@ -50,5 +53,5 @@ go tool cover -func="${WORKDIR}"/coverage.txt if [ "${1:-""}" = "full" ]; then # "gocover-cobertura" is used for test coverage visualization in the diff view. - gocover-cobertura < "$WORKDIR"/coverage.txt > coverage.xml + GOFLAGS=-tags="${tags}" gocover-cobertura < "$WORKDIR"/coverage.txt > coverage.xml fi diff --git a/cmd/daemon/events_linux.go b/cmd/daemon/events_linux.go index 27d32dc19..7b3e33df1 100644 --- a/cmd/daemon/events_linux.go +++ b/cmd/daemon/events_linux.go @@ -10,37 +10,40 @@ import ( type dummyAnalytics struct{} -func (*dummyAnalytics) Enable() error { return nil } -func (*dummyAnalytics) Disable() error { return nil } -func (*dummyAnalytics) NotifyKillswitch(bool) error { return nil } -func (*dummyAnalytics) NotifyAutoconnect(bool) error { return nil } -func (*dummyAnalytics) NotifyDNS(events.DataDNS) error { return nil } -func (*dummyAnalytics) NotifyThreatProtectionLite(bool) error { return nil } -func (*dummyAnalytics) NotifyProtocol(config.Protocol) error { return nil } -func (*dummyAnalytics) NotifyAllowlist(events.DataAllowlist) error { return nil } -func (*dummyAnalytics) NotifyTechnology(config.Technology) error { return nil } -func (*dummyAnalytics) NotifyObfuscate(bool) error { return nil } -func (*dummyAnalytics) NotifyFirewall(bool) error { return nil } -func (*dummyAnalytics) NotifyRouting(bool) error { return nil } -func (*dummyAnalytics) NotifyNotify(bool) error { return nil } -func (*dummyAnalytics) NotifyMeshnet(bool) error { return nil } -func (*dummyAnalytics) NotifyIpv6(bool) error { return nil } -func (*dummyAnalytics) NotifyDefaults(any) error { return nil } -func (*dummyAnalytics) NotifyConnect(events.DataConnect) error { return nil } -func (*dummyAnalytics) NotifyDisconnect(events.DataDisconnect) error { return nil } -func (*dummyAnalytics) NotifyLogin(events.DataAuthorization) error { return nil } -func (*dummyAnalytics) NotifyLogout(events.DataAuthorization) error { return nil } -func (*dummyAnalytics) NotifyMFA(bool) error { return nil } -func (*dummyAnalytics) NotifyAccountCheck(core.ServicesResponse) error { return nil } -func (*dummyAnalytics) NotifyRequestAPI(events.DataRequestAPI) error { return nil } -func (*dummyAnalytics) NotifyUiItemsClick(events.UiItemsAction) error { return nil } -func (*dummyAnalytics) NotifyHeartBeat(int) error { return nil } -func (*dummyAnalytics) NotifyDeviceLocation(core.Insights) error { return nil } -func (*dummyAnalytics) NotifyLANDiscovery(bool) error { return nil } -func (*dummyAnalytics) NotifyVirtualLocation(bool) error { return nil } -func (*dummyAnalytics) NotifyPostquantumVpn(bool) error { return nil } +func (*dummyAnalytics) Enable() error { return nil } +func (*dummyAnalytics) Disable() error { return nil } +func (*dummyAnalytics) NotifyKillswitch(bool) error { return nil } +func (*dummyAnalytics) NotifyAutoconnect(bool) error { return nil } +func (*dummyAnalytics) NotifyDNS(events.DataDNS) error { return nil } +func (*dummyAnalytics) NotifyThreatProtectionLite(bool) error { return nil } +func (*dummyAnalytics) NotifyProtocol(config.Protocol) error { return nil } +func (*dummyAnalytics) NotifyAllowlist(events.DataAllowlist) error { return nil } +func (*dummyAnalytics) NotifyTechnology(config.Technology) error { return nil } +func (*dummyAnalytics) NotifyObfuscate(bool) error { return nil } +func (*dummyAnalytics) NotifyFirewall(bool) error { return nil } +func (*dummyAnalytics) NotifyRouting(bool) error { return nil } +func (*dummyAnalytics) NotifyNotify(bool) error { return nil } +func (*dummyAnalytics) NotifyMeshnet(bool) error { return nil } +func (*dummyAnalytics) NotifyIpv6(bool) error { return nil } +func (*dummyAnalytics) NotifyDefaults(any) error { return nil } +func (*dummyAnalytics) NotifyConnect(events.DataConnect) error { return nil } +func (*dummyAnalytics) NotifyDisconnect(events.DataDisconnect) error { return nil } +func (*dummyAnalytics) NotifyLogin(events.DataAuthorization) error { return nil } +func (*dummyAnalytics) NotifyLogout(events.DataAuthorization) error { return nil } +func (*dummyAnalytics) NotifyMFA(bool) error { return nil } +func (*dummyAnalytics) NotifyAccountCheck(any) error { return nil } +func (*dummyAnalytics) NotifyRequestAPI(events.DataRequestAPI) error { return nil } +func (*dummyAnalytics) NotifyUiItemsClick(events.UiItemsAction) error { return nil } +func (*dummyAnalytics) NotifyHeartBeat(int) error { return nil } +func (*dummyAnalytics) NotifyDeviceLocation(core.Insights) error { return nil } +func (*dummyAnalytics) NotifyLANDiscovery(bool) error { return nil } +func (*dummyAnalytics) NotifyVirtualLocation(bool) error { return nil } +func (*dummyAnalytics) NotifyPostquantumVpn(bool) error { return nil } -func newAnalytics(eventsDbPath string, fs *config.FilesystemConfigManager, +func newAnalytics( + eventsDbPath string, + fs *config.FilesystemConfigManager, + subAPI core.SubscriptionAPI, version, env, id string) *dummyAnalytics { return &dummyAnalytics{} } diff --git a/cmd/daemon/events_moose.go b/cmd/daemon/events_moose.go index fdc230422..0806274c0 100644 --- a/cmd/daemon/events_moose.go +++ b/cmd/daemon/events_moose.go @@ -6,6 +6,7 @@ import ( "os" "github.com/NordSecurity/nordvpn-linux/config" + "github.com/NordSecurity/nordvpn-linux/core" "github.com/NordSecurity/nordvpn-linux/events/moose" "github.com/NordSecurity/nordvpn-linux/internal" ) @@ -15,7 +16,10 @@ var ( EventsSubdomain = "" ) -func newAnalytics(eventsDbPath string, fs *config.FilesystemConfigManager, +func newAnalytics( + eventsDbPath string, + fs *config.FilesystemConfigManager, + subAPI core.SubscriptionAPI, ver, env, id string) *moose.Subscriber { _ = os.Setenv("MOOSE_LOG_FILE", "Stdout") logLevel := "error" @@ -24,12 +28,13 @@ func newAnalytics(eventsDbPath string, fs *config.FilesystemConfigManager, } _ = os.Setenv("MOOSE_LOG", logLevel) return &moose.Subscriber{ - EventsDbPath: eventsDbPath, - Config: fs, - Version: ver, - Environment: env, - Domain: EventsDomain, - Subdomain: EventsSubdomain, - DeviceID: id, + EventsDbPath: eventsDbPath, + Config: fs, + Version: ver, + Environment: env, + Domain: EventsDomain, + Subdomain: EventsSubdomain, + DeviceID: id, + SubscriptionAPI: subAPI, } } diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 8c73bf30c..374abdc8f 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -280,7 +280,7 @@ func main() { // obfuscated machineID deviceID := fmt.Sprintf("%x", sha256.Sum256([]byte(cfg.MachineID.String()+Salt))) - analytics := newAnalytics(eventsDbPath, fsystem, Version, Environment, deviceID) + analytics := newAnalytics(eventsDbPath, fsystem, defaultAPI, Version, Environment, deviceID) if cfg.Analytics.Get() { if err := analytics.Enable(); err != nil { log.Println(internal.WarningPrefix, err) diff --git a/core/core.go b/core/core.go index 959da888c..a0e205dc7 100644 --- a/core/core.go +++ b/core/core.go @@ -53,6 +53,14 @@ type CombinedAPI interface { CreateUser(email, password string) (*UserCreateResponse, error) } +// SubscriptionAPI is responsible for fetching the subscription data of the user +type SubscriptionAPI interface { + // Orders returns a list of orders done by the user + Orders(token string) ([]Order, error) + // Payments returns a list of payments done by the user + Payments(token string) ([]PaymentResponse, error) +} + type DefaultAPI struct { agent string baseURL string @@ -531,3 +539,27 @@ func (api *DefaultAPI) Logout(token string) error { return nil } + +func (api *DefaultAPI) Orders(token string) ([]Order, error) { + return getData[[]Order](api, token, urlOrders) +} + +func (api *DefaultAPI) Payments(token string) ([]PaymentResponse, error) { + return getData[[]PaymentResponse](api, token, urlPayments) +} + +// getData calls a HTTP get request for the endpoints requiring authentication and returns the +// requested data. +func getData[T any](api *DefaultAPI, token string, url string) (T, error) { + var data T + resp, err := api.request(url, http.MethodGet, nil, token) + if err != nil { + return data, fmt.Errorf("executing HTTP GET request: %w", err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return data, fmt.Errorf("decoding data from JSON: %w", err) + } + + return data, nil +} diff --git a/core/models.go b/core/models.go index 42d7c7ec0..d84d302c8 100644 --- a/core/models.go +++ b/core/models.go @@ -5,7 +5,9 @@ import ( "fmt" "log" "net/netip" + "strconv" "strings" + "time" "golang.org/x/exp/slices" @@ -142,12 +144,73 @@ type CurrentUserResponse struct { Email string `json:"email"` } +type Order struct { + ID int `json:"id,omitempty"` + RemoteID int `json:"remote_id,omitempty"` + Status string `json:"status,omitempty"` + Plans Plans `json:"plans,omitempty"` +} + +type PaymentResponse struct { + Payment Payment `json:"payment,omitempty"` +} + +type Payment struct { + CreatedAt time.Time `json:"created_at,omitempty"` + Subscription Subscription `json:"subscription,omitempty"` + Status string `json:"status,omitempty"` + Payer Payer `json:"payer,omitempty"` + Amount float32 `json:"amount,omitempty"` + Currency string `json:"currency,omitempty"` + Provider string `json:"provider,omitempty"` +} + +func (p *Payment) UnmarshalJSON(data []byte) error { + type Alias Payment + aux := &struct { + CreatedAt string `json:"created_at"` + Amount string `json:"amount"` + *Alias + }{ + Alias: (*Alias)(p), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + t, err := time.Parse(internal.ServerDateFormat, aux.CreatedAt) + if err != nil { + return fmt.Errorf("parsing created_at: %w", err) + } + + amount, err := strconv.ParseFloat(aux.Amount, 32) + if err != nil { + return fmt.Errorf("parsing amount: %w", err) + } + + p.CreatedAt = t + p.Amount = float32(amount) + return nil +} + +type Subscription struct { + MerchantID int32 `json:"merchant_id,omitempty"` + FrequencyInterval int32 `json:"frequency_interval,omitempty"` + FrequencyUnit string `json:"frequency_unit,omitempty"` + Status string `json:"status,omitempty"` +} + +type Payer struct { + OrderID int `json:"order_id,omitempty"` +} + type TokenRenewResponse struct { Token string `json:"token"` RenewToken string `json:"renew_token"` ExpiresAt string `json:"expires_at"` } +type Plans []Plan + type TrustedPassTokenResponse struct { OwnerID string `json:"owner_id"` Token string `json:"token"` @@ -157,10 +220,8 @@ type MultifactorAuthStatusResponse struct { Status string `json:"status"` } -type Plans []Plan - type Plan struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Identifier string `json:"identifier"` Type string `json:"type"` Title string `json:"title"` diff --git a/core/models_test.go b/core/models_test.go index eda75f382..6caba6901 100644 --- a/core/models_test.go +++ b/core/models_test.go @@ -3,8 +3,11 @@ package core import ( "encoding/json" "net/netip" + "reflect" + "strconv" "strings" "testing" + "time" "github.com/NordSecurity/nordvpn-linux/config" "github.com/NordSecurity/nordvpn-linux/test/category" @@ -724,3 +727,47 @@ func TestLocationsCountry(t *testing.T) { assert.EqualValues(t, first, country) }) } + +func TestPayment_UnmarshalJSON(t *testing.T) { + category.Set(t, category.Unit) + for _, tt := range []struct { + name string + json string + payment Payment + errType error + }{ + { + name: "valid payment", + json: `{"created_at": "2001-01-01 00:00:00", "amount": "1.23", "status": "done"}`, + payment: Payment{ + CreatedAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC), + Amount: 1.23, + Status: "done", + }, + }, + { + name: "invalid JSON", + errType: &json.SyntaxError{}, + }, + { + name: "invalid created_at", + errType: &time.ParseError{}, + json: `{"created_at": "2001-01-01"}`, + }, + { + name: "invalid amount", + errType: strconv.ErrSyntax, + json: `{"created_at": "2001-01-01 00:00:00", "amount": "1.2.3"}`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + var p Payment + err := p.UnmarshalJSON([]byte(tt.json)) + if tt.errType != nil { + target := reflect.New(reflect.TypeOf(tt.errType)).Interface() + assert.ErrorAs(t, err, target) + } + assert.Equal(t, tt.payment, p) + }) + } +} diff --git a/core/urls.go b/core/urls.go index ba1c1e7c3..b0778728e 100644 --- a/core/urls.go +++ b/core/urls.go @@ -44,6 +44,12 @@ const ( // ServicesURL defines url to check user's current/expired services ServicesURL = UsersURL + "/services" + // urlOrders defines URL to list user's orders + urlOrders = UsersURL + "/orders" + + // urlOrders defines URL to list user's payments + urlPayments = UsersURL + "/payments" + // CredentialsURL defines url to generate openvpn credentials CredentialsURL = ServicesURL + "/credentials" diff --git a/daemon/events/events.go b/daemon/events/events.go index 82d113704..6a7c72d91 100644 --- a/daemon/events/events.go +++ b/daemon/events/events.go @@ -32,7 +32,7 @@ func NewEventsEmpty() *Events { &subs.Subject[any]{}, &subs.Subject[events.DataConnect]{}, &subs.Subject[events.DataDisconnect]{}, - &subs.Subject[core.ServicesResponse]{}, + &subs.Subject[any]{}, &subs.Subject[events.UiItemsAction]{}, &subs.Subject[int]{}, &subs.Subject[core.Insights]{}, @@ -63,7 +63,7 @@ func NewEvents( defaults events.PublishSubcriber[any], connect events.PublishSubcriber[events.DataConnect], disconnect events.PublishSubcriber[events.DataDisconnect], - accountCheck events.PublishSubcriber[core.ServicesResponse], + accountCheck events.PublishSubcriber[any], uiItemsClick events.PublishSubcriber[events.UiItemsAction], heartBeat events.PublishSubcriber[int], deviceLocation events.PublishSubcriber[core.Insights], @@ -185,7 +185,7 @@ func (s *SettingsEvents) Subscribe(to SettingsPublisher) { type ServicePublisher interface { NotifyConnect(events.DataConnect) error NotifyDisconnect(events.DataDisconnect) error - NotifyAccountCheck(core.ServicesResponse) error + NotifyAccountCheck(any) error NotifyUiItemsClick(events.UiItemsAction) error NotifyHeartBeat(int) error NotifyDeviceLocation(core.Insights) error @@ -194,7 +194,7 @@ type ServicePublisher interface { type ServiceEvents struct { Connect events.PublishSubcriber[events.DataConnect] Disconnect events.PublishSubcriber[events.DataDisconnect] - AccountCheck events.PublishSubcriber[core.ServicesResponse] + AccountCheck events.PublishSubcriber[any] UiItemsClick events.PublishSubcriber[events.UiItemsAction] HeartBeat events.PublishSubcriber[int] DeviceLocation events.PublishSubcriber[core.Insights] diff --git a/daemon/events/events_test.go b/daemon/events/events_test.go index 60c8abe58..fb087abba 100644 --- a/daemon/events/events_test.go +++ b/daemon/events/events_test.go @@ -29,32 +29,32 @@ func TestDaemonSubjectsSubscribe(t *testing.T) { type mockDaemonSubscriber struct{} -func (mockDaemonSubscriber) NotifyKillswitch(bool) error { return nil } -func (mockDaemonSubscriber) NotifyAutoconnect(bool) error { return nil } -func (mockDaemonSubscriber) NotifyDNS(events.DataDNS) error { return nil } -func (mockDaemonSubscriber) NotifyThreatProtectionLite(bool) error { return nil } -func (mockDaemonSubscriber) NotifyProtocol(config.Protocol) error { return nil } -func (mockDaemonSubscriber) NotifyAllowlist(events.DataAllowlist) error { return nil } -func (mockDaemonSubscriber) NotifyTechnology(config.Technology) error { return nil } -func (mockDaemonSubscriber) NotifyConnect(events.DataConnect) error { return nil } -func (mockDaemonSubscriber) NotifyDisconnect(events.DataDisconnect) error { return nil } -func (mockDaemonSubscriber) NotifyAccountCheck(core.ServicesResponse) error { return nil } -func (mockDaemonSubscriber) NotifyObfuscate(bool) error { return nil } -func (mockDaemonSubscriber) NotifyNotify(bool) error { return nil } -func (mockDaemonSubscriber) NotifyFirewall(bool) error { return nil } -func (mockDaemonSubscriber) NotifyRouting(bool) error { return nil } -func (mockDaemonSubscriber) NotifyIpv6(bool) error { return nil } -func (mockDaemonSubscriber) NotifyDefaults(any) error { return nil } -func (mockDaemonSubscriber) NotifyMeshnet(bool) error { return nil } -func (mockDaemonSubscriber) NotifyUiItemsClick(events.UiItemsAction) error { return nil } -func (mockDaemonSubscriber) NotifyHeartBeat(int) error { return nil } -func (mockDaemonSubscriber) NotifyDeviceLocation(core.Insights) error { return nil } -func (mockDaemonSubscriber) NotifyLANDiscovery(bool) error { return nil } -func (mockDaemonSubscriber) NotifyVirtualLocation(bool) error { return nil } -func (mockDaemonSubscriber) NotifyPostquantumVpn(bool) error { return nil } -func (mockDaemonSubscriber) NotifyLogin(events.DataAuthorization) error { return nil } -func (mockDaemonSubscriber) NotifyLogout(events.DataAuthorization) error { return nil } -func (mockDaemonSubscriber) NotifyMFA(bool) error { return nil } +func (mockDaemonSubscriber) NotifyKillswitch(bool) error { return nil } +func (mockDaemonSubscriber) NotifyAutoconnect(bool) error { return nil } +func (mockDaemonSubscriber) NotifyDNS(events.DataDNS) error { return nil } +func (mockDaemonSubscriber) NotifyThreatProtectionLite(bool) error { return nil } +func (mockDaemonSubscriber) NotifyProtocol(config.Protocol) error { return nil } +func (mockDaemonSubscriber) NotifyAllowlist(events.DataAllowlist) error { return nil } +func (mockDaemonSubscriber) NotifyTechnology(config.Technology) error { return nil } +func (mockDaemonSubscriber) NotifyConnect(events.DataConnect) error { return nil } +func (mockDaemonSubscriber) NotifyDisconnect(events.DataDisconnect) error { return nil } +func (mockDaemonSubscriber) NotifyAccountCheck(any) error { return nil } +func (mockDaemonSubscriber) NotifyObfuscate(bool) error { return nil } +func (mockDaemonSubscriber) NotifyNotify(bool) error { return nil } +func (mockDaemonSubscriber) NotifyFirewall(bool) error { return nil } +func (mockDaemonSubscriber) NotifyRouting(bool) error { return nil } +func (mockDaemonSubscriber) NotifyIpv6(bool) error { return nil } +func (mockDaemonSubscriber) NotifyDefaults(any) error { return nil } +func (mockDaemonSubscriber) NotifyMeshnet(bool) error { return nil } +func (mockDaemonSubscriber) NotifyUiItemsClick(events.UiItemsAction) error { return nil } +func (mockDaemonSubscriber) NotifyHeartBeat(int) error { return nil } +func (mockDaemonSubscriber) NotifyDeviceLocation(core.Insights) error { return nil } +func (mockDaemonSubscriber) NotifyLANDiscovery(bool) error { return nil } +func (mockDaemonSubscriber) NotifyVirtualLocation(bool) error { return nil } +func (mockDaemonSubscriber) NotifyPostquantumVpn(bool) error { return nil } +func (mockDaemonSubscriber) NotifyLogin(events.DataAuthorization) error { return nil } +func (mockDaemonSubscriber) NotifyLogout(events.DataAuthorization) error { return nil } +func (mockDaemonSubscriber) NotifyMFA(bool) error { return nil } // isValid returns true if given val is not nil. In case val is struct, // it checks if any of exported fields are not nil diff --git a/daemon/jobs.go b/daemon/jobs.go index 09e11fefd..e46e641d8 100644 --- a/daemon/jobs.go +++ b/daemon/jobs.go @@ -54,6 +54,11 @@ func (r *RPC) StartJobs(statePublisher *state.StatePublisher) { if _, err := r.scheduler.NewJob(gocron.DurationJob(24*time.Hour), gocron.NewTask(JobHeartBeat(1*24*60 /*minutes*/, r.events)), gocron.WithName("job heart beat")); err != nil { log.Println(internal.WarningPrefix, "job heart beat schedule error:", err) } + if _, err := r.scheduler.NewJob(gocron.DurationJob(7*24*time.Hour), gocron.NewTask(func() { + r.events.Service.AccountCheck.Publish(nil) + })); err != nil { + log.Println(internal.WarningPrefix, "job account check schedule error:", err) + } r.scheduler.Start() for _, job := range r.scheduler.Jobs() { diff --git a/daemon/rpc_account.go b/daemon/rpc_account.go index cda6a6751..ec61ad9ee 100644 --- a/daemon/rpc_account.go +++ b/daemon/rpc_account.go @@ -131,9 +131,7 @@ func (r *RPC) AccountInfo(ctx context.Context, _ *pb.Empty) (*pb.AccountResponse accountInfo.Username = currentUser.Username } - r.events.Service.AccountCheck.Publish( - core.ServicesResponse{}, - ) + r.events.Service.AccountCheck.Publish(struct{}{}) return accountInfo, nil } diff --git a/daemon/rpc_login.go b/daemon/rpc_login.go index cdfa628cd..842b3ae8d 100644 --- a/daemon/rpc_login.go +++ b/daemon/rpc_login.go @@ -66,7 +66,11 @@ func (r *RPC) loginCommon(customCB customCallbackType) (payload *pb.LoginRespons if retErr != nil || payload != nil && payload.Type != internal.CodeSuccess { eventStatus = events.StatusFailure } - r.events.User.Login.Publish(events.DataAuthorization{DurationMs: max(int(time.Since(loginStartTime).Milliseconds()), 1), EventTrigger: events.TriggerUser, EventStatus: eventStatus}) + r.events.User.Login.Publish(events.DataAuthorization{ + DurationMs: max(int(time.Since(loginStartTime).Milliseconds()), 1), + EventTrigger: events.TriggerUser, + EventStatus: eventStatus, + }) }() resp, pbresp, err := customCB() diff --git a/events/moose/moose.go b/events/moose/moose.go index 098866839..ecce7564f 100644 --- a/events/moose/moose.go +++ b/events/moose/moose.go @@ -17,6 +17,7 @@ import ( "net/http" "net/url" "os/exec" + "slices" "strconv" "strings" "sync" @@ -54,6 +55,7 @@ type Subscriber struct { Domain string Subdomain string DeviceID string + SubscriptionAPI core.SubscriptionAPI currentDomain string connectionStartTime time.Time connectionToMeshnetPeer bool @@ -205,13 +207,17 @@ func (s *Subscriber) NotifyKillswitch(data bool) error { return s.response(moose.NordvpnappSetContextApplicationNordvpnappConfigUserPreferencesKillSwitchEnabledValue(data)) } -func (s *Subscriber) NotifyAccountCheck(core.ServicesResponse) error { return nil } +func (s *Subscriber) NotifyAccountCheck(any) error { + return s.fetchSubscriptions() +} func (s *Subscriber) NotifyAutoconnect(data bool) error { return s.response(moose.NordvpnappSetContextApplicationNordvpnappConfigUserPreferencesAutoConnectEnabledValue(data)) } -func (s *Subscriber) NotifyDefaults(any) error { return nil } +func (s *Subscriber) NotifyDefaults(any) error { + return s.clearSubscriptions() +} func (s *Subscriber) NotifyDNS(data events.DataDNS) error { if err := s.response(moose.NordvpnappSetContextApplicationNordvpnappConfigUserPreferencesCustomDnsEnabledMeta(fmt.Sprintf(`{"count":%d}`, len(data.Ips)))); err != nil { @@ -271,7 +277,18 @@ func (s *Subscriber) NotifyLogin(data events.DataAuthorization) error { eventStatus = moose.NordvpnappEventStatusAttempt } - return s.response(moose.NordvpnappSendServiceQualityAuthorizationLogin(int32(data.DurationMs), eventTrigger, eventStatus, moose.NordvpnappOptBoolNone)) + if err := s.response(moose.NordvpnappSendServiceQualityAuthorizationLogin( + int32(data.DurationMs), + eventTrigger, + eventStatus, + moose.NordvpnappOptBoolNone, + )); err != nil { + return err + } + if data.EventStatus == events.StatusSuccess { + return s.fetchSubscriptions() + } + return nil } func (s *Subscriber) NotifyLogout(data events.DataAuthorization) error { @@ -297,7 +314,19 @@ func (s *Subscriber) NotifyLogout(data events.DataAuthorization) error { eventStatus = moose.NordvpnappEventStatusAttempt } - return s.response(moose.NordvpnappSendServiceQualityAuthorizationLogout(int32(data.DurationMs), eventTrigger, eventStatus, moose.NordvpnappOptBoolNone)) + if err := s.response(moose.NordvpnappSendServiceQualityAuthorizationLogout( + int32(data.DurationMs), + eventTrigger, + eventStatus, + moose.NordvpnappOptBoolNone, + )); err != nil { + return err + } + + if data.EventStatus == events.StatusSuccess { + return s.clearSubscriptions() + } + return nil } func (s *Subscriber) NotifyMFA(data bool) error { @@ -658,6 +687,222 @@ func (s *Subscriber) sendEvent(contentType, userAgent, requestBody string) int { return errCodeEventSendSuccess } +func (s *Subscriber) fetchSubscriptions() error { + if !s.enabled { + return nil + } + var cfg config.Config + if err := s.Config.Load(&cfg); err != nil { + return fmt.Errorf("loading config: %w", err) + } + token := cfg.TokensData[cfg.AutoConnectData.ID].Token + + payments, err := s.SubscriptionAPI.Payments(token) + if err != nil { + return fmt.Errorf("fetching payments: %w", err) + } + + orders, err := s.SubscriptionAPI.Orders(token) + if err != nil { + return fmt.Errorf("fetching orders: %w", err) + } + + payment, ok := findPayment(payments) + if !ok { + return fmt.Errorf("no valid payments found for the user") + } + + var orderErr error + order, ok := findOrder(payment, orders) + if !ok { + orderErr = fmt.Errorf("no valid order was found for the payment") + } + + if err := s.setSubscriptions( + payment, + order, + countFunc(payments, isPaymentValid, 2), + ); err != nil { + errors.Join(orderErr, fmt.Errorf("setting subscriptions: %w", err)) + } + + return orderErr +} + +func findPayment(payments []core.PaymentResponse) (core.Payment, bool) { + // Sort by CreatedAt descending + slices.SortFunc(payments, func(a core.PaymentResponse, b core.PaymentResponse) int { + return -a.Payment.CreatedAt.Compare(b.Payment.CreatedAt) + }) + + // Find first element matching criteria + index := slices.IndexFunc(payments, isPaymentValid) + if index < 0 { + return core.Payment{}, false + } + + return payments[index].Payment, true +} + +func findOrder(p core.Payment, orders []core.Order) (core.Order, bool) { + // Find order matching the payment + if p.Subscription.MerchantID != 25 && p.Subscription.MerchantID != 3 { + return core.Order{}, false + } + index := slices.IndexFunc(orders, func(o core.Order) bool { + var cmpID int + switch p.Subscription.MerchantID { + case 3: + cmpID = o.ID + case 25: + cmpID = o.RemoteID + } + return p.Payer.OrderID == cmpID + }) + if index < 0 { + return core.Order{}, false + } + + return orders[index], true +} + +func isPaymentValid(pr core.PaymentResponse) bool { + p := pr.Payment + return p.Status == "done" || + p.Status == "error" || + p.Status == "chargeback" || + p.Status == "refunded" || + p.Status == "partially_refunded" || + p.Status == "trial" +} + +// countFunc returns a number of elements in slice matching criteria +func countFunc[S ~[]E, E any](s S, f func(E) bool, stopAt int) int { + count := 0 + for _, e := range s { + if f(e) { + count++ + } + if stopAt >= 0 && count >= stopAt { + return count + } + } + return count +} + +func (s *Subscriber) setSubscriptions( + payment core.Payment, + order core.Order, + validPaymentsCount int, +) error { + var plan core.Plan + if len(order.Plans) > 0 { + plan = order.Plans[0] + } + for _, fn := range []func() uint32{ + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateActivationDate(payment.CreatedAt.Format(time.DateOnly)) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateFrequencyInterval(payment.Subscription.FrequencyInterval) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateFrequencyUnit(payment.Subscription.FrequencyUnit) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateIsActive(order.Status == "active") + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateIsNewCustomer(validPaymentsCount == 1) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateMerchantId(payment.Subscription.MerchantID) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePaymentAmount(payment.Amount) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePaymentCurrency(payment.Currency) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePaymentProvider(payment.Provider) + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePaymentStatus(payment.Status) + }, + func() uint32 { + if plan.ID != 0 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePlanId(plan.ID) + } + return 0 + }, + func() uint32 { + if plan.Type != "" { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStatePlanType(plan.Type) + } + return 0 + }, + func() uint32 { + return moose.NordvpnappSetContextUserNordvpnappSubscriptionCurrentStateSubscriptionStatus(payment.Subscription.Status) + }, + } { + if err := s.response(fn()); err != nil { + return err + } + } + return nil +} + +func (s *Subscriber) clearSubscriptions() error { + for _, fn := range []func() uint32{ + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateActivationDate() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateFrequencyInterval() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateFrequencyUnit() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateIsActive() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateIsNewCustomer() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateMerchantId() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePaymentAmount() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePaymentCurrency() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePaymentProvider() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePaymentStatus() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePlanId() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStatePlanType() + }, + func() uint32 { + return moose.NordvpnappUnsetContextUserNordvpnappSubscriptionCurrentStateSubscriptionStatus() + }, + } { + if err := s.response(fn()); err != nil { + return err + } + } + + return nil +} + func (s *Subscriber) updateEventDomain() error { domainUrl, err := url.Parse(s.Domain) if err != nil { diff --git a/events/moose/moose_test.go b/events/moose/moose_test.go new file mode 100644 index 000000000..cacc76b47 --- /dev/null +++ b/events/moose/moose_test.go @@ -0,0 +1,127 @@ +//go:build moose + +package moose + +import ( + "math" + "testing" + "time" + + "github.com/NordSecurity/nordvpn-linux/core" + "github.com/NordSecurity/nordvpn-linux/test/category" + "gotest.tools/v3/assert" +) + +func TestIsPaymentValid(t *testing.T) { + category.Set(t, category.Unit) + for _, tt := range []struct { + name string + payment core.Payment + valid bool + }{ + {name: "empty payment", valid: false}, + {name: "invalid status", valid: false, payment: core.Payment{Status: "invalid"}}, + {name: "done status", valid: true, payment: core.Payment{Status: "done"}}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal( + t, + tt.valid, + isPaymentValid(core.PaymentResponse{Payment: tt.payment}), + ) + }) + } +} + +func TestFindPayment(t *testing.T) { + category.Set(t, category.Unit) + now := time.Now() + for _, tt := range []struct { + name string + payments []core.PaymentResponse + payment core.Payment + ok bool + }{ + { + name: "empty list", + }, + { + name: "not found in invalid list", + ok: false, + payments: []core.PaymentResponse{{Payment: core.Payment{}}}, + }, + { + name: "1 out of 1 found", + ok: true, + payments: []core.PaymentResponse{{Payment: core.Payment{Status: "done"}}}, + payment: core.Payment{Status: "done"}, + }, + { + name: "latest valid found", + ok: true, + payments: []core.PaymentResponse{ + {Payment: core.Payment{Status: "done", CreatedAt: now}}, + {Payment: core.Payment{Status: "done", CreatedAt: now.Add(-time.Second)}}, + }, + payment: core.Payment{Status: "done", CreatedAt: now}, + }, + { + name: "latest valid found in mixture of valids and invalids", + ok: true, + payments: []core.PaymentResponse{ + {Payment: core.Payment{Status: "done", CreatedAt: now.Add(-3 * time.Second)}}, + {Payment: core.Payment{Status: "invalid", CreatedAt: now}}, + {Payment: core.Payment{Status: "done", CreatedAt: now.Add(-time.Second)}}, + {Payment: core.Payment{Status: "invalid", CreatedAt: now.Add(-2 * time.Second)}}, + }, + payment: core.Payment{Status: "done", CreatedAt: now.Add(-time.Second)}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + payment, ok := findPayment(tt.payments) + assert.Equal(t, tt.payment, payment) + assert.Equal(t, tt.ok, ok) + }) + } +} + +func TestFindOrder(t *testing.T) { + category.Set(t, category.Unit) + validOrder := core.Order{ID: 123, RemoteID: 321, Status: "done"} + for _, tt := range []struct { + name string + orders []core.Order + payment core.Payment + order core.Order + ok bool + }{ + { + name: "empty orders list", + }, + { + name: "invalid merchant ID", + payment: core.Payment{Payer: core.Payer{OrderID: 123}, Subscription: core.Subscription{MerchantID: math.MaxInt32}}, + orders: []core.Order{validOrder}, + }, + { + name: "merchant ID 3", + payment: core.Payment{Payer: core.Payer{OrderID: 123}, Subscription: core.Subscription{MerchantID: 3}}, + orders: []core.Order{validOrder}, + order: validOrder, + ok: true, + }, + { + name: "merchant ID 25", + payment: core.Payment{Payer: core.Payer{OrderID: 321}, Subscription: core.Subscription{MerchantID: 25}}, + orders: []core.Order{validOrder}, + order: validOrder, + ok: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + order, ok := findOrder(tt.payment, tt.orders) + assert.DeepEqual(t, tt.order, order) + assert.Equal(t, tt.ok, ok) + }) + } +} diff --git a/events/subs/subs.go b/events/subs/subs.go index 311f0402a..2225778b2 100644 --- a/events/subs/subs.go +++ b/events/subs/subs.go @@ -5,6 +5,7 @@ import ( "log" "github.com/NordSecurity/nordvpn-linux/events" + "github.com/NordSecurity/nordvpn-linux/internal" ) // Subject is a single topic, which supports multiple subscribers. @@ -21,7 +22,10 @@ func (s *Subject[T]) Subscribe(handler events.Handler[T]) { func (s *Subject[T]) Publish(message T) { for _, handler := range s.subscribers { if err := handler(message); err != nil { - log.Printf("notifying subscriber: %s\n", err) + log.Printf( + "%s error while notifying subscriber: %s\n", + internal.WarningPrefix, + err) } } }