github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/instance/lifecycle/lifecycle_test.go (about) 1 package lifecycle_test 2 3 import ( 4 "bytes" 5 "testing" 6 "time" 7 8 "github.com/cozy/cozy-stack/model/bitwarden/settings" 9 "github.com/cozy/cozy-stack/model/instance" 10 "github.com/cozy/cozy-stack/model/instance/lifecycle" 11 "github.com/cozy/cozy-stack/model/stack" 12 "github.com/cozy/cozy-stack/model/vfs" 13 "github.com/cozy/cozy-stack/pkg/config/config" 14 "github.com/cozy/cozy-stack/pkg/consts" 15 "github.com/cozy/cozy-stack/pkg/couchdb" 16 "github.com/cozy/cozy-stack/pkg/couchdb/mango" 17 "github.com/cozy/cozy-stack/pkg/prefixer" 18 "github.com/cozy/cozy-stack/tests/testutils" 19 "github.com/stretchr/testify/assert" 20 "github.com/stretchr/testify/require" 21 22 _ "github.com/cozy/cozy-stack/worker/mails" 23 ) 24 25 func TestLifecycle(t *testing.T) { 26 if testing.Short() { 27 t.Skip("an instance is required for this test: test skipped due to the use of --short flag") 28 } 29 30 config.UseTestFile(t) 31 32 testutils.NeedCouchdb(t) 33 34 _, _, err := stack.Start() 35 require.NoError(t, err) 36 37 t.Cleanup(cleanInstance) 38 39 t.Run("ChooseCouchCluster", func(t *testing.T) { 40 clusters := []config.CouchDBCluster{ 41 {Creation: false}, 42 } 43 _, err := lifecycle.ChooseCouchCluster(clusters) 44 assert.Error(t, err) 45 46 clusters = []config.CouchDBCluster{ 47 {Creation: false}, 48 {Creation: true}, 49 } 50 index, err := lifecycle.ChooseCouchCluster(clusters) 51 assert.NoError(t, err) 52 assert.Equal(t, 1, index) 53 54 clusters = []config.CouchDBCluster{ 55 {Creation: false}, 56 {Creation: true}, 57 {Creation: true}, 58 {Creation: false}, 59 {Creation: true}, 60 } 61 counts := make([]int, len(clusters)) 62 for i := 0; i < 10000; i++ { 63 index, err = lifecycle.ChooseCouchCluster(clusters) 64 assert.NoError(t, err) 65 counts[index] += 1 66 } 67 assert.Equal(t, 0, counts[0]) 68 assert.Greater(t, counts[1], 3000) 69 assert.Greater(t, counts[2], 3000) 70 assert.Equal(t, 0, counts[3]) 71 assert.Greater(t, counts[4], 3000) 72 }) 73 74 t.Run("GetInstanceNoDB", func(t *testing.T) { 75 res, err := lifecycle.GetInstance("no.instance.cozycloud.cc") 76 if assert.Error(t, err, "An error is expected") { 77 assert.Nil(t, res) 78 assert.ErrorIs(t, err, instance.ErrNotFound) 79 } 80 }) 81 82 t.Run("CreateInstance", func(t *testing.T) { 83 instance, err := lifecycle.Create(&lifecycle.Options{ 84 Domain: "test.cozycloud.cc", 85 Locale: "en", 86 }) 87 if assert.NoError(t, err) { 88 assert.NotEmpty(t, instance.ID()) 89 assert.Equal(t, instance.Domain, "test.cozycloud.cc") 90 } 91 }) 92 93 t.Run("CreateInstanceWithFewSettings", func(t *testing.T) { 94 inst, err := lifecycle.Create(&lifecycle.Options{ 95 Domain: "test2.cozycloud.cc", 96 Timezone: "Europe/Berlin", 97 Email: "alice@example.com", 98 PublicName: "Alice", 99 Passphrase: "password", 100 Settings: "offer:freemium,context:my_context,auth_mode:two_factor_mail,uuid:XXX,locale:en,tos:20151111,oidc_id:oidc_42", 101 }) 102 103 assert.NoError(t, err) 104 assert.Equal(t, inst.Domain, "test2.cozycloud.cc") 105 doc, err := inst.SettingsDocument() 106 assert.NoError(t, err) 107 assert.Equal(t, "Europe/Berlin", doc.M["tz"].(string)) 108 assert.Equal(t, "alice@example.com", doc.M["email"].(string)) 109 assert.Equal(t, "freemium", doc.M["offer"].(string)) 110 assert.Equal(t, "Alice", doc.M["public_name"].(string)) 111 112 assert.Equal(t, inst.UUID, "XXX") 113 assert.Equal(t, inst.OIDCID, "oidc_42") 114 assert.Equal(t, inst.Locale, "en") 115 assert.Equal(t, inst.TOSSigned, "1.0.0-20151111") 116 assert.Equal(t, inst.ContextName, "my_context") 117 assert.Equal(t, inst.AuthMode, instance.TwoFactorMail) 118 }) 119 120 t.Run("CreateInstanceWithMoreSettings", func(t *testing.T) { 121 inst, err := lifecycle.Create(&lifecycle.Options{ 122 Domain: "test3.cozycloud.cc", 123 UUID: "XXX", 124 OIDCID: "oidc_42", 125 Locale: "en", 126 TOSSigned: "20151111", 127 TOSLatest: "1.0.0-20151111", 128 Timezone: "Europe/Berlin", 129 ContextName: "my_context", 130 Email: "alice@example.com", 131 PublicName: "Alice", 132 AuthMode: "two_factor_mail", 133 Passphrase: "password", 134 Settings: "offer:freemium", 135 }) 136 137 assert.NoError(t, err) 138 assert.Equal(t, inst.Domain, "test3.cozycloud.cc") 139 doc, err := inst.SettingsDocument() 140 assert.NoError(t, err) 141 assert.Equal(t, "Europe/Berlin", doc.M["tz"].(string)) 142 assert.Equal(t, "alice@example.com", doc.M["email"].(string)) 143 assert.Equal(t, "freemium", doc.M["offer"].(string)) 144 assert.Equal(t, "Alice", doc.M["public_name"].(string)) 145 146 assert.Equal(t, inst.UUID, "XXX") 147 assert.Equal(t, inst.OIDCID, "oidc_42") 148 assert.Equal(t, inst.Locale, "en") 149 assert.Equal(t, inst.TOSSigned, "1.0.0-20151111") 150 assert.Equal(t, inst.ContextName, "my_context") 151 assert.Equal(t, inst.AuthMode, instance.TwoFactorMail) 152 }) 153 154 t.Run("CreateInstanceBadDomain", func(t *testing.T) { 155 _, err := lifecycle.Create(&lifecycle.Options{ 156 Domain: "..", 157 Locale: "en", 158 }) 159 assert.Error(t, err, "An error is expected") 160 161 _, err = lifecycle.Create(&lifecycle.Options{ 162 Domain: ".", 163 Locale: "en", 164 }) 165 assert.Error(t, err, "An error is expected") 166 167 _, err = lifecycle.Create(&lifecycle.Options{ 168 Domain: "foo/bar", 169 Locale: "en", 170 }) 171 assert.Error(t, err, "An error is expected") 172 }) 173 174 t.Run("GetWrongInstance", func(t *testing.T) { 175 res, err := lifecycle.GetInstance("no.instance.cozycloud.cc") 176 if assert.Error(t, err, "An error is expected") { 177 assert.Nil(t, res) 178 assert.ErrorIs(t, err, instance.ErrNotFound) 179 } 180 }) 181 182 t.Run("GetCorrectInstance", func(t *testing.T) { 183 instance, err := lifecycle.GetInstance("test.cozycloud.cc") 184 if assert.NoError(t, err) { 185 assert.NotNil(t, instance) 186 assert.Equal(t, instance.Domain, "test.cozycloud.cc") 187 } 188 }) 189 190 t.Run("InstancehasOAuthSecret", func(t *testing.T) { 191 i, err := lifecycle.GetInstance("test.cozycloud.cc") 192 if assert.NoError(t, err) { 193 assert.NotNil(t, i) 194 assert.NotNil(t, i.OAuthSecret) 195 assert.Equal(t, len(i.OAuthSecret), instance.OauthSecretLen) 196 } 197 }) 198 199 t.Run("InstanceHasRootDir", func(t *testing.T) { 200 var root vfs.DirDoc 201 prefix := getDB(t, "test.cozycloud.cc") 202 err := couchdb.GetDoc(prefix, consts.Files, consts.RootDirID, &root) 203 if assert.NoError(t, err) { 204 assert.Equal(t, root.Fullpath, "/") 205 } 206 }) 207 208 t.Run("InstanceHasIndexes", func(t *testing.T) { 209 var results []*vfs.DirDoc 210 prefix := getDB(t, "test.cozycloud.cc") 211 req := &couchdb.FindRequest{Selector: mango.Equal("path", "/")} 212 err := couchdb.FindDocs(prefix, consts.Files, req, &results) 213 assert.NoError(t, err) 214 assert.Len(t, results, 1) 215 }) 216 217 t.Run("RegisterPassphrase", func(t *testing.T) { 218 i, err := lifecycle.GetInstance("test.cozycloud.cc") 219 if !assert.NoError(t, err, "cant fetch i") { 220 return 221 } 222 assert.NotNil(t, i) 223 assert.NotEmpty(t, i.RegisterToken) 224 assert.Len(t, i.RegisterToken, instance.RegisterTokenLen) 225 assert.NotEmpty(t, i.OAuthSecret) 226 assert.Len(t, i.OAuthSecret, instance.OauthSecretLen) 227 assert.NotEmpty(t, i.SessSecret) 228 assert.Len(t, i.SessSecret, instance.SessionSecretLen) 229 230 rtoken := i.RegisterToken 231 pass := []byte("passphrase") 232 empty := []byte("") 233 badtoken := []byte("not-token") 234 235 err = lifecycle.RegisterPassphrase(i, empty, lifecycle.PassParameters{ 236 Pass: pass, 237 Iterations: 5000, 238 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 239 }) 240 assert.Error(t, err, "RegisterPassphrase requires token") 241 242 err = lifecycle.RegisterPassphrase(i, badtoken, lifecycle.PassParameters{ 243 Pass: pass, 244 Iterations: 5000, 245 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 246 }) 247 assert.Error(t, err, "RegisterPassphrase requires proper token") 248 249 err = lifecycle.RegisterPassphrase(i, rtoken, lifecycle.PassParameters{ 250 Pass: pass, 251 Iterations: 5000, 252 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 253 }) 254 assert.NoError(t, err) 255 256 assert.Empty(t, i.RegisterToken, "RegisterToken has not been removed") 257 assert.NotEmpty(t, i.PassphraseHash, "PassphraseHash has not been saved") 258 259 err = lifecycle.RegisterPassphrase(i, rtoken, lifecycle.PassParameters{ 260 Pass: pass, 261 Iterations: 5000, 262 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 263 }) 264 assert.Error(t, err, "RegisterPassphrase works only once") 265 }) 266 267 t.Run("UpdatePassphrase", func(t *testing.T) { 268 i, err := lifecycle.GetInstance("test.cozycloud.cc") 269 if !assert.NoError(t, err, "cant fetch i") { 270 return 271 } 272 assert.NotNil(t, i) 273 assert.Empty(t, i.RegisterToken) 274 assert.NotEmpty(t, i.OAuthSecret) 275 assert.Len(t, i.OAuthSecret, instance.OauthSecretLen) 276 assert.NotEmpty(t, i.SessSecret) 277 assert.Len(t, i.SessSecret, instance.SessionSecretLen) 278 279 oldHash := i.PassphraseHash 280 oldSecret := i.SessSecret 281 282 currentPass := []byte("passphrase") 283 newPass := []byte("new-passphrase") 284 badPass := []byte("not-passphrase") 285 empty := []byte("") 286 287 params := lifecycle.PassParameters{ 288 Pass: newPass, 289 Iterations: 5000, 290 } 291 err = lifecycle.UpdatePassphrase(i, empty, "", nil, params) 292 assert.Error(t, err, "UpdatePassphrase requires the current passphrase") 293 294 err = lifecycle.UpdatePassphrase(i, badPass, "", nil, params) 295 assert.Error(t, err, "UpdatePassphrase requires the current passphrase") 296 297 err = lifecycle.UpdatePassphrase(i, currentPass, "", nil, params) 298 assert.NoError(t, err) 299 300 assert.NotEmpty(t, i.PassphraseHash, "PassphraseHash has not been saved") 301 assert.NotEqual(t, oldHash, i.PassphraseHash) 302 assert.NotEqual(t, oldSecret, i.SessSecret) 303 304 settings, err := settings.Get(i) 305 assert.NoError(t, err) 306 assert.Equal(t, 5000, settings.PassphraseKdfIterations) 307 assert.Equal(t, 0, settings.PassphraseKdf) 308 }) 309 310 t.Run("RequestPassphraseReset", func(t *testing.T) { 311 in, err := lifecycle.Create(&lifecycle.Options{ 312 Domain: "test.cozycloud.cc.pass_reset", 313 Locale: "en", 314 }) 315 require.NoError(t, err) 316 317 err = lifecycle.RequestPassphraseReset(in, "") 318 require.NoError(t, err) 319 320 // token should not have been generated since we have not set a passphrase 321 // yet 322 require.Nil(t, in.PassphraseResetToken) 323 324 err = lifecycle.RegisterPassphrase(in, in.RegisterToken, lifecycle.PassParameters{ 325 Pass: []byte("MyPassphrase"), 326 Iterations: 5000, 327 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 328 }) 329 require.NoError(t, err) 330 331 err = lifecycle.RequestPassphraseReset(in, "") 332 require.NoError(t, err) 333 334 regToken := in.PassphraseResetToken 335 regTime := in.PassphraseResetTime 336 assert.NotNil(t, in.PassphraseResetToken) 337 assert.True(t, !in.PassphraseResetTime.Before(time.Now().UTC())) 338 339 err = lifecycle.RequestPassphraseReset(in, "") 340 assert.Equal(t, instance.ErrResetAlreadyRequested, err) 341 assert.EqualValues(t, regToken, in.PassphraseResetToken) 342 assert.EqualValues(t, regTime, in.PassphraseResetTime) 343 }) 344 345 t.Run("PassphraseRenew", func(t *testing.T) { 346 in, err := lifecycle.Create(&lifecycle.Options{ 347 Domain: "test.cozycloud.cc.pass_renew", 348 Locale: "en", 349 }) 350 require.NoError(t, err) 351 352 err = lifecycle.RegisterPassphrase(in, in.RegisterToken, lifecycle.PassParameters{ 353 Pass: []byte("MyPassphrase"), 354 Iterations: 5000, 355 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 356 }) 357 require.NoError(t, err) 358 359 passHash := in.PassphraseHash 360 err = lifecycle.PassphraseRenew(in, nil, lifecycle.PassParameters{ 361 Pass: []byte("NewPass"), 362 Iterations: 5000, 363 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 364 }) 365 require.Error(t, err) 366 367 err = lifecycle.RequestPassphraseReset(in, "") 368 require.NoError(t, err) 369 370 err = lifecycle.PassphraseRenew(in, []byte("token"), lifecycle.PassParameters{ 371 Pass: []byte("NewPass"), 372 Iterations: 5000, 373 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 374 }) 375 require.Error(t, err) 376 377 err = lifecycle.PassphraseRenew(in, in.PassphraseResetToken, lifecycle.PassParameters{ 378 Pass: []byte("NewPass"), 379 Iterations: 5000, 380 Key: "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 381 }) 382 require.NoError(t, err) 383 384 assert.False(t, bytes.Equal(passHash, in.PassphraseHash)) 385 }) 386 387 t.Run("InstanceNoDuplicate", func(t *testing.T) { 388 _, err := lifecycle.Create(&lifecycle.Options{ 389 Domain: "test.cozycloud.cc.duplicate", 390 Locale: "en", 391 }) 392 require.NoError(t, err) 393 394 i, err := lifecycle.Create(&lifecycle.Options{ 395 Domain: "test.cozycloud.cc.duplicate", 396 Locale: "en", 397 }) 398 if assert.Error(t, err, "Should not be possible to create duplicate") { 399 assert.Nil(t, i) 400 assert.ErrorIs(t, err, instance.ErrExists) 401 } 402 }) 403 404 t.Run("CheckPassphrase", func(t *testing.T) { 405 inst, err := lifecycle.GetInstance("test.cozycloud.cc") 406 if !assert.NoError(t, err, "cant fetch instance") { 407 return 408 } 409 410 assert.Empty(t, inst.RegisterToken, "changes have been saved in db") 411 assert.NotEmpty(t, inst.PassphraseHash, "changes have been saved in db") 412 413 err = instance.CheckPassphrase(inst, []byte("not-passphrase")) 414 assert.Error(t, err) 415 416 err = instance.CheckPassphrase(inst, []byte("new-passphrase")) 417 assert.NoError(t, err) 418 }) 419 420 t.Run("CheckTOSNotSigned", func(t *testing.T) { 421 now := time.Now() 422 i, err := lifecycle.Create(&lifecycle.Options{ 423 Domain: "tos.test.cozycloud.cc", 424 Locale: "en", 425 TOSSigned: "1.0.0-" + now.Format("20060102"), 426 }) 427 require.NoError(t, err) 428 429 notSigned, deadline := i.CheckTOSNotSignedAndDeadline() 430 assert.Empty(t, i.TOSLatest) 431 assert.False(t, notSigned) 432 assert.Equal(t, instance.TOSNone, deadline) 433 434 err = lifecycle.Patch(i, &lifecycle.Options{ 435 TOSLatest: "1.0.1-" + now.Format("20060102"), 436 }) 437 require.NoError(t, err) 438 439 notSigned, deadline = i.CheckTOSNotSignedAndDeadline() 440 assert.Empty(t, i.TOSLatest) 441 assert.False(t, notSigned) 442 assert.Equal(t, instance.TOSNone, deadline) 443 444 err = lifecycle.Patch(i, &lifecycle.Options{ 445 TOSLatest: "2.0.1-" + now.Add(40*24*time.Hour).Format("20060102"), 446 }) 447 require.NoError(t, err) 448 449 notSigned, deadline = i.CheckTOSNotSignedAndDeadline() 450 assert.NotEmpty(t, i.TOSLatest) 451 assert.True(t, notSigned) 452 assert.Equal(t, instance.TOSNone, deadline) 453 454 err = lifecycle.Patch(i, &lifecycle.Options{ 455 TOSLatest: "2.0.1-" + now.Add(10*24*time.Hour).Format("20060102"), 456 }) 457 require.NoError(t, err) 458 459 notSigned, deadline = i.CheckTOSNotSignedAndDeadline() 460 assert.NotEmpty(t, i.TOSLatest) 461 assert.True(t, notSigned) 462 assert.Equal(t, instance.TOSWarning, deadline) 463 464 err = lifecycle.Patch(i, &lifecycle.Options{ 465 TOSLatest: "2.0.1-" + now.Format("20060102"), 466 }) 467 require.NoError(t, err) 468 469 notSigned, deadline = i.CheckTOSNotSignedAndDeadline() 470 assert.NotEmpty(t, i.TOSLatest) 471 assert.True(t, notSigned) 472 assert.Equal(t, instance.TOSBlocked, deadline) 473 474 err = lifecycle.Patch(i, &lifecycle.Options{ 475 TOSSigned: "2.0.1-" + now.Format("20060102"), 476 }) 477 require.NoError(t, err) 478 479 notSigned, deadline = i.CheckTOSNotSignedAndDeadline() 480 assert.Empty(t, i.TOSLatest) 481 assert.False(t, notSigned) 482 assert.Equal(t, instance.TOSNone, deadline) 483 }) 484 485 t.Run("InstanceDestroy", func(t *testing.T) { 486 _ = lifecycle.Destroy("test.cozycloud.cc") 487 488 _, err := lifecycle.Create(&lifecycle.Options{ 489 Domain: "test.cozycloud.cc", 490 Locale: "en", 491 }) 492 require.NoError(t, err) 493 494 err = lifecycle.Destroy("test.cozycloud.cc") 495 assert.NoError(t, err) 496 497 err = lifecycle.Destroy("test.cozycloud.cc") 498 if assert.Error(t, err) { 499 assert.Equal(t, instance.ErrNotFound, err) 500 } 501 }) 502 } 503 504 func cleanInstance() { 505 _ = lifecycle.Destroy("test.cozycloud.cc") 506 _ = lifecycle.Destroy("test2.cozycloud.cc") 507 _ = lifecycle.Destroy("test3.cozycloud.cc") 508 _ = lifecycle.Destroy("test.cozycloud.cc.pass_reset") 509 _ = lifecycle.Destroy("test.cozycloud.cc.pass_renew") 510 _ = lifecycle.Destroy("test.cozycloud.cc.duplicate") 511 _ = lifecycle.Destroy("tos.test.cozycloud.cc") 512 } 513 514 func getDB(t *testing.T, domain string) prefixer.Prefixer { 515 instance, err := lifecycle.GetInstance(domain) 516 if !assert.NoError(t, err, "Should get instance %v", domain) { 517 t.FailNow() 518 } 519 return instance 520 }