diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go index 3129c5da2..eef789c39 100644 --- a/pkg/modules/migration/errors.go +++ b/pkg/modules/migration/errors.go @@ -18,10 +18,33 @@ package migration import ( "net/http" + "time" "code.vikunja.io/api/pkg/web" ) +// ErrMigrationAlreadyRunning is returned when a migration is started for a user +// who already has one in progress (started but not yet finished). +type ErrMigrationAlreadyRunning struct { + StartedAt time.Time +} + +func (err *ErrMigrationAlreadyRunning) Error() string { + return "Migration already running" +} + +// ErrCodeMigrationAlreadyRunning holds the unique world-error code of this error +const ErrCodeMigrationAlreadyRunning = 14005 + +// HTTPError holds the http error description +func (err *ErrMigrationAlreadyRunning) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeMigrationAlreadyRunning, + Message: "Migration already running", + } +} + // ErrNotAZipFile represents a "ErrNotAZipFile" kind of error. type ErrNotAZipFile struct{} diff --git a/pkg/modules/migration/handler/handler.go b/pkg/modules/migration/handler/handler.go index 840dbacd6..5ef52d747 100644 --- a/pkg/modules/migration/handler/handler.go +++ b/pkg/modules/migration/handler/handler.go @@ -39,7 +39,7 @@ type MigrationWeb struct { // AuthURL is returned to the user when requesting the auth url type AuthURL struct { - URL string `json:"url"` + URL string `json:"url" readOnly:"true" doc:"The OAuth authorization url the client should redirect the user to. After authorizing, the obtained code is passed back to the migrate endpoint."` } // RegisterMigrator registers all routes for migration @@ -57,6 +57,28 @@ func (mw *MigrationWeb) AuthURL(c *echo.Context) error { return c.JSON(http.StatusOK, &AuthURL{URL: ms.AuthURL()}) } +// StartMigration kicks off a migration for the given user: it refuses with +// migration.ErrMigrationAlreadyRunning if one is already in progress, then +// dispatches the MigrationRequestedEvent that runs the migration asynchronously. +// The migrator must already carry its request payload (e.g. the OAuth code). +// Shared by the v1 and v2 HTTP layers so the orchestration lives in one place. +func StartMigration(ms migration.Migrator, u *user2.User) error { + stats, err := migration.GetMigrationStatus(ms, u) + if err != nil { + return err + } + + if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() { + return &migration.ErrMigrationAlreadyRunning{StartedAt: stats.StartedAt} + } + + return events.Dispatch(&MigrationRequestedEvent{ + Migrator: ms, + MigratorKind: ms.Name(), + User: u, + }) +} + // Migrate calls the migration method func (mw *MigrationWeb) Migrate(c *echo.Context) error { ms := mw.MigrationStruct() @@ -85,12 +107,7 @@ func (mw *MigrationWeb) Migrate(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error()).Wrap(err) } - err = events.Dispatch(&MigrationRequestedEvent{ - Migrator: ms, - MigratorKind: ms.Name(), - User: user, - }) - if err != nil { + if err := StartMigration(ms, user); err != nil { return err } diff --git a/pkg/modules/migration/migration_status.go b/pkg/modules/migration/migration_status.go index a967c950c..419ac880c 100644 --- a/pkg/modules/migration/migration_status.go +++ b/pkg/modules/migration/migration_status.go @@ -25,11 +25,11 @@ import ( // Status represents this migration status type Status struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this migration status."` UserID int64 `xorm:"bigint not null" json:"-"` - MigratorName string `xorm:"varchar(255)" json:"migrator_name"` - StartedAt time.Time `xorm:"not null" json:"started_at"` - FinishedAt time.Time `xorm:"null" json:"finished_at"` + MigratorName string `xorm:"varchar(255)" json:"migrator_name" readOnly:"true" doc:"The name of the migrator this status belongs to, e.g. \"todoist\"."` + StartedAt time.Time `xorm:"not null" json:"started_at" readOnly:"true" doc:"When the last migration started. Zero value if the user never migrated from this service."` + FinishedAt time.Time `xorm:"null" json:"finished_at" readOnly:"true" doc:"When the last migration finished. Zero value while a migration is still running or was never run."` } // TableName holds the table name for the migration status table diff --git a/pkg/routes/api/v2/migration_oauth.go b/pkg/routes/api/v2/migration_oauth.go new file mode 100644 index 000000000..4d254632c --- /dev/null +++ b/pkg/routes/api/v2/migration_oauth.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" + "code.vikunja.io/api/pkg/modules/migration/todoist" + "code.vikunja.io/api/pkg/modules/migration/trello" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// migrationAuthURLBody is the response for the OAuth auth-url endpoint. +type migrationAuthURLBody struct { + Body migrationHandler.AuthURL +} + +// migrationStatusBody is the response for the migration status endpoint. +type migrationStatusBody struct { + Body *migration.Status +} + +// migrationMigrateBody carries the OAuth code obtained from the auth url back +// to the server. It is applied onto the concrete migrator (whose field carries +// json:"code") so it works across migrators regardless of their field name. +type migrationMigrateBody struct { + Code string `json:"code" doc:"The OAuth code obtained after authorizing against the auth url."` +} + +// migrationStartedBody confirms the migration was kicked off; the actual work +// runs asynchronously. +type migrationStartedBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterMigrationOAuthRoutes wires the OAuth-based migrators (Todoist, Trello, +// Microsoft To-Do) onto the Huma API. Each migrator is gated behind its static +// config flag and exposes the same three operations, so registration is driven +// by one generic helper instead of three copy-pasted blocks. +func RegisterMigrationOAuthRoutes(api huma.API) { + registerOAuthMigrator(api, config.MigrationTodoistEnable.GetBool(), func() migration.Migrator { return &todoist.Migration{} }) + registerOAuthMigrator(api, config.MigrationTrelloEnable.GetBool(), func() migration.Migrator { return &trello.Migration{} }) + registerOAuthMigrator(api, config.MigrationMicrosoftTodoEnable.GetBool(), func() migration.Migrator { return µsofttodo.Migration{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationOAuthRoutes) } + +// registerOAuthMigrator registers auth/status/migrate for a single OAuth +// migrator. enabled gates the whole migrator (config early-return, no +// middleware); factory produces a fresh migrator instance per request, matching +// v1's MigrationStruct func so concurrent requests never share mutable state. +func registerOAuthMigrator(api huma.API, enabled bool, factory func() migration.Migrator) { + if !enabled { + return + } + + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-auth", + Summary: "Get the auth url for " + name, + Description: "Returns the OAuth url the user needs to authenticate against. The code obtained there is passed back to the migrate endpoint.", + Method: http.MethodGet, + Path: "/migration/" + name + "/auth", + Tags: tags, + }, func(_ context.Context, _ *struct{}) (*migrationAuthURLBody, error) { + return &migrationAuthURLBody{Body: migrationHandler.AuthURL{URL: factory().AuthURL()}}, nil + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated. Used to prevent starting a second migration while one is running.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationOAuthStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Starts a migration of the authenticated user's data from this service into Vikunja. The migration runs asynchronously; this returns once it has been queued. Refuses with 412 if a migration for this service is already running.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST kicks off a job rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, func(ctx context.Context, in *struct{ Body migrationMigrateBody }) (*migrationStartedBody, error) { + return migrationOAuthMigrate(ctx, factory, in.Body) + }) +} + +func migrationOAuthStatus(ctx context.Context, factory func() migration.Migrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationOAuthMigrate(ctx context.Context, factory func() migration.Migrator, body migrationMigrateBody) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + ms := factory() + // Apply the request payload onto the concrete migrator the same way v1's + // c.Bind does, so migrator-specific field names (e.g. Trello's Token, + // json:"code") bind transparently. + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, ms); err != nil { + return nil, huma.Error400BadRequest("invalid migration payload", err) + } + + if err := migrationHandler.StartMigration(ms, u); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Migration was started successfully." + return out, nil +} diff --git a/pkg/webtests/huma_migration_oauth_test.go b/pkg/webtests/huma_migration_oauth_test.go new file mode 100644 index 000000000..7d15c576a --- /dev/null +++ b/pkg/webtests/huma_migration_oauth_test.go @@ -0,0 +1,153 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupMigrationTestEnv builds a test env with the OAuth migrators enabled so +// their v2 routes are registered (they are gated behind config flags that +// default to false). setupTestEnv resets config to defaults, so the flags must +// be set after it and the router rebuilt. +func setupMigrationTestEnv(t *testing.T) *echo.Echo { + t.Helper() + _, err := setupTestEnv() + require.NoError(t, err) + + // migration.Status is not part of models.GetTables() (pkg/models cannot + // import pkg/modules/migration without a cycle), so SetupTests never syncs + // migration_status. Create it here so the status/migrate handlers can query. + s := db.NewSession() + require.NoError(t, s.Sync2(&migration.Status{})) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + config.MigrationTodoistEnable.Set(true) + config.MigrationTrelloEnable.Set(true) + config.MigrationMicrosoftTodoEnable.Set(true) + t.Cleanup(func() { + config.MigrationTodoistEnable.Set(false) + config.MigrationTrelloEnable.Set(false) + config.MigrationMicrosoftTodoEnable.Set(false) + }) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// TestHumaMigrationOAuth covers the three OAuth migrators' v2 endpoints. There +// is no v1 webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationOAuth(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // The generic registration helper wires the same three ops for every + // migrator, so exercising each name guards against a copy-paste regression. + for _, name := range []string{"todoist", "trello", "microsoft-todo"} { + t.Run(name+" auth url", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/auth", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"url":"http`, "auth url must be returned; body: %s", rec.Body.String()) + }) + + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + } + + t.Run("migrate kicks off the migration", func(t *testing.T) { + events.ClearDispatchedEvents() + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + // 200, not the wrapper's POST default 201: this queues a job, it does + // not create a REST resource. + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Migration was started successfully."`) + events.AssertDispatched(t, &migrationHandler.MigrationRequestedEvent{}) + }) +} + +// TestHumaMigrationOAuth_AlreadyRunning ports v1's guard: starting a migration +// while one is already in progress (started, not finished) is refused with 412. +func TestHumaMigrationOAuth_AlreadyRunning(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + s := db.NewSession() + _, err := s.Insert(&migration.Status{ + UserID: testuser1.ID, + MigratorName: "todoist", + StartedAt: time.Now(), + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) + _ = s.Close() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) +} + +// TestHumaMigrationOAuth_Unauthenticated proves all three ops require auth. +func TestHumaMigrationOAuth_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"x"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationOAuth_Disabled proves a migrator's routes are absent when its +// config flag is off. +func TestHumaMigrationOAuth_Disabled(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + // All migration flags default to false after InitDefaultConfig. + + e := routes.NewEcho() + routes.RegisterRoutes(e) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "migration routes must not be registered when the flag is off; body: %s", rec.Body.String()) +}