feat(api): CRUD files/folders + upload multipart réels (scope owner, soft-delete, conteurs d'erreurs)

- repository.Resources (insert/list/get/soft-delete scoping owner_id, UNIQUE(parent_id,name) → NAME_CONFLICT) + Devices.Upsert au register
- service.Resources : DTOs {id,name,size,mimeType,folderId,createdAt,updatedAt}, upload → UPLOAD_DIR/<device>/<id>.<ext> (nettoyage si métadonnée échoue)
- handlers : files list/get/delete/upload + folders racines ; routes consolidées dans handlers.RegisterRoutes
- dbtest package : test DB jetée par repo (open+reset+migrate, skip si PG down) ; tests repository + handlers end-to-end (register→token→CRUD, scoping cross-device, FILE_TOO_LARGE, NAME_CONFLICT)
- docs: /devices persiste le device, layout UPLOAD_DIR par device, codes NAME_CONFLICT/SERVICE_UNAVAILABLE
This commit is contained in:
m
2026-09-10 07:44:55 +02:00
parent 484498d989
commit 14c058fddb
16 changed files with 1153 additions and 49 deletions
+26 -3
View File
@@ -8,9 +8,15 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/dbtest"
"github.com/vaultdrop/backend/pkg/auth"
"github.com/vaultdrop/backend/repository"
"github.com/vaultdrop/backend/service"
)
const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable"
func newTestRouterForAuth() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
@@ -21,10 +27,20 @@ func newTestRouterForAuth() *gin.Engine {
return r
}
func setTestStore(t *testing.T) *repository.Repository {
t.Helper()
conn := dbtest.OpenTestDatabase(t, authTestURL)
repo := repository.NewRepository(conn)
Store = service.NewResources(repo, t.TempDir(), 1_048_576)
t.Cleanup(func() { Store = nil })
return repo
}
func TestDevicesRegisterValid(t *testing.T) {
m, _ := auth.NewManager("test-secret")
Auth = m
defer func() { Auth = nil }()
repo := setTestStore(t)
deviceID := "0123456789abcdef0123456789abcdef"
body := `{"deviceId":"` + deviceID + `"}`
@@ -54,6 +70,12 @@ func TestDevicesRegisterValid(t *testing.T) {
if err != nil || verified != deviceID {
t.Errorf("token invalid: %v", err)
}
// Le device est bien persisté (requis par les FK resources.owner_id).
exists, err := repo.Devices.Exists(deviceID)
if err != nil || !exists {
t.Errorf("device non persisté: exists=%v err=%v", exists, err)
}
}
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
@@ -98,8 +120,9 @@ func TestRequireDeviceAcceptsValidToken(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+signed)
newTestRouterForAuth().ServeHTTP(rec, req)
// FilesList is still a 501 stubthe point is it got past the middleware.
if rec.Code != 501 {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
// Sans Store le handler répond SERVICE_UNAVAILABLE (503)le point est
// que la requête a dépassé le middleware (jamais 401).
if rec.Code == 401 {
t.Fatalf("middleware a rejeté un token valide: %s", rec.Body.String())
}
}
+9
View File
@@ -25,6 +25,15 @@ func DevicesRegister(c *gin.Context) {
return
}
if Store == nil || Store.Repository == nil {
api.Error(c, 503, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
if err := Store.Repository.Devices.Upsert(req.DeviceID); err != nil {
api.Error(c, 500, "INTERNAL", "could not persist device")
return
}
token, err := Auth.Issue(req.DeviceID)
if err != nil {
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
+118 -4
View File
@@ -1,12 +1,126 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
)
func FilesList(c *gin.Context) { api.NotImplemented(c) }
func FilesGet(c *gin.Context) { api.NotImplemented(c) }
func FilesDelete(c *gin.Context) { api.NotImplemented(c) }
func intParam(value string, fallback int) int {
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return fallback
}
return parsed
}
func FilesList(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
page := intParam(c.Query("page"), 1)
pageSize := intParam(c.Query("pageSize"), 50)
if pageSize > 200 {
pageSize = 200
}
files, total, err := Store.ListFiles(deviceID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
if err != nil {
writeError(c, err)
return
}
api.OKList(c, files, page, pageSize, total)
}
func FilesGet(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
id := c.Param("id")
if !deviceIDPattern.MatchString(id) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
return
}
file, err := Store.GetFile(deviceID, id)
if err != nil {
writeError(c, err)
return
}
api.OK(c, file)
}
func FilesDelete(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
id := c.Param("id")
if !deviceIDPattern.MatchString(id) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
return
}
deletedID, err := Store.DeleteFile(deviceID, id)
if err != nil {
writeError(c, err)
return
}
api.OK(c, gin.H{"id": deletedID})
}
func FilesUpload(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
folderID := c.PostForm("folderId")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
file, err := c.FormFile("file")
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
api.Error(c, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
return
}
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing multipart field `file`")
return
}
if file.Size > Store.MaxFileSize {
api.Error(c, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
return
}
dto, err := Store.Upload(deviceID, file, folderID)
if err != nil {
writeError(c, err)
return
}
api.OK(c, dto)
}
func FoldersList(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
folders, err := Store.ListRootFolders(deviceID)
if err != nil {
writeError(c, err)
return
}
api.OK(c, folders)
}
func FilesSearch(c *gin.Context) { api.NotImplemented(c) }
func FilesUpload(c *gin.Context) { api.NotImplemented(c) }
+283
View File
@@ -0,0 +1,283 @@
package handlers_test
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/dbtest"
"github.com/vaultdrop/backend/handlers"
"github.com/vaultdrop/backend/pkg/auth"
"github.com/vaultdrop/backend/repository"
"github.com/vaultdrop/backend/service"
)
const handlersTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_test?sslmode=disable"
type envelope struct {
Data json.RawMessage `json:"data"`
Meta *struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
} `json:"meta,omitempty"`
}
type fileDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
MimeType string `json:"mimeType"`
FolderID string `json:"folderId"`
CreatedAt string `json:"createdAt"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func setup(t *testing.T) (*gin.Engine, *service.Resources, *repository.Repository) {
t.Helper()
conn := dbtest.OpenTestDatabase(t, handlersTestURL)
repo := repository.NewRepository(conn)
// max 10 bytes pour couvrir FILE_TOO_LARGE en test
store := service.NewResources(repo, t.TempDir(), 10)
manager, err := auth.NewManager("test-secret")
if err != nil {
t.Fatalf("auth manager: %v", err)
}
handlers.Auth = manager
handlers.Store = store
gin.SetMode(gin.TestMode)
r := gin.New()
handlers.RegisterRoutes(r)
return r, store, repo
}
func doRequest(t *testing.T, r *gin.Engine, method, path, token string, body []byte, contentType string) (*httptest.ResponseRecorder, envelope) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec, envelope{}
}
func expectOK(t *testing.T, rec *httptest.ResponseRecorder, path string) envelope {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("%s: attendu 200, got %d body=%s", path, rec.Code, rec.Body.String())
}
var env envelope
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("%s: unmarshal: %v body=%s", path, err, rec.Body.String())
}
return env
}
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code, path string) {
t.Helper()
if rec.Code != status {
t.Fatalf("%s: attendu %d, got %d body=%s", path, status, rec.Code, rec.Body.String())
}
var e apiError
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("%s: unmarshal error: %v body=%s", path, err, rec.Body.String())
}
if e.Code != code {
t.Errorf("%s: code erreur attendu %s, got %s", path, code, e.Code)
}
}
func registerDevice(t *testing.T, r *gin.Engine, deviceID string) string {
t.Helper()
body := fmt.Sprintf(`{"deviceId":%q}`, deviceID)
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(body), "application/json")
var env struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("register: unmarshal: %v body=%s", err, rec.Body.String())
}
if env.Data.Token == "" {
t.Fatalf("register: pas de token (status %d)", rec.Code)
}
return env.Data.Token
}
func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename string, content []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write body: %v", err)
}
if folderID != "" {
if err := writer.WriteField("folderId", folderID); err != nil {
t.Fatalf("write folderId: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/files/upload", token, body.Bytes(), writer.FormDataContentType())
return rec
}
func TestFilesFlow(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
deviceB := repository.NewID()
tokenA := registerDevice(t, r, deviceA)
tokenB := registerDevice(t, r, deviceB)
folderID := repository.NewID()
if err := repo.Resources.InsertFolder(deviceA, folderID, "Docs", ""); err != nil {
t.Fatalf("insert root folder: %v", err)
}
// Upload root + dossier
rec := uploadMultipart(t, r, tokenA, "", "hello.txt", []byte("hello world"))
env := expectOK(t, rec, "upload")
var uploaded fileDTO
if err := json.Unmarshal(env.Data, &uploaded); err != nil {
t.Fatalf("upload: unmarshal: %v", err)
}
if uploaded.Name != "hello.txt" || uploaded.Size != 11 || uploaded.FolderID != "" || uploaded.ID == "" {
t.Errorf("FileDto inattendu: %+v", uploaded)
}
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
env = expectOK(t, rec, "upload-doc")
var doc fileDTO
if err := json.Unmarshal(env.Data, &doc); err != nil {
t.Fatalf("upload-doc: unmarshal: %v", err)
}
if doc.FolderID != folderID {
t.Errorf("folderId attendu %s, got %s", folderID, doc.FolderID)
}
// Same name, same folder → NAME_CONFLICT
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
// Création de fichiers du device B
if err := repo.Resources.InsertFile(deviceB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
t.Fatalf("insert B file: %v", err)
}
// Liste racine (A)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files", tokenA, nil, "")
env = expectOK(t, rec, "list")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("list: unmarshal: %v", err)
}
if len(files) != 1 || files[0].ID != uploaded.ID {
t.Errorf("liste racine A: %+v", files)
}
if env.Meta == nil || env.Meta.Total != 1 {
t.Errorf("meta attendu total=1, got %+v", env.Meta)
}
// Liste dans le dossier (A)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?folderId="+folderID, tokenA, nil, "")
env = expectOK(t, rec, "list-folder")
files = nil
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("list-folder: unmarshal: %v", err)
}
if len(files) != 1 || files[0].ID != doc.ID {
t.Errorf("liste dossier: %+v", files)
}
// B ne voit pas les fichiers de A
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenB, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device")
// Get + delete côté A
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
env = expectOK(t, rec, "get")
var got fileDTO
if err := json.Unmarshal(env.Data, &got); err != nil {
t.Fatalf("get: unmarshal: %v", err)
}
if got.ID != uploaded.ID || got.MimeType != "text/plain" || got.CreatedAt == "" {
t.Errorf("get FileDto inattendu: %+v", got)
}
rec, _ = doRequest(t, r, http.MethodDelete, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
env = expectOK(t, rec, "delete")
var deleted struct {
ID string `json:"id"`
}
if err := json.Unmarshal(env.Data, &deleted); err != nil {
t.Fatalf("delete: unmarshal: %v", err)
}
if deleted.ID != uploaded.ID {
t.Errorf("delete id: %+v", deleted)
}
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenA, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-after-delete")
}
func TestUploadTooLarge(t *testing.T) {
r, _, _ := setup(t)
device := repository.NewID()
token := registerDevice(t, r, device)
rec := uploadMultipart(t, r, token, "", "big.txt", []byte("0123456789ABCDEF"))
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
}
func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
deviceB := repository.NewID()
tokenA := registerDevice(t, r, deviceA)
registerDevice(t, r, deviceB)
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "AA", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "BB", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
if err := repo.Resources.InsertFolder(deviceB, repository.NewID(), "CC", ""); err != nil {
t.Fatalf("insert folder B: %v", err)
}
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/folders", tokenA, nil, "")
env := expectOK(t, rec, "folders")
var folders []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(env.Data, &folders); err != nil {
t.Fatalf("folders: unmarshal: %v", err)
}
if len(folders) != 2 || folders[0].Name != "AA" || folders[1].Name != "BB" {
t.Errorf("folders A: %+v", folders)
}
}
-8
View File
@@ -1,8 +0,0 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
)
func FoldersList(c *gin.Context) { api.NotImplemented(c) }
+32
View File
@@ -0,0 +1,32 @@
package handlers
import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes wires the full /api/v1 surface (public + protected).
// Public: /health, /devices. Everything else requires a device bearer token.
func RegisterRoutes(r *gin.Engine) {
public := r.Group("/api/v1")
{
public.GET("/health", Health)
public.POST("/devices", DevicesRegister)
}
protected := r.Group("/api/v1")
protected.Use(RequireDevice)
{
protected.GET("/files", FilesList)
protected.GET("/files/search", FilesSearch)
protected.GET("/files/:id", FilesGet)
protected.DELETE("/files/:id", FilesDelete)
protected.GET("/files/folders", FoldersList)
protected.POST("/files/upload", FilesUpload)
protected.POST("/ocr/jobs", OcrJobsCreate)
protected.GET("/ocr/jobs/:id", OcrJobsGet)
protected.POST("/sync/ops", SyncOpsPush)
protected.GET("/sync/permissions", SyncPermissionsGet)
}
}
+28
View File
@@ -0,0 +1,28 @@
package handlers
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
"github.com/vaultdrop/backend/repository"
"github.com/vaultdrop/backend/service"
)
// Store is the business layer used by handlers; set once at startup
// (cmd/server). Nil until then.
var Store *service.Resources
// writeError maps repository/service sentinels to contract error codes.
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrNotFound):
api.Error(c, 404, "NOT_FOUND", "resource not found")
case errors.Is(err, repository.ErrNameConflict):
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
case errors.Is(err, service.FileTooLargeError):
api.Error(c, 413, "FILE_TOO_LARGE", "file exceeds the maximum allowed size")
default:
api.Error(c, 500, "INTERNAL", err.Error())
}
}