add tests

This commit is contained in:
m
2026-09-13 19:24:58 +02:00
parent c054b90c92
commit 5578e4a354
19 changed files with 1599 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
package config
import (
"testing"
)
// clearEnv neutralise toute influence de l'environnement / .env pour le test.
func clearEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"PORT", "DATABASE_URL", "UPLOAD_DIR", "MAX_FILE_SIZE_MB",
"OCR_LANG", "AUTH_SECRET", "ADMIN_USERNAME", "ADMIN_PASSWORD",
} {
t.Setenv(name, "")
}
}
func TestLoadApplicationDefaults(t *testing.T) {
clearEnv(t)
err, cfg := LoadApplicationConfig()
if err != nil {
t.Fatalf("LoadApplicationConfig: %v", err)
}
if cfg.Port != 8080 {
t.Errorf("Port = %d, attendu 8080", cfg.Port)
}
if cfg.MaxFileSizeMB != 50 {
t.Errorf("MaxFileSizeMB = %d, attendu 50", cfg.MaxFileSizeMB)
}
if cfg.DatabaseURL != "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_dev?sslmode=disable" {
t.Errorf("DatabaseURL défaut inattendu: %q", cfg.DatabaseURL)
}
if cfg.UploadDir != "./uploads" {
t.Errorf("UploadDir défaut inattendu: %q", cfg.UploadDir)
}
if cfg.OcrLang != "fra+eng" {
t.Errorf("OcrLang défaut inattendu: %q", cfg.OcrLang)
}
if cfg.AuthSecret != "dev-secret-change-me" {
t.Errorf("AuthSecret défaut inattendu: %q", cfg.AuthSecret)
}
if cfg.AdminUsername != "" || cfg.AdminPassword != "" {
t.Errorf("identifiants admin doivent être vides par défaut: %+v", cfg)
}
}
func TestLoadApplicationEnvOverrides(t *testing.T) {
clearEnv(t)
t.Setenv("PORT", "9090")
t.Setenv("MAX_FILE_SIZE_MB", "120")
t.Setenv("DATABASE_URL", "postgres://u:p@host:5433/db?sslmode=disable")
t.Setenv("UPLOAD_DIR", "/tmp/up")
t.Setenv("OCR_LANG", "eng")
t.Setenv("AUTH_SECRET", "super-secret")
t.Setenv("ADMIN_USERNAME", "root")
t.Setenv("ADMIN_PASSWORD", "toor")
err, cfg := LoadApplicationConfig()
if err != nil {
t.Fatalf("LoadApplicationConfig: %v", err)
}
if cfg.Port != 9090 || cfg.MaxFileSizeMB != 120 || cfg.DatabaseURL != "postgres://u:p@host:5433/db?sslmode=disable" ||
cfg.UploadDir != "/tmp/up" || cfg.OcrLang != "eng" || cfg.AuthSecret != "super-secret" ||
cfg.AdminUsername != "root" || cfg.AdminPassword != "toor" {
t.Errorf("overrides non appliqués: %+v", cfg)
}
}
func TestLoadApplicationRejectsInvalidInt(t *testing.T) {
clearEnv(t)
t.Setenv("PORT", "not-a-number")
if err, _ := LoadApplicationConfig(); err == nil {
t.Error("PORT invalide : attendu une erreur")
}
clearEnv(t)
t.Setenv("MAX_FILE_SIZE_MB", "99999999999999999999999")
if err, _ := LoadApplicationConfig(); err == nil {
t.Error("MAX_FILE_SIZE_MB invalide : attendu une erreur")
}
}
+126
View File
@@ -328,6 +328,30 @@ func TestSearchFiles(t *testing.T) {
}
}
func TestSearchFilesEscapesPercent(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sp"), "search-test-password", device)
if err := repo.Resources.InsertFile(user, repository.NewID(), "half%price.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert percent file: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "plain.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert plain file: %v", err)
}
// q=% (encodé %25) ne doit matcher QUE le nom contenant un '%' littéral.
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=%25", token, nil, "")
env := expectOK(t, rec, "search-percent")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("search-percent: unmarshal: %v", err)
}
if len(files) != 1 || files[0].Name != "half%price.txt" {
t.Errorf("'%%' littéral : attendu 1 résultat, got %+v", files)
}
}
func TestUploadTooLarge(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
@@ -337,6 +361,30 @@ func TestUploadTooLarge(t *testing.T) {
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
}
func TestUploadIntoUnknownFolder(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uf"), "upload-test-password", device)
rec := uploadMultipart(t, r, token, repository.NewID(), "orphan.txt", []byte("hi"))
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-unknown-folder")
}
func TestUploadNameConflict(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uc"), "upload-test-password", device)
rec := uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
if rec.Code != http.StatusOK {
t.Fatalf("premier upload: status %d body=%s", rec.Code, rec.Body.String())
}
// Même nom à la racine → conflit d'unicité (parent_id NULL)
rec = uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
}
func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
@@ -367,3 +415,81 @@ func TestFoldersListAndScoping(t *testing.T) {
t.Errorf("folders A: %+v", folders)
}
}
func TestFilesPaginationDefaultsAndClamp(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "pg"), "pagination-test-password", device)
// pageSize au-delà de 200 → clampé à 200
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?pageSize=9999", token, nil, "")
env := expectOK(t, rec, "pageSize-clamp")
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 200 {
t.Errorf("clamp pageSize: %+v", env.Meta)
}
// Paramètres invalides → défauts (page=1, pageSize=50)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?page=0&pageSize=-5", token, nil, "")
env = expectOK(t, rec, "invalid-params")
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 50 {
t.Errorf("defauts page/pageSize: %+v", env.Meta)
}
// sort/order inconnus → pas d'erreur (défaut created_at desc)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?sort=zzz&order=up&pageSize=10", token, nil, "")
expectOK(t, rec, "invalid-sort")
}
func TestFilesListSortBySize(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sr"), "sort-test-password", device)
// Tri volontairement désordonné : 30, 10, 20.
if err := repo.Resources.InsertFile(user, repository.NewID(), "a.txt", "", 30, nil, nil); err != nil {
t.Fatalf("insert a: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "b.txt", "", 10, nil, nil); err != nil {
t.Fatalf("insert b: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "c.txt", "", 20, nil, nil); err != nil {
t.Fatalf("insert c: %v", err)
}
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?sort=size&order=asc", token, nil, "")
env := expectOK(t, rec, "sort-size")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(files) != 3 || files[0].Size != 10 || files[1].Size != 20 || files[2].Size != 30 {
t.Errorf("ordre size asc inattendu: %+v", files)
}
}
func TestFilesGetDeleteRejectInvalidID(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "ii"), "invalid-id-test-password", device)
// ID non 32-hex → 404 avant toute requête.
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/NOT-HEX-ID", token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-invalid-id")
rec, _ = doRequest(t, r, http.MethodDelete, "/api/v1/files/xyz", token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "delete-invalid-id")
// ID 32-hex mais inconnu (du même user) → 404.
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+repository.NewID(), token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-unknown")
// Le user ne voit jamais les fichiers d'un autre même avec un ID valide.
fileID := repository.NewID()
if err := repo.Resources.InsertFile(user, fileID, "mine.txt", "", 4, nil, nil); err != nil {
t.Fatalf("insert: %v", err)
}
otherDevice := repository.NewID()
otherToken, _ := registerAndLogin(t, r, repo, testUserUsername(otherDevice, "ii2"), "invalid-id-test-password-b", otherDevice)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, otherToken, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-user")
}
+32
View File
@@ -0,0 +1,32 @@
package handlers_test
import (
"encoding/json"
"net/http"
"testing"
)
func TestHealth(t *testing.T) {
r, _, _ := setup(t)
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/health", "", nil, "")
if rec.Code != http.StatusOK {
t.Fatalf("health: status %d body=%s", rec.Code, rec.Body.String())
}
var env struct {
Data struct {
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("health: unmarshal: %v", err)
}
if env.Data.Status != "healthy" {
t.Errorf("health status = %q, attendu healthy", env.Data.Status)
}
}
func TestUnknownRouteReturnsJsonEnvelope(t *testing.T) {
r, _, _ := setup(t)
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/does-not-exist", "", nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "unknown-route")
}
+8
View File
@@ -1,9 +1,12 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
)
// RegisterRoutes wires the full /api/v1 surface (public + protected).
@@ -36,6 +39,11 @@ func RegisterRoutes(r *gin.Engine) {
protected.POST("/sync/ops", SyncOpsPush)
protected.GET("/sync/permissions", SyncPermissionsGet)
}
// Route inconnue → enveloppe d'erreur du contrat (jamais de HTML).
r.NoRoute(func(c *gin.Context) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "route not found")
})
}
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
+103
View File
@@ -151,6 +151,109 @@ func TestSyncOpsCreateResourceWithParent(t *testing.T) {
}
}
func TestSyncOpsRejectsMalformedBody(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sm"), "sync-test-password", device)
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, []byte(`{invalid`), "application/json")
expectError(t, rec, http.StatusBadRequest, "INVALID_REQUEST", "sync-malformed")
}
func TestSyncOpsEmptyBatch(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "se"), "sync-test-password", device)
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(nil), "application/json")
env := expectOK(t, rec, "sync-empty")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if result.Applied != 0 {
t.Errorf("batch vide: applied attendu 0, got %d", result.Applied)
}
}
func TestSyncOpsReplayCreateIsNoop(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "srn"), "sync-test-password", device)
fileID := repository.NewID()
send := func(batch []map[string]any) int {
t.Helper()
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(batch), "application/json")
env := expectOK(t, rec, "sync-send")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String())
}
return result.Applied
}
ops := []map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
}
if applied := send(ops); applied != 1 {
t.Fatalf("premier envoi: applied attendu 1, got %d", applied)
}
// Rejeu avec le MÊME resource_id mais un operation_id neuf → idempotent
// (la ressource existe déjà : no-op), pas de doublon côté ressources.
if applied := send([]map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
}); applied != 1 {
t.Fatalf("rejeu: applied attendu 1, got %d", applied)
}
files, total, err := repo.Resources.ListFiles(user, "", 10, 0, "created_at", "desc")
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 || len(files) != 1 || files[0].ID != fileID {
t.Errorf("pas de doublon attendu: total=%d files=%+v", total, files)
}
}
func TestSyncOpsLargeBatchAccepted(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sl"), "sync-test-password", device)
ops := make([]map[string]any, 0, 25)
for i := 0; i < 25; i++ {
ops = append(ops, op(repository.NewID(), repository.NewID(), "create_resource", "file", map[string]any{"name": fmt.Sprintf("f%02d.txt", i)}))
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-large")
var result struct {
Applied int `json:"applied"`
Failed any `json:"failed"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if result.Applied != 25 || result.Failed != nil {
t.Errorf("attendu applied=25 failed=null, got %+v", result)
}
files, total, err := repo.Resources.ListFiles(user, "", 50, 0, "created_at", "desc")
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 25 || len(files) != 25 {
t.Errorf("les 25 ressources doivent être persistées, total=%d files=%d", total, len(files))
}
}
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
+92
View File
@@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func setupRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/ok", func(c *gin.Context) { OK(c, gin.H{"id": "abc"}) })
r.GET("/list", func(c *gin.Context) { OKList(c, []int{1, 2}, 3, 50, 123) })
r.GET("/err", func(c *gin.Context) { Error(c, 400, "BAD_REQUEST", "some message") })
r.GET("/nope", func(c *gin.Context) { NotImplemented(c) })
return r
}
func TestEnvelopeShapes(t *testing.T) {
r := setupRouter()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ok", nil))
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
var okBody struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &okBody); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if okBody.Data.ID != "abc" {
t.Errorf("data inattendu: %s", rec.Body.String())
}
rec = httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/list", nil))
var listBody struct {
Data []int `json:"data"`
Meta struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
} `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listBody); err != nil {
t.Fatalf("unmarshal list: %v", err)
}
if len(listBody.Data) != 2 || listBody.Meta.Page != 3 || listBody.Meta.PageSize != 50 || listBody.Meta.Total != 123 {
t.Errorf("enveloppe list inattendue: %s", rec.Body.String())
}
rec = httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/err", nil))
if rec.Code != 400 {
t.Fatalf("status err: %d", rec.Code)
}
var errBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatalf("unmarshal err: %v", err)
}
if errBody.Error.Code != "BAD_REQUEST" || errBody.Error.Message != "some message" {
t.Errorf("enveloppe erreur inattendue: %s", rec.Body.String())
}
rec = httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
if rec.Code != 501 || errorCodeOf(rec) != NotImplementedCode {
t.Errorf("not implemented: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func errorCodeOf(rec *httptest.ResponseRecorder) string {
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
return body.Error.Code
}
+23
View File
@@ -95,3 +95,26 @@ func TestVerifyRequiresDeviceClaim(t *testing.T) {
t.Fatal("expected token without device claim to be rejected")
}
}
func TestIssueSetsExpiration(t *testing.T) {
m, _ := NewManager("test-secret")
before := time.Now()
signed, err := m.Issue(testUserID, testDeviceID)
if err != nil {
t.Fatalf("Issue: %v", err)
}
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
if err != nil {
t.Fatalf("parse: %v", err)
}
exp, err := parsed.GetExpiration()
if err != nil {
t.Fatalf("GetExpiration: %v", err)
}
// Le TTL est fixé à 7 jours (docs/api-v1.md) — marge de 1 min par sécurité.
lower := before.Add(TokenTTL - time.Minute)
upper := before.Add(TokenTTL + time.Minute)
if exp.Before(lower) || exp.After(upper) {
t.Errorf("expiration = %v, attendu ≈ now+%v (fenêtre [%v, %v])", exp, TokenTTL, lower, upper)
}
}
+73
View File
@@ -0,0 +1,73 @@
package repository
import (
"testing"
"github.com/vaultdrop/backend/dbtest"
)
func newTestDevices(t *testing.T) *Devices {
t.Helper()
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
return &Devices{DB: conn}
}
func TestDevicesUpsertAndExists(t *testing.T) {
d := newTestDevices(t)
deviceID := NewID()
exists, err := d.Exists(deviceID)
if err != nil || exists {
t.Fatalf("device non enregistré : exists=%v err=%v", exists, err)
}
if err := d.Upsert(deviceID); err != nil {
t.Fatalf("upsert: %v", err)
}
exists, err = d.Exists(deviceID)
if err != nil || !exists {
t.Fatalf("après upsert : exists=%v err=%v", exists, err)
}
// Upsert idempotent (ON CONFLICT) → pas d'erreur ni de doublon.
if err := d.Upsert(deviceID); err != nil {
t.Fatalf("second upsert: %v", err)
}
exists, err = d.Exists(deviceID)
if err != nil || !exists {
t.Fatalf("après second upsert : exists=%v err=%v", exists, err)
}
var count int
if err := d.DB.QueryRow(`SELECT COUNT(*) FROM devices WHERE device_id = $1`, deviceID).Scan(&count); err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Errorf("device dupliqué, count = %d", count)
}
}
func TestDevicesMarkUser(t *testing.T) {
d := newTestDevices(t)
deviceID := NewID()
if err := d.Upsert(deviceID); err != nil {
t.Fatalf("upsert: %v", err)
}
users := &Users{DB: d.DB}
userID, err := users.Create("device-user", "device-user", "hash", false)
if err != nil {
t.Fatalf("create user: %v", err)
}
if err := d.MarkUser(deviceID, userID); err != nil {
t.Fatalf("mark user: %v", err)
}
var storedUser string
if err := d.DB.QueryRow(`SELECT user_id FROM devices WHERE device_id = $1`, deviceID).Scan(&storedUser); err != nil {
t.Fatalf("read user_id: %v", err)
}
if storedUser != userID {
t.Errorf("user_id mémorisé = %q, attendu %q", storedUser, userID)
}
}
+92
View File
@@ -0,0 +1,92 @@
package repository
import (
"errors"
"testing"
"github.com/vaultdrop/backend/dbtest"
)
func newTestRepo(t *testing.T) *Repository {
t.Helper()
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
return NewRepository(conn)
}
func seedOcrJobFixture(t *testing.T) (*Repository, string, string, string) {
t.Helper()
repo := newTestRepo(t)
userID, err := repo.Users.Create("ocr-user", "ocr-user", "hash", false)
if err != nil {
t.Fatalf("create user: %v", err)
}
deviceID := NewID()
if err := repo.Devices.Upsert(deviceID); err != nil {
t.Fatalf("register device: %v", err)
}
fileID := NewID()
if err := repo.Resources.InsertFile(userID, fileID, "scan.png", "", 10, nil, nil); err != nil {
t.Fatalf("insert file: %v", err)
}
return repo, userID, deviceID, fileID
}
func TestOcrJobsLifecycleTransitions(t *testing.T) {
repo, _, deviceID, fileID := seedOcrJobFixture(t)
jobID := NewID()
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
t.Fatalf("create: %v", err)
}
row, err := repo.OcrJobs.Get(deviceID, jobID)
if err != nil || row.Status != "queued" {
t.Fatalf("get (queued): %+v err=%v", row, err)
}
if err := repo.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
t.Fatalf("touch processing: %v", err)
}
row, _ = repo.OcrJobs.Get(deviceID, jobID)
if row.Status != "processing" {
t.Errorf("status attendu processing, got %q", row.Status)
}
if err := repo.OcrJobs.Complete(deviceID, jobID, "HELLO"); err != nil {
t.Fatalf("complete: %v", err)
}
row, _ = repo.OcrJobs.Get(deviceID, jobID)
if row.Status != "done" || row.Text == nil || *row.Text != "HELLO" {
t.Errorf("after complete: %+v", row)
}
}
func TestOcrJobsFail(t *testing.T) {
repo, _, deviceID, fileID := seedOcrJobFixture(t)
jobID := NewID()
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
t.Fatalf("create: %v", err)
}
if err := repo.OcrJobs.Fail(deviceID, jobID, "tesseract exploded"); err != nil {
t.Fatalf("fail: %v", err)
}
row, err := repo.OcrJobs.Get(deviceID, jobID)
if err != nil || row.Status != "failed" || row.Error == nil || *row.Error != "tesseract exploded" {
t.Errorf("après fail : %+v err=%v", row, err)
}
}
func TestOcrJobsGetScopedByDevice(t *testing.T) {
repo, _, deviceID, fileID := seedOcrJobFixture(t)
jobID := NewID()
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
t.Fatalf("create: %v", err)
}
otherDevice := NewID()
if err := repo.Devices.Upsert(otherDevice); err != nil {
t.Fatalf("register other device: %v", err)
}
if _, err := repo.OcrJobs.Get(otherDevice, jobID); !errors.Is(err, ErrJobNotFound) {
t.Errorf("get par un autre device : attendu ErrJobNotFound, got %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
package repository
import (
"encoding/json"
"errors"
"testing"
"github.com/vaultdrop/backend/dbtest"
)
func newTestOperations(t *testing.T) (*Operations, *Devices) {
t.Helper()
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
return &Operations{DB: conn}, &Devices{DB: conn}
}
func TestOperationsRecordAndApplied(t *testing.T) {
o, devices := newTestOperations(t)
deviceID := NewID()
if err := devices.Upsert(deviceID); err != nil {
t.Fatalf("register device: %v", err)
}
opID := NewID()
applied, err := o.Applied(deviceID, opID)
if err != nil || applied {
t.Fatalf("avant record : applied=%v err=%v", applied, err)
}
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`{"name":"x"}`)); err != nil {
t.Fatalf("record: %v", err)
}
applied, err = o.Applied(deviceID, opID)
if err != nil || !applied {
t.Fatalf("après record : applied=%v err=%v", applied, err)
}
// Rejeu → ON CONFLICT DO NOTHING, aucune erreur.
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`null`)); err != nil {
t.Fatalf("rejeu: %v", err)
}
if applied, _ := o.Applied(deviceID, opID); !applied {
t.Error("rejeu : l'op doit rester appliquée")
}
// Idempotence par (device_id, operation_id) : un autre device peut réutiliser
// le même operation_id sans conflit (les deux rows sont distinctes).
device02 := NewID()
if err := devices.Upsert(device02); err != nil {
t.Fatalf("register device 2: %v", err)
}
if err := o.Record(device02, opID, "create_resource", "resource", nil, NewID(), []byte(`{}`)); err != nil {
t.Errorf("même op_id sur un autre device: %v", err)
}
}
func TestOperationsRecordRejectsInvalidOperationID(t *testing.T) {
o, devices := newTestOperations(t)
if err := devices.Upsert(NewID()); err != nil {
t.Fatalf("register device: %v", err)
}
err := o.Record(NewID(), "UPPERCASENOT32", "create_resource", "resource", nil, NewID(), []byte(`{}`))
if !errors.Is(err, ErrInvalidOperationID) {
t.Errorf("operation_id invalide : attendu ErrInvalidOperationID, got %v", err)
}
}
func TestOperationsRecordNormalizesInvalidPayload(t *testing.T) {
o, devices := newTestOperations(t)
deviceID := NewID()
if err := devices.Upsert(deviceID); err != nil {
t.Fatalf("register device: %v", err)
}
opID := NewID()
// Un payload non-JSON ne doit pas faire échouer la trace (jsonBytes → {}).
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`not-json`)); err != nil {
t.Fatalf("record: %v", err)
}
var stored string
if err := o.DB.QueryRow(`SELECT payload::text FROM operations WHERE device_id = $1 AND operation_id = $2`, deviceID, opID).Scan(&stored); err != nil {
t.Fatalf("read payload: %v", err)
}
var parsed any
if err := json.Unmarshal([]byte(stored), &parsed); err != nil {
t.Fatalf("payload stocké invalide: %q", stored)
}
if stored != "{}" {
t.Errorf("payload normalisé attendu {}, got %q", stored)
}
}
+1 -1
View File
@@ -305,7 +305,7 @@ func (r *Resources) SyncDelete(ownerID, resourceID string) error {
_, err := r.DB.Exec(
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
ownerID, resourceID,
resourceID, ownerID,
)
return err
}
+115
View File
@@ -3,6 +3,7 @@ package repository
import (
"errors"
"testing"
"time"
"github.com/vaultdrop/backend/dbtest"
)
@@ -117,3 +118,117 @@ func TestNameConflictAndUnknownFolder(t *testing.T) {
t.Errorf("folder inconnu doit être NOT_FOUND, got %v", err)
}
}
func TestSearchFilesEscapesWildcards(t *testing.T) {
repo := newTestResources(t)
owner := NewID()
mustInsertUser(t, repo, owner)
insert := func(name string) {
t.Helper()
if err := repo.InsertFile(owner, NewID(), name, "", 1, nil, nil); err != nil {
t.Fatalf("insert %q: %v", name, err)
}
}
insert("half%price.txt")
insert("plain.txt")
insert("a_b.txt")
insert("abx.txt")
insert(`win\file.txt`)
// '%' littéral → uniquement le nom contenant un '%'
files, total, err := repo.SearchFiles(owner, "%", 50, 0)
if err != nil {
t.Fatalf("search %%: %v", err)
}
if total != 1 || len(files) != 1 || files[0].Name != "half%price.txt" {
t.Errorf("'%%' littéral : total=%d files=%+v", total, files)
}
// '_' littéral → uniquement a_b.txt (pas abx.txt)
files, total, err = repo.SearchFiles(owner, "_", 50, 0)
if err != nil {
t.Fatalf("search _: %v", err)
}
if total != 1 || files[0].Name != "a_b.txt" {
t.Errorf("'_' littéral : total=%d files=%+v", total, files)
}
// '\' littéral → uniquement win\file.txt
files, total, err = repo.SearchFiles(owner, `win\file`, 50, 0)
if err != nil {
t.Fatalf("search backslash: %v", err)
}
if total != 1 || files[0].Name != `win\file.txt` {
t.Errorf("'\\' littéral : total=%d files=%+v", total, files)
}
}
func TestListFilesPagination(t *testing.T) {
repo := newTestResources(t)
owner := NewID()
mustInsertUser(t, repo, owner)
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
if err := repo.InsertFile(owner, NewID(), name, "", 1, nil, nil); err != nil {
t.Fatalf("insert %q: %v", name, err)
}
}
files, total, err := repo.ListFiles(owner, "", 2, 0, "name", "asc")
if err != nil {
t.Fatalf("list page 1: %v", err)
}
if total != 3 || len(files) != 2 || files[0].Name != "a.txt" || files[1].Name != "b.txt" {
t.Errorf("page 1 : total=%d files=%+v", total, files)
}
files, total, err = repo.ListFiles(owner, "", 2, 2, "name", "asc")
if err != nil {
t.Fatalf("list page 2: %v", err)
}
if total != 3 || len(files) != 1 || files[0].Name != "c.txt" {
t.Errorf("page 2 : total=%d files=%+v", total, files)
}
}
func TestListOwnedDelta(t *testing.T) {
repo := newTestResources(t)
owner := NewID()
mustInsertUser(t, repo, owner)
if err := repo.InsertFolder(owner, NewID(), "Docs", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
if err := repo.InsertFile(owner, NewID(), "old.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert old: %v", err)
}
// aprèsMs=0 → tout
all, err := repo.ListOwned(owner, 0)
if err != nil || len(all) != 2 {
t.Fatalf("snapshot complet: %+v err=%v", all, err)
}
// aprèsMs dans le futur → vide
far, err := repo.ListOwned(owner, time.Now().Add(time.Hour).UnixMilli())
if err != nil || len(far) != 0 {
t.Errorf("aprèsMs futur : attendu vide, got %+v err=%v", far, err)
}
// Delta : une ressource insérée APRÈS baseline → uniquement celle-là.
baseline := time.Now()
time.Sleep(5 * time.Millisecond)
freshID := NewID()
if err := repo.InsertFile(owner, freshID, "fresh.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert fresh: %v", err)
}
delta, err := repo.ListOwned(owner, baseline.UnixMilli())
if err != nil {
t.Fatalf("delta: %v", err)
}
if len(delta) != 1 || delta[0].ID != freshID {
t.Errorf("delta attendu uniquement fresh.txt, got %+v", delta)
}
}
+88
View File
@@ -0,0 +1,88 @@
package repository
import (
"errors"
"testing"
"github.com/vaultdrop/backend/dbtest"
)
func newTestUsers(t *testing.T) *Users {
t.Helper()
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
return &Users{DB: conn}
}
func TestUsersCreateGetAndCount(t *testing.T) {
u := newTestUsers(t)
id, err := u.Create("Alice", "alice", "hash-1", false)
if err != nil {
t.Fatalf("create: %v", err)
}
row, err := u.GetByUsernameNormalized("alice")
if err != nil {
t.Fatalf("get by normalized: %v", err)
}
if row.ID != id || row.Username != "Alice" || row.UsernameNormalized != "alice" || row.IsAdmin || row.PasswordHash != "hash-1" {
t.Errorf("row inattendue: %+v", row)
}
byID, err := u.GetByID(id)
if err != nil || byID.ID != id {
t.Fatalf("get by id: %+v err=%v", byID, err)
}
resolved, err := u.ResolveExact("alice")
if err != nil || resolved.ID != id {
t.Fatalf("resolve exact: %+v err=%v", resolved, err)
}
n, err := u.Count()
if err != nil || n != 1 {
t.Errorf("count = %d err=%v, attendu 1", n, err)
}
// Doublon de username normalisé → ErrNameConflict.
if _, err := u.Create("alice2", "alice", "hash-X", false); !errors.Is(err, ErrNameConflict) {
t.Errorf("doublon normalisé : attendu ErrNameConflict, got %v", err)
}
}
func TestUsersUpdatePassword(t *testing.T) {
u := newTestUsers(t)
id, err := u.Create("bob", "bob", "hash-1", false)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := u.UpdatePassword(id, "hash-2"); err != nil {
t.Fatalf("update password: %v", err)
}
row, err := u.GetByID(id)
if err != nil || row.PasswordHash != "hash-2" {
t.Errorf("hash après update = %q (err=%v)", row.PasswordHash, err)
}
}
func TestUsersMarkDeletedHidesAccount(t *testing.T) {
u := newTestUsers(t)
id, err := u.Create("carol", "carol", "hash-1", false)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := u.MarkDeleted(id); err != nil {
t.Fatalf("mark deleted: %v", err)
}
if _, err := u.GetByID(id); !errors.Is(err, ErrNotFound) {
t.Errorf("get by id après suppression : attendu ErrNotFound, got %v", err)
}
if _, err := u.GetByUsernameNormalized("carol"); !errors.Is(err, ErrNotFound) {
t.Errorf("get par username après suppression : attendu ErrNotFound, got %v", err)
}
if n, _ := u.Count(); n != 1 {
t.Errorf("count doit compter aussi les supprimés (bootstrap), got %d", n)
}
}
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"errors"
"testing"
"github.com/vaultdrop/backend/pkg/passwd"
"github.com/vaultdrop/backend/repository"
)
func TestEnsureAdminCreatesFirstAdmin(t *testing.T) {
s := newServiceStore(t)
repo := s.Repository
if err := EnsureAdmin(repo, " Admin ", "admin-secret-123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
user, err := repo.Users.GetByUsernameNormalized("admin")
if err != nil {
t.Fatalf("admin non trouvé: %v", err)
}
if !user.IsAdmin {
t.Error("le compte créé doit être admin")
}
if user.UsernameNormalized != "admin" || user.Username != "admin" {
t.Errorf("identité anormale: %+v", user)
}
if err := passwd.Verify("admin-secret-123", user.PasswordHash); err != nil {
t.Errorf("le mot de passe doit vérifier: %v", err)
}
}
func TestNormalizeUsername(t *testing.T) {
cases := map[string]string{
" admin ": "admin",
" Alice": "alice",
"A B C": "a b c",
"": "",
" ": "",
}
for in, want := range cases {
if got := NormalizeUsername(in); got != want {
t.Errorf("NormalizeUsername(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestEnsureAdminRefusesOnEmptyDBWithoutEnv(t *testing.T) {
s := newServiceStore(t)
err := EnsureAdmin(s.Repository, "", "")
if !errors.Is(err, ErrAdminRequired) {
t.Errorf("env absent : attendu ErrAdminRequired, got %v", err)
}
err = EnsureAdmin(s.Repository, " ", "some-password")
if !errors.Is(err, ErrAdminRequired) {
t.Errorf("username blanc : attendu ErrAdminRequired, got %v", err)
}
}
func TestEnsureAdminNeverOverwritesExistingAccounts(t *testing.T) {
s := newServiceStore(t)
repo := s.Repository
// La base n'est PAS vide → l'env ne doit rien créer, même avec des valeurs.
if _, err := repo.Users.Create("alice", "alice", "existing-hash", false); err != nil {
t.Fatalf("seed user: %v", err)
}
if err := EnsureAdmin(repo, "ADMIN", "would-be-admin-secret"); err != nil {
t.Fatalf("EnsureAdmin sur base non vide : %v", err)
}
// Aucun admin n'a été ajouté, l'existant est intact.
if _, err := repo.Users.GetByUsernameNormalized("admin"); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("admin ne doit pas exister sur base non vide (err=%v)", err)
}
alice, err := repo.Users.GetByUsernameNormalized("alice")
if err != nil {
t.Fatalf("alice doit être intacte: %v", err)
}
if alice.PasswordHash != "existing-hash" || alice.IsAdmin {
t.Errorf("l'existant doit être préservé: %+v", alice)
}
}
+130
View File
@@ -0,0 +1,130 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/vaultdrop/backend/ocr"
"github.com/vaultdrop/backend/repository"
)
// stubEngine again (package service) — même contrat que handlers/ocr_test.
type stubEngine struct {
text string
err error
}
func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string, error) {
if s.err != nil {
return "", s.err
}
return s.text, nil
}
// newTestOcr builds an Ocr over a fresh DB and returns it with the ids.
func newTestOcr(t *testing.T, uploadDir string, engine ocr.Engine) (*Ocr, string, string, string) {
t.Helper()
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "ocr-failed")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
return NewOcr(s.Repository, uploadDir, "fra+eng", engine), userID, deviceID, uploadDir
}
// waitTillTerminal poll jusqu'à un statut terminal (done/failed).
func waitTillTerminal(t *testing.T, o *Ocr, deviceID, jobID string) repository.OcrJobRow {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
row, err := o.Repository.OcrJobs.Get(deviceID, jobID)
if err != nil {
t.Fatalf("get job: %v", err)
}
if row.Status == "done" || row.Status == "failed" {
return row
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("job jamais terminal")
return repository.OcrJobRow{}
}
func insertOcrFile(t *testing.T, o *Ocr, userID, fileID string) {
t.Helper()
if err := o.Repository.Resources.InsertFile(userID, fileID, "scan.png", "", 128, nil, nil); err != nil {
t.Fatalf("insert file: %v", err)
}
}
func TestOcrPhysicalPath(t *testing.T) {
uploadDir := t.TempDir()
userID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fileID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Vérifie que l'extension du fichier trouvé est acceptée (glob = ordre
// alphabétique, on ne présume pas du nom choisi).
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("x"), 0o644); err != nil {
t.Fatalf("write png: %v", err)
}
o := NewOcr(nil, uploadDir, "fra+eng", nil)
found, err := o.physicalPath(userID, fileID)
if err != nil {
t.Fatalf("physicalPath: %v", err)
}
if !strings.HasPrefix(filepath.Base(found), fileID+".") {
t.Errorf("path = %q, attendu préfixe %q", found, fileID+".")
}
if _, err := o.physicalPath(userID, "cccccccccccccccccccccccccccccccc"); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("fichier absent : attendu ErrNotFound, got %v", err)
}
}
func TestOcrJobFailsWhenPhysicalFileMissing(t *testing.T) {
o, userID, deviceID, _ := newTestOcr(t, t.TempDir(), stubEngine{text: "x"})
fileID := repository.NewID()
insertOcrFile(t, o, userID, fileID)
job, err := o.Create(userID, deviceID, fileID)
if err != nil {
t.Fatalf("create: %v", err)
}
row := waitTillTerminal(t, o, deviceID, job.ID)
if row.Status != "failed" || row.Error == nil {
t.Errorf("fichier physique absent : attendu failed avec erreur, got %+v", row)
}
}
func TestOcrJobFailsOnEngineError(t *testing.T) {
uploadDir := t.TempDir()
o, userID, deviceID, _ := newTestOcr(t, uploadDir, stubEngine{err: errors.New("tesseract boom")})
fileID := repository.NewID()
insertOcrFile(t, o, userID, fileID)
// Le fichier physique doit exister pour atteindre l'engine.
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("img"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
job, err := o.Create(userID, deviceID, fileID)
if err != nil {
t.Fatalf("create: %v", err)
}
row := waitTillTerminal(t, o, deviceID, job.ID)
if row.Status != "failed" || row.Error == nil || !strings.Contains(*row.Error, "tesseract boom") {
t.Errorf("erreur engine : attendu failed avec message engine, got %+v", row)
}
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"bytes"
"errors"
"mime/multipart"
"os"
"path/filepath"
"testing"
"github.com/vaultdrop/backend/repository"
)
func multipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reader := multipart.NewReader(&body, writer.Boundary())
form, err := reader.ReadForm(1 << 20)
if err != nil {
t.Fatalf("read form: %v", err)
}
files := form.File["file"]
if len(files) == 0 {
t.Fatal("aucun fichier dans la forme")
}
return files[0]
}
func TestUploadPersistsPhysicalFile(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-happy")
dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "")
if err != nil {
t.Fatalf("upload: %v", err)
}
if dto.ID == "" || dto.Name != "docs.txt" {
t.Errorf("dto inattendu: %+v", dto)
}
// Le fichier physique existe sous UploadDir/<user>/<id>.<ext>.
path := filepath.Join(s.UploadDir, userID, dto.ID+".txt")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("fichier physique absent: %v", err)
}
if info.Size() != 5 {
t.Errorf("taille physique = %d, attendu 5", info.Size())
}
}
func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-conflict")
folderID := repository.NewID()
if err := s.Repository.Resources.InsertFolder(userID, folderID, "Docs", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
target := multipartFileHeader(t, "dupe.txt", []byte("hello"))
first, err := s.Upload(userID, target, folderID)
if err != nil {
t.Fatalf("premier upload: %v", err)
}
if first.ID == "" {
t.Fatal("aucun id au premier upload")
}
// Même nom, même dossier → NAME_CONFLICT et pas de second fichier physique.
if _, err := s.Upload(userID, target, folderID); !errors.Is(err, repository.ErrNameConflict) {
t.Fatalf("second upload : attendu ErrNameConflict, got %v", err)
}
entries, err := os.ReadDir(filepath.Join(s.UploadDir, userID))
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 1 {
t.Errorf("fichier orphelin laissé après NAME_CONFLICT : %d fichiers", len(entries))
}
}
+281
View File
@@ -0,0 +1,281 @@
package service
import (
"encoding/json"
"errors"
"testing"
"github.com/vaultdrop/backend/dbtest"
"github.com/vaultdrop/backend/repository"
)
const serviceTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_service_test?sslmode=disable"
// newServiceStore returns a Resources service over a fresh migrated test DB.
func newServiceStore(t *testing.T) *Resources {
t.Helper()
conn := dbtest.OpenTestDatabase(t, serviceTestURL)
repo := repository.NewRepository(conn)
return NewResources(repo, t.TempDir(), 100)
}
// mustCreateUser creates a plain (non-admin) account and returns its id.
func mustCreateUser(t *testing.T, repo *repository.Repository, username string) string {
t.Helper()
id, err := repo.Users.Create(username, username, "test-hash", false)
if err != nil {
t.Fatalf("create user %q: %v", username, err)
}
return id
}
// mustRegisterDevice registers a device (idempotent) and returns its id.
func mustRegisterDevice(t *testing.T, repo *repository.Repository, deviceID string) string {
t.Helper()
if err := repo.Devices.Upsert(deviceID); err != nil {
t.Fatalf("register device: %v", err)
}
return deviceID
}
func strp(v string) *string { return &v }
// syncOp builds a SyncOperation with a payload serialized as JSON.
func syncOp(operationID, resourceID, operation, resourceType string, payload any) SyncOperation {
raw, _ := json.Marshal(payload)
return SyncOperation{
OperationID: operationID,
ResourceID: strp(resourceID),
ResourceType: strp(resourceType),
Operation: operation,
Payload: raw,
}
}
func TestValidateSyncOp(t *testing.T) {
hexID := repository.NewID()
valid := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x.txt"})
if err := validateSyncOp(&valid); err != nil {
t.Errorf("op valide rejetée: %v", err)
}
cases := []struct {
name string
op SyncOperation
}{
{"operation_id non 32-hex", syncOp("UPPERCASE", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
{"operation_id trop court", syncOp("abc", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
{"operation vide", func() SyncOperation {
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
o.Operation = ""
return o
}()},
{"operation inconnue", syncOp(hexID, hexID, "explode", "file", map[string]any{"name": "x"})},
{"resource_id non 32-hex", syncOp(hexID, "not-hex", OpCreateResource, "file", map[string]any{"name": "x"})},
{"resource_type invalide", syncOp(hexID, hexID, OpCreateResource, "document", map[string]any{"name": "x"})},
{"resource_type manquant", func() SyncOperation {
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
o.ResourceType = nil
return o
}()},
}
for _, tc := range cases {
if err := validateSyncOp(&tc.op); err == nil {
t.Errorf("%s: validation attendue à échouer", tc.name)
}
}
}
func TestValidateSyncOpAckOnlyOpsSkipResourceValidation(t *testing.T) {
// Les ops partage/lien sont accusées réception SANS état serveur : ni
// resource_id ni resource_type ne sont exigés.
for _, opType := range []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink} {
op := SyncOperation{
OperationID: repository.NewID(),
Operation: opType,
Payload: json.RawMessage(`{}`),
}
if err := validateSyncOp(&op); err != nil {
t.Errorf("%s: ack-only op rejetée: %v", opType, err)
}
}
}
func TestApplyBatchRejectsInvalidOperationID(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-invalid")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp("NOT-32-HEX", repository.NewID(), OpCreateResource, "file", map[string]any{"name": "x.txt"}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil {
t.Fatalf("attendu applied=0 + échec, got %+v", result)
}
if result.Failed.OperationID != "NOT-32-HEX" || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("failed inattendu: %+v", result.Failed)
}
}
func TestApplyBatchAckOnlyOpsRecordedWithoutState(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-ack")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
ops := []SyncOperation{
syncOp(repository.NewID(), repository.NewID(), OpShare, "file", map[string]any{}),
syncOp(repository.NewID(), repository.NewID(), OpCreateLink, "file", map[string]any{}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 2 || result.Failed != nil {
t.Errorf("attendu applied=2, got %+v", result)
}
// Aucune ressource ne doit avoir été créée.
files, total, _ := s.Repo.ListFiles(userID, "", 10, 0, "created_at", "desc")
if len(files) != 0 || total != 0 {
t.Errorf("aucune ressource attendue, got %d", total)
}
// Rejeu → toujours accusé, jamais visible.
result, err = s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 {
t.Errorf("rejeu: applied=%d err=%v", result.Applied, err)
}
}
func TestApplyBatchUpdateMetadata(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-rename")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
ops := []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "before.txt"}),
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "after.txt"}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 || result.Failed != nil {
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
row, err := s.Repo.GetFile(userID, fileID)
if err != nil {
t.Fatalf("GetFile: %v", err)
}
if row.Name != "after.txt" {
t.Errorf("nom après update_metadata = %q, attendu after.txt", row.Name)
}
}
func TestApplyBatchMoveIntoItselfRejected(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-selfmove")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("apply create: %v", err)
}
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": fileID}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("move dans soi-même: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchMoveToMissingFolder(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-move-missing")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("apply create: %v", err)
}
missing := repository.NewID()
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": missing}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" {
t.Errorf("move vers dossier absent: attendu applied=0 NOT_FOUND, got %+v", result)
}
}
func TestApplyBatchRejectsInvalidPayloadJSON(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-badpayload")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
op := syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{})
op.Payload = json.RawMessage(`{"name":`)
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{op})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("payload JSON invalide: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchRejectsMissingName(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-noname")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("create sans name: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchDeleteIsIdempotent(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-del")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
ops := []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 || result.Failed != nil {
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
if _, err := s.Repo.GetFile(userID, fileID); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("fichier supprimé attendu NOT_FOUND, got %v", err)
}
// Suppression d'une ressource déjà absente → no-op réussi.
result, err = s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
})
if err != nil || result.Applied != 1 || result.Failed != nil {
t.Errorf("delete absent: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
}