// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package migratest import ( "context" "encoding/json" "os" "path/filepath" "regexp" "strings" "testing" "github.com/bradleyjkemp/cupaloy/v2" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/persistence/sql" gomigrations "github.com/ory/kratos/persistence/sql/migrations/go" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/strategy/code" "github.com/ory/kratos/selfservice/strategy/link" "github.com/ory/kratos/session" "github.com/ory/kratos/x" "github.com/ory/pop/v6" "github.com/ory/x/configx" "github.com/ory/x/dbal" "github.com/ory/x/fsx" "github.com/ory/x/logrusx" "github.com/ory/x/migratest" "github.com/ory/x/networkx" "github.com/ory/x/pagination/keysetpagination" "github.com/ory/x/popx" "github.com/ory/x/sqlcon" "github.com/ory/x/sqlcon/dockertest" ) func snapshotFor(paths ...string) *cupaloy.Config { return cupaloy.New( cupaloy.CreateNewAutomatically(true), cupaloy.FailOnUpdate(true), cupaloy.SnapshotFileExtension(".json"), cupaloy.SnapshotSubdirectory(filepath.Join(paths...)), ) } func CompareWithFixture(t *testing.T, actual interface{}, prefix string, id string) { s := snapshotFor("fixtures", prefix) actualJSON, err := json.MarshalIndent(actual, "", " ") require.NoError(t, err) err = s.SnapshotWithName(id, actualJSON) assert.NoErrorf(t, err, "actual = %s", string(actualJSON)) } func TestMigrations_SQLite(t *testing.T) { t.Parallel() sqlite, err := pop.NewConnection(&pop.ConnectionDetails{ URL: dbal.NewSQLiteTestDatabase(t), }) require.NoError(t, err) require.NoError(t, sqlite.Open()) testDatabase(t, "sqlite", sqlite) } func TestMigrations_Postgres(t *testing.T) { if testing.Short() { t.Skip("skipping testing in short mode") } t.Parallel() testDatabase(t, "postgres", dockertest.ConnectPop(t, dockertest.RunTestPostgreSQLWithVersion(t, "16"))) } func TestMigrations_Mysql(t *testing.T) { if testing.Short() { t.Skip("skipping testing in short mode") } t.Parallel() testDatabase(t, "mysql", dockertest.ConnectPop(t, dockertest.RunTestMySQLWithVersion(t, "8.4"))) } func TestMigrations_Cockroach(t *testing.T) { if testing.Short() { t.Skip("skipping testing in short mode") } t.Parallel() testDatabase(t, "cockroach", dockertest.ConnectPop(t, dockertest.RunTestCockroachDBWithVersion(t, "latest-v25.4"))) } func testDatabase(t *testing.T, db string, c *pop.Connection) { ctx := context.Background() l := logrusx.New("", "", logrusx.ForceLevel(logrus.DebugLevel)) url := c.URL() // workaround for https://github.com/ory/pop/issues/538 switch db { case "mysql": url = "mysql://" + url case "sqlite": url = "sqlite3://" + url case "cockroach": url = "cockroach" + strings.TrimPrefix(url, "postgres") } if db != "sqlite" { dbName := "testdb" + strings.ReplaceAll(x.NewUUID().String(), "-", "") require.NoError(t, c.RawQuery("CREATE DATABASE "+dbName).Exec()) url = regexp.MustCompile(`/[a-z0-9]+\?`).ReplaceAllString(url, "/"+dbName+"?") } t.Logf("URL: %s", url) var err error c, err = pop.NewConnection(&pop.ConnectionDetails{URL: url}) require.NoError(t, err) require.NoError(t, c.Open()) tm, err := popx.NewMigrationBox( fsx.Merge(sql.Migrations, networkx.Migrations), c, l, popx.WithGoMigrations(gomigrations.All), popx.WithTestdata(t, os.DirFS("./testdata")), popx.WithoutGoldenDatabase(), ) require.NoError(t, err) err = tm.Up(ctx) // for easy breakpointing // _ = tm.DumpMigrationSchema(ctx) // uncomment to get the current state of the database after migrations have run if !assert.NoError(t, err) { assert.NoError(t, tm.DumpMigrationSchema(ctx)) t.FailNow() } opts := driver.WithConfigOptions( configx.WithValues(map[string]any{ config.ViperKeyDSN: url, config.ViperKeyPublicBaseURL: "https://www.ory.sh/", config.ViperKeyIdentitySchemas: config.Schemas{{ID: "default", URL: "file://stub/default.schema.json"}}, config.ViperKeySecretsDefault: []string{"secret"}, }), configx.SkipValidation(), ) t.Run("suite=fixtures", func(t *testing.T) { t.Run("case=identity", func(t *testing.T) { t.Parallel() d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) ids, _, err := d.PrivilegedIdentityPool().ListIdentities(context.Background(), identity.ListIdentityParameters{Expand: identity.ExpandEverything, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(1000)}}) require.NoError(t, err) require.NotEmpty(t, ids) var found []string for y, id := range ids { found = append(found, id.ID.String()) actual := &ids[y] for _, a := range actual.VerifiableAddresses { CompareWithFixture(t, a, "identity_verification_address", a.ID.String()) } for _, a := range actual.RecoveryAddresses { CompareWithFixture(t, a, "identity_recovery_address", a.ID.String()) } CompareWithFixture(t, identity.WithCredentialsAndAdminMetadataInJSON(*actual), "identity", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "identity"), found) }) t.Run("case=identity_get", func(t *testing.T) { t.Parallel() d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) ids, _, err := d.PrivilegedIdentityPool().ListIdentities(context.Background(), identity.ListIdentityParameters{Expand: identity.ExpandNothing, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(1000)}}) require.NoError(t, err) require.NotEmpty(t, ids) var found []string for _, id := range ids { actual, err := d.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) found = append(found, actual.ID.String()) CompareWithFixture(t, identity.WithCredentialsAndAdminMetadataInJSON(*actual), "identity", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "identity"), found) }) t.Run("case=verification_token", func(t *testing.T) { t.Parallel() var ids []link.VerificationToken require.NoError(t, c.All(&ids)) require.NotEmpty(t, ids) for _, id := range ids { CompareWithFixture(t, id, "verification_token", id.ID.String()) } }) t.Run("case=session", func(t *testing.T) { t.Parallel() var ids []session.Session require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.SessionPersister().GetSession(context.Background(), id.ID, session.ExpandEverything) require.NoErrorf(t, err, "Trying to get session: %s", id.ID) require.NotEmpty(t, actual.LogoutToken, "check if migrations have generated a logout token for existing sessions") CompareWithFixture(t, actual, "session", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "session"), found) }) t.Run("case=login", func(t *testing.T) { t.Parallel() var ids []login.Flow require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.LoginFlowPersister().GetLoginFlow(context.Background(), id.ID) require.NoError(t, err) CompareWithFixture(t, actual, "login_flow", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "login_flow"), found) }) t.Run("case=registration", func(t *testing.T) { t.Parallel() var ids []registration.Flow require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), id.ID) require.NoError(t, err) CompareWithFixture(t, actual, "registration_flow", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "registration_flow"), found) }) t.Run("case=settings_flow", func(t *testing.T) { t.Parallel() var ids []settings.Flow require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.SettingsFlowPersister().GetSettingsFlow(context.Background(), id.ID) require.NoError(t, err, id.ID.String()) CompareWithFixture(t, actual, "settings_flow", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "settings_flow"), found) }) t.Run("case=recovery_flow", func(t *testing.T) { t.Parallel() var ids []recovery.Flow require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), id.ID) require.NoError(t, err) CompareWithFixture(t, actual, "recovery_flow", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "recovery_flow"), found) }) t.Run("case=verification_flow", func(t *testing.T) { t.Parallel() var ids []verification.Flow require.NoError(t, c.Select("id").All(&ids)) require.NotEmpty(t, ids) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) var found []string for _, id := range ids { found = append(found, id.ID.String()) actual, err := d.VerificationFlowPersister().GetVerificationFlow(context.Background(), id.ID) require.NoError(t, err) CompareWithFixture(t, actual, "verification_flow", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "verification_flow"), found) }) t.Run("case=recovery_token", func(t *testing.T) { t.Parallel() var ids []link.RecoveryToken require.NoError(t, c.All(&ids)) require.NotEmpty(t, ids) var found []string for _, id := range ids { found = append(found, id.ID.String()) CompareWithFixture(t, id, "recovery_token", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "recovery_token"), found) }) t.Run("case=recovery_code", func(t *testing.T) { t.Parallel() var ids []code.RecoveryCode require.NoError(t, c.All(&ids)) require.NotEmpty(t, ids) var found []string for _, id := range ids { found = append(found, id.ID.String()) CompareWithFixture(t, id, "recovery_code", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "recovery_code"), found) }) t.Run("case=registration_code", func(t *testing.T) { t.Parallel() var ids []code.RegistrationCode require.NoError(t, c.All(&ids)) require.NotEmpty(t, ids) var found []string for _, id := range ids { found = append(found, id.ID.String()) CompareWithFixture(t, id, "registration_code", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "registration_code"), found) }) t.Run("case=login_code", func(t *testing.T) { t.Parallel() var ids []code.LoginCode require.NoError(t, c.All(&ids)) require.NotEmpty(t, ids) var found []string for _, id := range ids { found = append(found, id.ID.String()) CompareWithFixture(t, id, "login_code", id.ID.String()) } migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "login_code"), found) }) }) t.Run("suite=constraints", func(t *testing.T) { t.Cleanup(func() { // clean up test duplicates - remove identity_credential_identifiers 10985ed1-5b6e-4012-ac10-03d87df65618 - otherwise down migration later fails. require.NoError(t, c.RawQuery("DELETE FROM identity_credential_identifiers WHERE identifier = '10985ed1-5b6e-4012-ac10-03d87df65618'").Exec()) }) d, err := driver.New( context.Background(), os.Stderr, opts, ) require.NoError(t, err) sr, err := d.SettingsFlowPersister().GetSettingsFlow(context.Background(), x.ParseUUID("a79bfcf1-68ae-49de-8b23-4f96921b8341")) require.NoError(t, err) require.NoError(t, d.PrivilegedIdentityPool().DeleteIdentity(context.Background(), sr.IdentityID)) _, err = d.SettingsFlowPersister().GetSettingsFlow(context.Background(), x.ParseUUID("a79bfcf1-68ae-49de-8b23-4f96921b8341")) require.Error(t, err) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) t.Run("suite=down", func(t *testing.T) { err = tm.Down(ctx, -1) // for easy breakpointing if !assert.NoError(t, err) { assert.NoError(t, tm.DumpMigrationSchema(ctx)) t.FailNow() } }) }