feat(api/v2): add user data export endpoints
Port POST /user/export/request, POST /user/export/download (zip stream) and GET /user/export (status) to v2. Extract the export-file loader and status builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2 download streams via the shared WriteFileDownload writer; local users confirm with their password, external-provider users are passed through.
This commit is contained in:
parent
ac5e94252b
commit
8c72e83a4d
|
|
@ -2624,3 +2624,32 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
|
||||||
Message: "A time entry's end time cannot be before its start time.",
|
Message: "A time entry's end time cannot be before its start time.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// User export errors
|
||||||
|
// =================
|
||||||
|
|
||||||
|
// ErrUserDataExportDoesNotExist represents an error where a user has no ready data export to download.
|
||||||
|
type ErrUserDataExportDoesNotExist struct{}
|
||||||
|
|
||||||
|
// IsErrUserDataExportDoesNotExist checks if an error is ErrUserDataExportDoesNotExist.
|
||||||
|
func IsErrUserDataExportDoesNotExist(err error) bool {
|
||||||
|
_, ok := err.(ErrUserDataExportDoesNotExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrUserDataExportDoesNotExist) Error() string {
|
||||||
|
return "No user data export found"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCodeUserDataExportDoesNotExist holds the unique world-error code of this error
|
||||||
|
const ErrCodeUserDataExportDoesNotExist = 19001
|
||||||
|
|
||||||
|
// HTTPError holds the http error description
|
||||||
|
func (err ErrUserDataExportDoesNotExist) HTTPError() web.HTTPError {
|
||||||
|
return web.HTTPError{
|
||||||
|
HTTPCode: http.StatusNotFound,
|
||||||
|
Code: ErrCodeUserDataExportDoesNotExist,
|
||||||
|
Message: "No user data export found.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,59 @@ func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (er
|
||||||
return utils.WriteFilesToZip(backgroundFiles, wr)
|
return utils.WriteFilesToZip(backgroundFiles, wr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDataExportFile loads the user's ready data export with its bytes open for
|
||||||
|
// reading. It returns ErrUserDataExportDoesNotExist when the user never requested an
|
||||||
|
// export or the underlying file is gone. The caller must close the returned reader.
|
||||||
|
func GetUserDataExportFile(u *user.User) (*files.File, error) {
|
||||||
|
if u.ExportFileID == 0 {
|
||||||
|
return nil, ErrUserDataExportDoesNotExist{}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportFile := &files.File{ID: u.ExportFileID}
|
||||||
|
if err := exportFile.LoadFileMetaByID(); err != nil {
|
||||||
|
if files.IsErrFileDoesNotExist(err) {
|
||||||
|
return nil, ErrUserDataExportDoesNotExist{}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := exportFile.LoadFileByID(); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrUserDataExportDoesNotExist{}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserDataExportStatus returns metadata about the user's current data export, or
|
||||||
|
// nil when none exists. The expiry mirrors the cleanup cron's 7-day retention.
|
||||||
|
func GetUserDataExportStatus(u *user.User) (*UserExportStatus, error) {
|
||||||
|
if u.ExportFileID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exportFile := &files.File{ID: u.ExportFileID}
|
||||||
|
if err := exportFile.LoadFileMetaByID(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserExportStatus{
|
||||||
|
ID: exportFile.ID,
|
||||||
|
Size: exportFile.Size,
|
||||||
|
Created: exportFile.Created,
|
||||||
|
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExportStatus is the metadata returned for a user's current data export.
|
||||||
|
type UserExportStatus struct {
|
||||||
|
ID int64 `json:"id" readOnly:"true" doc:"The id of the export file."`
|
||||||
|
Size uint64 `json:"size" readOnly:"true" doc:"The size of the export file in bytes."`
|
||||||
|
Created time.Time `json:"created" readOnly:"true" doc:"When the export was created."`
|
||||||
|
Expires time.Time `json:"expires" readOnly:"true" doc:"When the export will be automatically deleted (7 days after creation)."`
|
||||||
|
}
|
||||||
|
|
||||||
func RegisterOldExportCleanupCron() {
|
func RegisterOldExportCleanupCron() {
|
||||||
const logPrefix = "[User Export Cleanup Cron] "
|
const logPrefix = "[User Export Cleanup Cron] "
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,11 @@ package v1
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/config"
|
"code.vikunja.io/api/pkg/config"
|
||||||
"code.vikunja.io/api/pkg/db"
|
"code.vikunja.io/api/pkg/db"
|
||||||
"code.vikunja.io/api/pkg/events"
|
"code.vikunja.io/api/pkg/events"
|
||||||
"code.vikunja.io/api/pkg/files"
|
|
||||||
"code.vikunja.io/api/pkg/models"
|
"code.vikunja.io/api/pkg/models"
|
||||||
"code.vikunja.io/api/pkg/user"
|
"code.vikunja.io/api/pkg/user"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
|
@ -127,25 +124,10 @@ func DownloadUserDataExport(c *echo.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has an export file
|
exportFile, err := models.GetUserDataExportFile(u)
|
||||||
exportNotFoundError := echo.NewHTTPError(http.StatusNotFound, "No user data export found.")
|
|
||||||
if u.ExportFileID == 0 {
|
|
||||||
return exportNotFoundError
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download
|
|
||||||
exportFile := &files.File{ID: u.ExportFileID}
|
|
||||||
err = exportFile.LoadFileMetaByID()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if files.IsErrFileDoesNotExist(err) {
|
if models.IsErrUserDataExportDoesNotExist(err) {
|
||||||
return exportNotFoundError
|
return echo.NewHTTPError(http.StatusNotFound, "No user data export found.")
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = exportFile.LoadFileByID()
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return exportNotFoundError
|
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -163,19 +145,12 @@ func DownloadUserDataExport(c *echo.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserExportStatus struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Size uint64 `json:"size"`
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
Expires time.Time `json:"expires"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserExportStatus returns metadata about the current user export if it exists
|
// GetUserExportStatus returns metadata about the current user export if it exists
|
||||||
// @Summary Get current user data export
|
// @Summary Get current user data export
|
||||||
// @tags user
|
// @tags user
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security JWTKeyAuth
|
// @Security JWTKeyAuth
|
||||||
// @Success 200 {object} v1.UserExportStatus
|
// @Success 200 {object} models.UserExportStatus
|
||||||
// @Router /user/export [get]
|
// @Router /user/export [get]
|
||||||
func GetUserExportStatus(c *echo.Context) error {
|
func GetUserExportStatus(c *echo.Context) error {
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
|
@ -186,20 +161,12 @@ func GetUserExportStatus(c *echo.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.ExportFileID == 0 {
|
status, err := models.GetUserDataExportStatus(u)
|
||||||
return c.JSON(http.StatusOK, struct{}{})
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
exportFile := &files.File{ID: u.ExportFileID}
|
|
||||||
if err := exportFile.LoadFileMetaByID(); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if status == nil {
|
||||||
status := UserExportStatus{
|
return c.JSON(http.StatusOK, struct{}{})
|
||||||
ID: exportFile.ID,
|
|
||||||
Size: exportFile.Size,
|
|
||||||
Created: exportFile.Created,
|
|
||||||
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, status)
|
return c.JSON(http.StatusOK, status)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
// 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"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/db"
|
||||||
|
"code.vikunja.io/api/pkg/events"
|
||||||
|
"code.vikunja.io/api/pkg/models"
|
||||||
|
"code.vikunja.io/api/pkg/modules/humaecho5"
|
||||||
|
"code.vikunja.io/api/pkg/user"
|
||||||
|
webfiles "code.vikunja.io/api/pkg/web/files"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userExportPasswordBody struct {
|
||||||
|
Body struct {
|
||||||
|
Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type userExportStatusBody struct {
|
||||||
|
Body *models.UserExportStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterUserExportRoutes(api huma.API) {
|
||||||
|
tags := []string{"user"}
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "user-export-request",
|
||||||
|
Summary: "Request a data export",
|
||||||
|
Description: "Starts building a full export of the authenticated user's data. Local users must confirm with their password. The export runs in the background; an email is sent when it is ready to download.",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/user/export/request",
|
||||||
|
Tags: tags,
|
||||||
|
DefaultStatus: http.StatusOK,
|
||||||
|
}, userExportRequest)
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "user-export-download",
|
||||||
|
Summary: "Download the data export",
|
||||||
|
Description: "Streams the authenticated user's prepared data export as a zip file. Local users must confirm with their password. Fails with 404 if no export has been prepared. A POST (not GET) because the password is sent in the body.",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/user/export/download",
|
||||||
|
Tags: tags,
|
||||||
|
// Spell out the binary response; the default would be modeled as JSON.
|
||||||
|
Responses: map[string]*huma.Response{
|
||||||
|
"200": {
|
||||||
|
Description: "The data export as a zip file.",
|
||||||
|
Content: map[string]*huma.MediaType{
|
||||||
|
"application/zip": {
|
||||||
|
Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, userExportDownload)
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "user-export-status",
|
||||||
|
Summary: "Get the current data export",
|
||||||
|
Description: "Returns metadata about the authenticated user's current data export (id, size, creation and expiry time), or null if none has been prepared.",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/user/export",
|
||||||
|
Tags: tags,
|
||||||
|
}, userExportStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { AddRouteRegistrar(RegisterUserExportRoutes) }
|
||||||
|
|
||||||
|
// confirmExportPassword resolves the full DB user and, for local accounts, verifies
|
||||||
|
// the supplied password — mirroring v1's checkExportRequest. External-provider users
|
||||||
|
// cannot supply a password and are passed through, as in v1.
|
||||||
|
func confirmExportPassword(ctx context.Context, s *xorm.Session, password string) (*user.User, error) {
|
||||||
|
u, err := authUserFromCtx(ctx, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.IsLocalUser() {
|
||||||
|
if err := user.CheckUserPassword(u, password); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userExportRequest(ctx context.Context, in *userExportPasswordBody) (*messageBody, error) {
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
u, err := confirmExportPassword(ctx, s, in.Body.Password)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
events.DispatchOnCommit(s, &models.UserDataExportRequestedEvent{User: u})
|
||||||
|
|
||||||
|
if err := s.Commit(); err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
events.CleanupPending(s)
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
events.DispatchPending(ctx, s)
|
||||||
|
|
||||||
|
out := &messageBody{}
|
||||||
|
out.Body.Message = "Successfully requested data export. We will send you an email when it's ready."
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma.StreamResponse, error) {
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
u, err := confirmExportPassword(ctx, s, in.Body.Password)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
exportFile, err := models.GetUserDataExportFile(u)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file reader comes from object storage, not the DB session, so it stays
|
||||||
|
// valid after the commit; the StreamResponse callback runs after this returns.
|
||||||
|
if err := s.Commit(); err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
||||||
|
defer func() { _ = exportFile.File.Close() }()
|
||||||
|
c := humaecho5.Unwrap(hctx)
|
||||||
|
webfiles.WriteFileDownload((*c).Response(), (*c).Request(), exportFile)
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userExportStatus(ctx context.Context, _ *struct{}) (*userExportStatusBody, error) {
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
u, err := authUserFromCtx(ctx, s)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := models.GetUserDataExportStatus(u)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
if err := s.Commit(); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userExportStatusBody{Body: status}, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
// 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 (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/files"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHumaUserExport covers the v2 data-export endpoints. Fixture topology
|
||||||
|
// (pkg/db/fixtures/users.yml + files.yml):
|
||||||
|
// - user1: local, password 12345678, export_file_id 1 (file row exists, no bytes).
|
||||||
|
// - user14: non-local (OIDC), no password to confirm.
|
||||||
|
// - user15: local, no export.
|
||||||
|
func TestHumaUserExport(t *testing.T) {
|
||||||
|
t.Run("Request with the correct password", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
|
||||||
|
`{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
assert.Contains(t, rec.Body.String(), "requested data export")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Request with a wrong password is refused", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
|
||||||
|
`{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "")
|
||||||
|
require.NotEqual(t, http.StatusOK, rec.Code,
|
||||||
|
"a wrong password must not start an export; body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Request as a non-local user skips the password", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
|
||||||
|
`{}`, humaTokenFor(t, &testuser14), "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Download streams the export bytes", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
// user1's export points at file 1; setupTestEnv resets storage, so write
|
||||||
|
// real bytes for it (size matches the fixture's declared 100 bytes).
|
||||||
|
content := bytes.Repeat([]byte("v"), 100)
|
||||||
|
require.NoError(t, (&files.File{ID: 1, Size: uint64(len(content))}).Save(bytes.NewReader(content)))
|
||||||
|
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
|
||||||
|
`{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match")
|
||||||
|
assert.Contains(t, rec.Header().Get("Content-Disposition"), "test")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Download with a wrong password is refused", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
|
||||||
|
`{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "")
|
||||||
|
require.NotEqual(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Download without an export returns 404", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
|
||||||
|
`{"password":"12345678"}`, humaTokenFor(t, &testuser15), "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Download with a missing physical file returns 404", func(t *testing.T) {
|
||||||
|
// user1 has export_file_id 1, but setupTestEnv leaves its bytes unwritten.
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
|
||||||
|
`{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Status returns the export metadata", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser1), "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
assert.Contains(t, rec.Body.String(), `"id":1`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"expires"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Status without an export returns null", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser15), "")
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
assert.JSONEq(t, "null", rec.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unauthenticated request is rejected", func(t *testing.T) {
|
||||||
|
e, err := setupTestEnv()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", "", "")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue