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:
parent
809ac118f9
commit
9c3c1047ac
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 µ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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
Loading…
Reference in New Issue