From 0b8cc2d98d97367ca77601b659e886edb170bbc9 Mon Sep 17 00:00:00 2001 From: Erwan Guyader Date: Wed, 20 Nov 2024 16:43:00 +0100 Subject: [PATCH] feat: Add legal notice URL to instance settings When an instance's context has an associated cloudery and the instance's partner on the cloudery has defined a legal notice, the URL to this notice will be present in the instance settings returned by `GET /settings/instance` calls. --- docs/settings.md | 11 ++- model/cloudery/init.go | 1 + model/cloudery/service.go | 27 +++++++ model/cloudery/service_mock.go | 10 +++ model/cloudery/service_noop.go | 4 + model/settings/init.go | 1 + model/settings/service.go | 4 + model/settings/service_mock.go | 10 +++ model/settings/service_test.go | 136 ++++++++++++++------------------- web/settings/instance.go | 8 ++ web/settings/settings_test.go | 12 ++- 11 files changed, 141 insertions(+), 83 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index b297c1d718e..fb59903da2a 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -690,13 +690,14 @@ Cookie: sessionid=xxxx "auth_mode": "basic", "default_redirection": "drive/#/folder", "context": "dev", - "sponsorships": ["springfield"] + "sponsorships": ["springfield"], + "legal_notice_url": "https://manager.cozycloud.cc/e96388a5-8eed-44cc-81e6-40aad273f0d4.pdf" } } } ``` -#### Note about `password_defined` +##### Note about `password_defined` There are a few fields that are persisted on the instance its-self, not on its settings document. When they are updated, it won't be reflected in the realtime @@ -706,6 +707,12 @@ For `password_defined`, it is possible to be notified when the password is defined by watching a synthetic document with the doctype `io.cozy.settings`, and the id `io.cozy.settings.passphrase`. +##### Note about `legal_notice_url` + +This attribute will only be present if a manager is associated with the +instance and the instance was created on behalf of a partner with a defined +legal notice. + ### POST /settings/instance/deletion The settings application can use this route if the user wants to delete their diff --git a/model/cloudery/init.go b/model/cloudery/init.go index 85cfcc686dd..c8dcb717d1e 100644 --- a/model/cloudery/init.go +++ b/model/cloudery/init.go @@ -16,6 +16,7 @@ var service Service type Service interface { SaveInstance(inst *instance.Instance, cmd *SaveCmd) error BlockingSubscription(inst *instance.Instance) (*BlockingSubscription, error) + LegalNoticeUrl(inst *instance.Instance) (string, error) } func Init(contexts map[string]config.ClouderyConfig) Service { diff --git a/model/cloudery/service.go b/model/cloudery/service.go index d627a9c605f..3c43a110977 100644 --- a/model/cloudery/service.go +++ b/model/cloudery/service.go @@ -95,3 +95,30 @@ func blockingSubscriptionVendor(clouderyInstance map[string]interface{}) (string return "", fmt.Errorf("invalid blocking subscription vendor") } + +func (s *ClouderyService) LegalNoticeUrl(inst *instance.Instance) (string, error) { + client := instance.APIManagerClient(inst) + if client == nil { + return "", nil + } + + url := fmt.Sprintf("/api/v1/instances/%s", url.PathEscape(inst.UUID)) + res, err := client.Get(url) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + + return legalNoticeUrl(res) +} + +func legalNoticeUrl(clouderyInstance map[string]interface{}) (string, error) { + if str, ok := clouderyInstance["legal_notice_url"]; ok { + if url, ok := str.(string); ok { + return url, nil + } + + return "", fmt.Errorf("invalid legal notice url") + } + + return "", nil +} diff --git a/model/cloudery/service_mock.go b/model/cloudery/service_mock.go index 9b021bc9dca..899cdd9a3e7 100644 --- a/model/cloudery/service_mock.go +++ b/model/cloudery/service_mock.go @@ -35,3 +35,13 @@ func (m *Mock) BlockingSubscription(inst *instance.Instance) (*BlockingSubscript return args.Get(0).(*BlockingSubscription), args.Error(1) } + +func (m *Mock) LegalNoticeUrl(inst *instance.Instance) (string, error) { + args := m.Called(inst) + + if args.Get(0) == "" { + return "", args.Error(1) + } + + return args.Get(0).(string), args.Error(1) +} diff --git a/model/cloudery/service_noop.go b/model/cloudery/service_noop.go index 0f3adb8ed0c..5f2c9607ccc 100644 --- a/model/cloudery/service_noop.go +++ b/model/cloudery/service_noop.go @@ -15,3 +15,7 @@ func (s *NoopService) SaveInstance(inst *instance.Instance, cmd *SaveCmd) error func (s *NoopService) BlockingSubscription(inst *instance.Instance) (*BlockingSubscription, error) { return nil, nil } + +func (s *NoopService) LegalNoticeUrl(inst *instance.Instance) (string, error) { + return "", nil +} diff --git a/model/settings/init.go b/model/settings/init.go index 0ef42bf7248..406e0fe8e69 100644 --- a/model/settings/init.go +++ b/model/settings/init.go @@ -20,6 +20,7 @@ type Service interface { ConfirmEmailUpdate(inst *instance.Instance, tok string) error CancelEmailUpdate(inst *instance.Instance) error GetExternalTies(inst *instance.Instance) (*ExternalTies, error) + GetLegalNoticeUrl(inst *instance.Instance) (string, error) } func Init( diff --git a/model/settings/service.go b/model/settings/service.go index dccc1604ea4..8cfeff363ca 100644 --- a/model/settings/service.go +++ b/model/settings/service.go @@ -266,3 +266,7 @@ func (s *SettingsService) GetExternalTies(inst *instance.Instance) (*ExternalTie return &ties, nil } + +func (s *SettingsService) GetLegalNoticeUrl(inst *instance.Instance) (string, error) { + return s.cloudery.LegalNoticeUrl(inst) +} diff --git a/model/settings/service_mock.go b/model/settings/service_mock.go index 3b2c04e48d9..51d955bcb86 100644 --- a/model/settings/service_mock.go +++ b/model/settings/service_mock.go @@ -75,3 +75,13 @@ func (m *Mock) GetExternalTies(inst *instance.Instance) (*ExternalTies, error) { return args.Get(0).(*ExternalTies), args.Error(1) } + +func (m *Mock) GetLegalNoticeUrl(inst *instance.Instance) (string, error) { + args := m.Called(inst) + + if args.Get(0) == "" { + return "", args.Error(1) + } + + return args.Get(0).(string), args.Error(1) +} diff --git a/model/settings/service_test.go b/model/settings/service_test.go index 33c721d72c8..8483e9978f8 100644 --- a/model/settings/service_test.go +++ b/model/settings/service_test.go @@ -12,19 +12,28 @@ import ( "github.com/stretchr/testify/assert" ) -func TestServiceImplems(t *testing.T) { - assert.Implements(t, (*Service)(nil), new(SettingsService)) - assert.Implements(t, (*Service)(nil), new(Mock)) -} - -func Test_StartEmailUpdate_success(t *testing.T) { +func setupTest(t *testing.T) (*emailer.Mock, *instance.Mock, *token.Mock, *cloudery.Mock, *storageMock, Service) { emailerSvc := emailer.NewMock(t) instSvc := instance.NewMock(t) tokenSvc := token.NewMock(t) clouderySvc := cloudery.NewMock(t) storage := newStorageMock(t) - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + return emailerSvc, + instSvc, + tokenSvc, + clouderySvc, + storage, + NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) +} + +func TestServiceImplems(t *testing.T) { + assert.Implements(t, (*Service)(nil), new(SettingsService)) + assert.Implements(t, (*Service)(nil), new(Mock)) +} + +func Test_StartEmailUpdate_success(t *testing.T) { + emailerSvc, instSvc, tokenSvc, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -64,13 +73,7 @@ func Test_StartEmailUpdate_success(t *testing.T) { } func Test_StartEmailUpdate_with_an_invalid_password(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, instSvc, _, _, _, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -88,13 +91,7 @@ func Test_StartEmailUpdate_with_an_invalid_password(t *testing.T) { } func Test_StartEmailUpdate_with_a_missing_public_name(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + emailerSvc, instSvc, tokenSvc, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -135,13 +132,7 @@ func Test_StartEmailUpdate_with_a_missing_public_name(t *testing.T) { } func TestConfirmEmailUpdate_success(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, tokenSvc, clouderySvc, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -178,13 +169,7 @@ func TestConfirmEmailUpdate_success(t *testing.T) { } func TestConfirmEmailUpdate_with_an_invalid_token(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, tokenSvc, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -206,13 +191,7 @@ func TestConfirmEmailUpdate_with_an_invalid_token(t *testing.T) { } func TestConfirmEmailUpdate_without_a_pending_email(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, _, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -231,13 +210,7 @@ func TestConfirmEmailUpdate_without_a_pending_email(t *testing.T) { } func Test_CancelEmailUpdate_success(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, _, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -264,13 +237,7 @@ func Test_CancelEmailUpdate_success(t *testing.T) { } func Test_CancelEmailUpdate_without_pending_email(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, _, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -288,13 +255,7 @@ func Test_CancelEmailUpdate_without_pending_email(t *testing.T) { } func Test_ResendEmailUpdate_success(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + emailerSvc, _, tokenSvc, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -323,13 +284,7 @@ func Test_ResendEmailUpdate_success(t *testing.T) { } func Test_ResendEmailUpdate_with_no_pending_email(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, _, _, storage, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -347,13 +302,7 @@ func Test_ResendEmailUpdate_with_no_pending_email(t *testing.T) { } func Test_GetExternalTies(t *testing.T) { - emailerSvc := emailer.NewMock(t) - instSvc := instance.NewMock(t) - tokenSvc := token.NewMock(t) - clouderySvc := cloudery.NewMock(t) - storage := newStorageMock(t) - - svc := NewService(emailerSvc, instSvc, tokenSvc, clouderySvc, storage) + _, _, _, clouderySvc, _, svc := setupTest(t) inst := instance.Instance{ Domain: "foo.mycozy.cloud", @@ -386,3 +335,36 @@ func Test_GetExternalTies(t *testing.T) { assert.Nil(t, ties) }) } + +func Test_GetLegalNoticeUrl(t *testing.T) { + _, _, _, clouderySvc, _, svc := setupTest(t) + + inst := instance.Instance{ + Domain: "foo.mycozy.cloud", + } + + t.Run("with a legal notice", func(t *testing.T) { + clouderySvc.On("LegalNoticeUrl", &inst).Return("https://testmanager.cozycloud.cc", nil).Once() + + url, err := svc.GetLegalNoticeUrl(&inst) + assert.NoError(t, err) + assert.Equal(t, "https://testmanager.cozycloud.cc", url) + }) + + t.Run("without a legal notice", func(t *testing.T) { + clouderySvc.On("LegalNoticeUrl", &inst).Return("", nil).Once() + + url, err := svc.GetLegalNoticeUrl(&inst) + assert.NoError(t, err) + assert.Equal(t, "", url) + }) + + t.Run("with error from cloudery", func(t *testing.T) { + unauthorizedError := errors.New("unauthorized") + clouderySvc.On("LegalNoticeUrl", &inst).Return("", unauthorizedError).Once() + + url, err := svc.GetLegalNoticeUrl(&inst) + assert.ErrorIs(t, err, unauthorizedError) + assert.Equal(t, "", url) + }) +} diff --git a/web/settings/instance.go b/web/settings/instance.go index f5621de002c..c4b29645e76 100644 --- a/web/settings/instance.go +++ b/web/settings/instance.go @@ -71,6 +71,14 @@ func (h *HTTPHandler) getInstance(c echo.Context) error { return err } + url, err := h.svc.GetLegalNoticeUrl(inst) + if err != nil { + return err + } + if url != "" { + doc.M["legal_notice_url"] = url + } + return jsonapi.Data(c, http.StatusOK, &apiInstance{doc}, nil) } diff --git a/web/settings/settings_test.go b/web/settings/settings_test.go index 281586670c4..b476a39dff7 100644 --- a/web/settings/settings_test.go +++ b/web/settings/settings_test.go @@ -121,12 +121,12 @@ func TestSettings(t *testing.T) { Object() data := obj.Value("data").Object() - data.ValueEqual("type", "io.cozy.settings") - data.ValueEqual("id", "io.cozy.settings.context") + data.HasValue("type", "io.cozy.settings") + data.HasValue("id", "io.cozy.settings.context") attrs := data.Value("attributes").Object() - attrs.ValueEqual("manager_url", "http://manager.example.org") - attrs.ValueEqual("logos", map[string]interface{}{ + attrs.HasValue("manager_url", "http://manager.example.org") + attrs.HasValue("logos", map[string]interface{}{ "home": map[string]interface{}{ "light": []interface{}{ map[string]interface{}{"src": "/logos/main_cozy.png", "alt": "Cozy Cloud"}, @@ -470,6 +470,7 @@ func TestSettings(t *testing.T) { t.Run("GetCapabilities", func(t *testing.T) { e := testutils.CreateTestClient(t, tsURL) + svc.On("GetLegalNoticeUrl", testInstance).Return("", nil).Once() e.GET("/settings/instance"). WithCookie(sessCookie, "connected"). @@ -508,6 +509,7 @@ func TestSettings(t *testing.T) { Expect().Status(200) testInstance.RegisterToken = []byte{} + svc.On("GetLegalNoticeUrl", testInstance).Return("https://testmanager.cozycloud.cc/tos/12345.pdf", nil).Once() obj := e.GET("/settings/instance"). WithCookie(sessCookie, "connected"). @@ -528,6 +530,7 @@ func TestSettings(t *testing.T) { attrs.HasValue("tz", "Europe/London") attrs.HasValue("locale", "en") attrs.HasValue("password_defined", true) + attrs.HasValue("legal_notice_url", "https://testmanager.cozycloud.cc/tos/12345.pdf") }) t.Run("UpdateInstance", func(t *testing.T) { @@ -571,6 +574,7 @@ func TestSettings(t *testing.T) { t.Run("GetUpdatedInstance", func(t *testing.T) { e := testutils.CreateTestClient(t, tsURL) + svc.On("GetLegalNoticeUrl", testInstance).Return("", nil).Once() obj := e.GET("/settings/instance"). WithCookie(sessCookie, "connected").