github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/sharings/replicator_test.go (about)

     1  package sharings_test
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  	"testing"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/instance"
    10  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    11  	"github.com/cozy/cozy-stack/model/permission"
    12  	"github.com/cozy/cozy-stack/model/sharing"
    13  	"github.com/cozy/cozy-stack/model/vfs"
    14  	"github.com/cozy/cozy-stack/pkg/assets/dynamic"
    15  	build "github.com/cozy/cozy-stack/pkg/config"
    16  	"github.com/cozy/cozy-stack/pkg/config/config"
    17  	"github.com/cozy/cozy-stack/pkg/consts"
    18  	"github.com/cozy/cozy-stack/pkg/couchdb"
    19  	"github.com/cozy/cozy-stack/pkg/couchdb/revision"
    20  	"github.com/cozy/cozy-stack/tests/testutils"
    21  	"github.com/cozy/cozy-stack/web"
    22  	"github.com/cozy/cozy-stack/web/errors"
    23  	"github.com/cozy/cozy-stack/web/middlewares"
    24  	"github.com/cozy/cozy-stack/web/permissions"
    25  	"github.com/cozy/cozy-stack/web/sharings"
    26  	"github.com/cozy/cozy-stack/web/statik"
    27  	"github.com/gavv/httpexpect/v2"
    28  	"github.com/gofrs/uuid/v5"
    29  	"github.com/labstack/echo/v4"
    30  	"github.com/stretchr/testify/assert"
    31  	"github.com/stretchr/testify/require"
    32  )
    33  
    34  func TestReplicator(t *testing.T) {
    35  	if testing.Short() {
    36  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    37  	}
    38  
    39  	// Things for the replicator tests
    40  	var replSharingID, replAccessToken string
    41  	var fileSharingID, fileAccessToken string
    42  	var dirID string
    43  	var xorKey []byte
    44  
    45  	const replDoctype = "io.cozy.replicator.tests"
    46  
    47  	config.UseTestFile(t)
    48  	build.BuildMode = build.ModeDev
    49  	config.GetConfig().Assets = "../../assets"
    50  	_ = web.LoadSupportedLocales()
    51  	testutils.NeedCouchdb(t)
    52  	render, _ := statik.NewDirRenderer("../../assets")
    53  	middlewares.BuildTemplates()
    54  
    55  	// Prepare Alice's instance
    56  	setup := testutils.NewSetup(t, t.Name()+"_alice")
    57  	aliceInstance := setup.GetTestInstance(&lifecycle.Options{
    58  		Email:      "alice@example.net",
    59  		PublicName: "Alice",
    60  	})
    61  	charlieContact = createContact(t, aliceInstance, "Charlie", "charlie@example.net")
    62  	daveContact = createContact(t, aliceInstance, "Dave", "dave@example.net")
    63  	tsA := setup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){
    64  		"/sharings":    sharings.Routes,
    65  		"/permissions": permissions.Routes,
    66  	})
    67  	tsA.Config.Handler.(*echo.Echo).Renderer = render
    68  	tsA.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler
    69  	t.Cleanup(tsA.Close)
    70  
    71  	// Prepare another instance for the replicator tests
    72  	replSetup := testutils.NewSetup(t, t.Name()+"_replicator")
    73  	replInstance := replSetup.GetTestInstance()
    74  	tsR := replSetup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){
    75  		"/sharings": sharings.Routes,
    76  	})
    77  	t.Cleanup(tsR.Close)
    78  
    79  	require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String()), "Could not init dynamic FS")
    80  	t.Run("CreateSharingForReplicatorTest", func(t *testing.T) {
    81  		rule := sharing.Rule{
    82  			Title:    "tests",
    83  			DocType:  replDoctype,
    84  			Selector: "foo",
    85  			Values:   []string{"bar", "baz"},
    86  			Add:      "sync",
    87  			Update:   "sync",
    88  			Remove:   "sync",
    89  		}
    90  		s := sharing.Sharing{
    91  			Description: "replicator tests",
    92  			Rules:       []sharing.Rule{rule},
    93  		}
    94  		assert.NoError(t, s.BeOwner(replInstance, ""))
    95  		s.Members = append(s.Members, sharing.Member{
    96  			Status:   sharing.MemberStatusReady,
    97  			Name:     "J. Doe",
    98  			Email:    "j.doe@example.net",
    99  			Instance: "https://j.example.net/",
   100  		})
   101  		s.Credentials = append(s.Credentials, sharing.Credentials{})
   102  		_, err := s.Create(replInstance)
   103  		assert.NoError(t, err)
   104  		replSharingID = s.SID
   105  
   106  		cli, err := sharing.CreateOAuthClient(replInstance, &s.Members[1])
   107  		assert.NoError(t, err)
   108  		s.Credentials[0].Client = sharing.ConvertOAuthClient(cli)
   109  		token, err := sharing.CreateAccessToken(replInstance, cli, s.SID, permission.ALL)
   110  		assert.NoError(t, err)
   111  		s.Credentials[0].AccessToken = token
   112  		assert.NoError(t, couchdb.UpdateDoc(replInstance, &s))
   113  		replAccessToken = token.AccessToken
   114  		assert.NoError(t, couchdb.CreateDB(replInstance, replDoctype))
   115  	})
   116  
   117  	t.Run("Permissions", func(t *testing.T) {
   118  		assert.NotNil(t, replSharingID)
   119  		assert.NotNil(t, replAccessToken)
   120  
   121  		id := replDoctype + "/" + uuidv7()
   122  		createShared(t, id, []string{"111111111"}, replInstance, replSharingID)
   123  
   124  		t.Run("WithoutBearerToken", func(t *testing.T) {
   125  			e := httpexpect.Default(t, tsR.URL)
   126  
   127  			e.POST("/sharings/"+replSharingID+"/_revs_diff").
   128  				WithHeader("Content-Type", "application/json").
   129  				WithHeader("Accept", "application/json").
   130  				WithBytes([]byte(`{"id": ["1-111111111"]}`)).
   131  				Expect().Status(401)
   132  		})
   133  
   134  		t.Run("OK", func(t *testing.T) {
   135  			e := httpexpect.Default(t, tsR.URL)
   136  
   137  			e.POST("/sharings/"+replSharingID+"/_revs_diff").
   138  				WithHeader("Content-Type", "application/json").
   139  				WithHeader("Authorization", "Bearer "+replAccessToken).
   140  				WithHeader("Accept", "application/json").
   141  				WithBytes([]byte(`{"id": ["1-111111111"]}`)).
   142  				Expect().Status(200)
   143  		})
   144  	})
   145  
   146  	t.Run("RevsDiff", func(t *testing.T) {
   147  		assert.NotEmpty(t, replSharingID)
   148  		assert.NotEmpty(t, replAccessToken)
   149  
   150  		sid1 := replDoctype + "/" + uuidv7()
   151  		createShared(t, sid1, []string{"1a", "1a", "1a"}, replInstance, replSharingID)
   152  		sid2 := replDoctype + "/" + uuidv7()
   153  		createShared(t, sid2, []string{"2a", "2a", "2a"}, replInstance, replSharingID)
   154  		sid3 := replDoctype + "/" + uuidv7()
   155  		createShared(t, sid3, []string{"3a", "3a", "3a"}, replInstance, replSharingID)
   156  		sid4 := replDoctype + "/" + uuidv7()
   157  		createShared(t, sid4, []string{"4a", "4a", "4a"}, replInstance, replSharingID)
   158  		sid5 := replDoctype + "/" + uuidv7()
   159  		createShared(t, sid5, []string{"5a", "5a", "5a"}, replInstance, replSharingID)
   160  		sid6 := replDoctype + "/" + uuidv7()
   161  
   162  		e := httpexpect.Default(t, tsR.URL)
   163  
   164  		obj := e.POST("/sharings/"+replSharingID+"/_revs_diff").
   165  			WithHeader("Authorization", "Bearer "+replAccessToken).
   166  			WithHeader("Accept", "application/json").
   167  			WithJSON(sharing.Changed{
   168  				sid1: []string{"3-1a"},
   169  				sid2: []string{"2-2a"},
   170  				sid3: []string{"5-3b"},
   171  				sid4: []string{"2-4b", "2-4c", "4-4d"},
   172  				sid6: []string{"1-6b"},
   173  			}).
   174  			Expect().Status(200).
   175  			JSON().Object()
   176  
   177  		// sid1 is the same on both sides
   178  		obj.NotContainsKey(sid1)
   179  
   180  		// sid2 was updated on the target
   181  		obj.NotContainsKey(sid2)
   182  
   183  		// sid3 was updated on the source
   184  		obj.Value(sid3).Object().Value("missing").Array().IsEqual([]string{"5-3b"})
   185  
   186  		// sid4 is a conflict
   187  		obj.Value(sid4).Object().Value("missing").Array().IsEqual([]string{"2-4b", "2-4c", "4-4d"})
   188  
   189  		// sid5 has been created on the target
   190  		obj.NotContainsKey(sid5)
   191  
   192  		// sid6 has been created on the source
   193  		obj.Value(sid6).Object().Value("missing").Array().IsEqual([]string{"1-6b"})
   194  	})
   195  
   196  	t.Run("BulkDocs", func(t *testing.T) {
   197  		assert.NotEmpty(t, replSharingID)
   198  		assert.NotEmpty(t, replAccessToken)
   199  
   200  		id1 := uuidv7()
   201  		sid1 := replDoctype + "/" + id1
   202  		createShared(t, sid1, []string{"aaa", "bbb"}, replInstance, replSharingID)
   203  		id2 := uuidv7()
   204  		sid2 := replDoctype + "/" + id2
   205  
   206  		e := httpexpect.Default(t, tsR.URL)
   207  
   208  		e.POST("/sharings/"+replSharingID+"/_bulk_docs").
   209  			WithHeader("Authorization", "Bearer "+replAccessToken).
   210  			WithHeader("Accept", "application/json").
   211  			WithJSON(sharing.DocsByDoctype{
   212  				replDoctype: {
   213  					{
   214  						"_id":  id1,
   215  						"_rev": "3-ccc",
   216  						"_revisions": map[string]interface{}{
   217  							"start": 3,
   218  							"ids":   []string{"ccc", "bbb"},
   219  						},
   220  						"this": "is document " + id1 + " at revision 3-ccc",
   221  						"foo":  "bar",
   222  					},
   223  					{
   224  						"_id":  id2,
   225  						"_rev": "3-fff",
   226  						"_revisions": map[string]interface{}{
   227  							"start": 3,
   228  							"ids":   []string{"fff", "eee", "dd"},
   229  						},
   230  						"this": "is document " + id2 + " at revision 3-fff",
   231  						"foo":  "baz",
   232  					},
   233  				},
   234  			}).
   235  			Expect().Status(200)
   236  
   237  		assertSharedDoc(t, sid1, "3-ccc", replInstance)
   238  		assertSharedDoc(t, sid2, "3-fff", replInstance)
   239  	})
   240  
   241  	t.Run("CreateSharingForUploadFileTest", func(t *testing.T) {
   242  		dirID = uuidv7()
   243  		ruleOne := sharing.Rule{
   244  			Title:    "file one",
   245  			DocType:  "io.cozy.files",
   246  			Selector: "",
   247  			Values:   []string{dirID},
   248  			Add:      "sync",
   249  			Update:   "sync",
   250  			Remove:   "sync",
   251  		}
   252  		s := sharing.Sharing{
   253  			Description: "upload files tests",
   254  			Rules:       []sharing.Rule{ruleOne},
   255  		}
   256  		assert.NoError(t, s.BeOwner(replInstance, ""))
   257  
   258  		s.Members = append(s.Members, sharing.Member{
   259  			Status:   sharing.MemberStatusReady,
   260  			Name:     "J. Doe",
   261  			Email:    "j.doe@example.net",
   262  			Instance: "https://j.example.net/",
   263  		})
   264  
   265  		s.Credentials = append(s.Credentials, sharing.Credentials{})
   266  		_, err := s.Create(replInstance)
   267  		assert.NoError(t, err)
   268  		fileSharingID = s.SID
   269  
   270  		xorKey = sharing.MakeXorKey()
   271  		s.Credentials[0].XorKey = xorKey
   272  
   273  		cli, err := sharing.CreateOAuthClient(aliceInstance, &s.Members[0])
   274  		assert.NoError(t, err)
   275  		s.Credentials[0].Client = sharing.ConvertOAuthClient(cli)
   276  
   277  		token, err := sharing.CreateAccessToken(aliceInstance, cli, s.SID, permission.ALL)
   278  		assert.NoError(t, err)
   279  		s.Credentials[0].AccessToken = token
   280  
   281  		cli2, err := sharing.CreateOAuthClient(replInstance, &s.Members[1])
   282  		assert.NoError(t, err)
   283  		s.Credentials[0].InboundClientID = cli2.ClientID
   284  
   285  		token2, err := sharing.CreateAccessToken(replInstance, cli2, s.SID, permission.ALL)
   286  		assert.NoError(t, err)
   287  		fileAccessToken = token2.AccessToken
   288  		assert.NoError(t, couchdb.UpdateDoc(replInstance, &s))
   289  	})
   290  
   291  	t.Run("UploadNewFile", func(t *testing.T) {
   292  		e := httpexpect.Default(t, tsR.URL)
   293  
   294  		assert.NotEmpty(t, fileSharingID)
   295  		assert.NotEmpty(t, fileAccessToken)
   296  
   297  		fileOneID := uuidv7()
   298  
   299  		obj := e.PUT("/sharings/"+fileSharingID+"/io.cozy.files/"+fileOneID+"/metadata").
   300  			WithHeader("Authorization", "Bearer "+fileAccessToken).
   301  			WithHeader("Accept", "application/json").
   302  			WithJSON(map[string]interface{}{
   303  				"_id":  fileOneID,
   304  				"_rev": "1-5f9ba207fefdc250e35f7cd866c84cc6",
   305  				"_revisions": map[string]interface{}{
   306  					"start": 1,
   307  					"ids":   []string{"5f9ba207fefdc250e35f7cd866c84cc6"},
   308  				},
   309  				"type":       "file",
   310  				"name":       "hello.txt",
   311  				"created_at": "2018-04-23T18:11:42.343937292+02:00",
   312  				"updated_at": "2018-04-23T18:11:42.343937292+02:00",
   313  				"size":       "6",
   314  				"md5sum":     "WReFt5RgHiErJg4lklY2/Q==",
   315  				"mime":       "text/plain",
   316  				"class":      "text",
   317  				"executable": false,
   318  				"trashed":    false,
   319  				"tags":       []string{},
   320  			}).
   321  			Expect().Status(200).
   322  			JSON().Object()
   323  
   324  		key := obj.Value("key").String().NotEmpty().Raw()
   325  
   326  		e.PUT("/sharings/"+fileSharingID+"/io.cozy.files/"+key).
   327  			WithHeader("Authorization", "Bearer "+fileAccessToken).
   328  			WithText("world\n"). // Must match the md5sum in the body just above
   329  			Expect().Status(204)
   330  	})
   331  
   332  	t.Run("GetFolder", func(t *testing.T) {
   333  		e := httpexpect.Default(t, tsR.URL)
   334  
   335  		assert.NotEmpty(t, fileSharingID)
   336  		assert.NotEmpty(t, fileAccessToken)
   337  
   338  		fs := replInstance.VFS()
   339  		folder, err := vfs.NewDirDoc(fs, "zorglub", dirID, nil)
   340  		assert.NoError(t, err)
   341  		assert.NoError(t, fs.CreateDir(folder))
   342  		msg := sharing.TrackMessage{
   343  			SharingID: fileSharingID,
   344  			RuleIndex: 0,
   345  			DocType:   consts.Files,
   346  		}
   347  		evt := sharing.TrackEvent{
   348  			Verb: "CREATED",
   349  			Doc: couchdb.JSONDoc{
   350  				Type: consts.Files,
   351  				M: map[string]interface{}{
   352  					"type":   folder.Type,
   353  					"_id":    folder.DocID,
   354  					"_rev":   folder.DocRev,
   355  					"name":   folder.DocName,
   356  					"path":   folder.Fullpath,
   357  					"dir_id": dirID,
   358  				},
   359  			},
   360  		}
   361  		assert.NoError(t, sharing.UpdateShared(replInstance, msg, evt))
   362  
   363  		xoredID := sharing.XorID(folder.DocID, xorKey)
   364  
   365  		obj := e.GET("/sharings/"+fileSharingID+"/io.cozy.files/"+xoredID).
   366  			WithHeader("Authorization", "Bearer "+fileAccessToken).
   367  			WithHeader("Accept", "application/json").
   368  			Expect().Status(200).
   369  			JSON().Object()
   370  
   371  		obj.HasValue("_id", xoredID)
   372  		obj.HasValue("_rev", folder.DocRev)
   373  		obj.HasValue("type", "directory")
   374  		obj.HasValue("name", "zorglub")
   375  		obj.NotContainsKey("dir_id")
   376  		obj.Value("created_at").String().AsDateTime(time.RFC3339)
   377  		obj.Value("updated_at").String().AsDateTime(time.RFC3339)
   378  	})
   379  }
   380  
   381  func uuidv7() string {
   382  	return uuid.Must(uuid.NewV7()).String()
   383  }
   384  
   385  func createShared(t *testing.T, sid string, revisions []string, replInstance *instance.Instance, replSharingID string) *sharing.SharedRef {
   386  	rev := fmt.Sprintf("%d-%s", len(revisions), revisions[0])
   387  	parts := strings.SplitN(sid, "/", 2)
   388  	doctype := parts[0]
   389  	id := parts[1]
   390  	start := revision.Generation(rev)
   391  	docs := []map[string]interface{}{
   392  		{
   393  			"_id":  id,
   394  			"_rev": rev,
   395  			"_revisions": map[string]interface{}{
   396  				"start": start,
   397  				"ids":   revisions,
   398  			},
   399  			"this": "is document " + id + " at revision " + rev,
   400  		},
   401  	}
   402  	err := couchdb.BulkForceUpdateDocs(replInstance, doctype, docs)
   403  	assert.NoError(t, err)
   404  	var tree *sharing.RevsTree
   405  	for i, r := range revisions {
   406  		old := tree
   407  		tree = &sharing.RevsTree{
   408  			Rev: fmt.Sprintf("%d-%s", start-i, r),
   409  		}
   410  		if old != nil {
   411  			tree.Branches = []sharing.RevsTree{*old}
   412  		}
   413  	}
   414  	ref := sharing.SharedRef{
   415  		SID:       sid,
   416  		Revisions: tree,
   417  		Infos: map[string]sharing.SharedInfo{
   418  			replSharingID: {Rule: 0},
   419  		},
   420  	}
   421  	err = couchdb.CreateNamedDocWithDB(replInstance, &ref)
   422  	assert.NoError(t, err)
   423  	return &ref
   424  }
   425  
   426  func assertSharedDoc(t *testing.T, sid, rev string, replInstance *instance.Instance) {
   427  	parts := strings.SplitN(sid, "/", 2)
   428  	doctype := parts[0]
   429  	id := parts[1]
   430  	var doc couchdb.JSONDoc
   431  	assert.NoError(t, couchdb.GetDoc(replInstance, doctype, id, &doc))
   432  	assert.Equal(t, doc.ID(), id)
   433  	assert.Equal(t, doc.Rev(), rev)
   434  	assert.Equal(t, doc.M["this"], "is document "+id+" at revision "+rev)
   435  }