feat(api/v2): port OAuth migrators (Todoist, Trello, Microsoft To-Do)

Add /api/v2 auth/status/migrate endpoints for the three OAuth-based
migrators. One generic helper registers all three ops per migrator
behind its static config gate, so there's no copy-pasted block per
migrator.

The migrate kick-off orchestration (already-running guard + event
dispatch) is extracted into migrationHandler.StartMigration so v1 and
v2 share it; v1's wire output is unchanged. The guard now surfaces as a
typed migration.ErrMigrationAlreadyRunning (412) so v2 can translate it
through the standard error bridge.
This commit is contained in:
kolaente 2026-06-11 09:29:34 +02:00 committed by kolaente
parent 809ac118f9
commit 9c3c1047ac
5 changed files with 371 additions and 11 deletions

View File

@ -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{}

View File

@ -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
}

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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 &microsofttodo.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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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())
}