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 }