From 5578e4a3548e1b3b12b701e42dfabfd0c8e04ff5 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 13 Sep 2026 19:24:58 +0200 Subject: [PATCH] add tests --- backend/config/config_test.go | 83 ++++++ backend/handlers/files_test.go | 126 ++++++++ backend/handlers/health_test.go | 32 ++ backend/handlers/router.go | 8 + backend/handlers/sync_test.go | 103 +++++++ backend/pkg/api/response_test.go | 92 ++++++ backend/pkg/auth/auth_test.go | 23 ++ backend/repository/devices_test.go | 73 +++++ backend/repository/ocr_jobs_test.go | 92 ++++++ backend/repository/operations_test.go | 93 ++++++ backend/repository/resources.go | 2 +- backend/repository/resources_test.go | 115 +++++++ backend/repository/users_test.go | 88 ++++++ backend/service/bootstrap_test.go | 87 ++++++ backend/service/ocr_test.go | 130 ++++++++ backend/service/resources_test.go | 93 ++++++ backend/service/sync_test.go | 281 ++++++++++++++++++ .../mobile/ui/pdfbuilder/PdfBuilderScreen.kt | 77 +++++ .../app/src/main/res/values/strings.xml | 2 + 19 files changed, 1599 insertions(+), 1 deletion(-) create mode 100644 backend/config/config_test.go create mode 100644 backend/handlers/health_test.go create mode 100644 backend/pkg/api/response_test.go create mode 100644 backend/repository/devices_test.go create mode 100644 backend/repository/ocr_jobs_test.go create mode 100644 backend/repository/operations_test.go create mode 100644 backend/repository/users_test.go create mode 100644 backend/service/bootstrap_test.go create mode 100644 backend/service/ocr_test.go create mode 100644 backend/service/resources_test.go create mode 100644 backend/service/sync_test.go diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..2e1c383 --- /dev/null +++ b/backend/config/config_test.go @@ -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") + } +} diff --git a/backend/handlers/files_test.go b/backend/handlers/files_test.go index 80fe501..588028a 100644 --- a/backend/handlers/files_test.go +++ b/backend/handlers/files_test.go @@ -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") +} diff --git a/backend/handlers/health_test.go b/backend/handlers/health_test.go new file mode 100644 index 0000000..12250f5 --- /dev/null +++ b/backend/handlers/health_test.go @@ -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") +} diff --git a/backend/handlers/router.go b/backend/handlers/router.go index fb649c2..98713c5 100644 --- a/backend/handlers/router.go +++ b/backend/handlers/router.go @@ -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 diff --git a/backend/handlers/sync_test.go b/backend/handlers/sync_test.go index 4244f71..b67d48d 100644 --- a/backend/handlers/sync_test.go +++ b/backend/handlers/sync_test.go @@ -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() diff --git a/backend/pkg/api/response_test.go b/backend/pkg/api/response_test.go new file mode 100644 index 0000000..9ddafe7 --- /dev/null +++ b/backend/pkg/api/response_test.go @@ -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 +} diff --git a/backend/pkg/auth/auth_test.go b/backend/pkg/auth/auth_test.go index 844c7b0..d5e81eb 100644 --- a/backend/pkg/auth/auth_test.go +++ b/backend/pkg/auth/auth_test.go @@ -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) + } +} diff --git a/backend/repository/devices_test.go b/backend/repository/devices_test.go new file mode 100644 index 0000000..a4c46d3 --- /dev/null +++ b/backend/repository/devices_test.go @@ -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) + } +} diff --git a/backend/repository/ocr_jobs_test.go b/backend/repository/ocr_jobs_test.go new file mode 100644 index 0000000..15fa650 --- /dev/null +++ b/backend/repository/ocr_jobs_test.go @@ -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) + } +} diff --git a/backend/repository/operations_test.go b/backend/repository/operations_test.go new file mode 100644 index 0000000..96af1e8 --- /dev/null +++ b/backend/repository/operations_test.go @@ -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) + } +} diff --git a/backend/repository/resources.go b/backend/repository/resources.go index 1a3f79b..ed282fd 100644 --- a/backend/repository/resources.go +++ b/backend/repository/resources.go @@ -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 } diff --git a/backend/repository/resources_test.go b/backend/repository/resources_test.go index f4fbc1b..b1d6b76 100644 --- a/backend/repository/resources_test.go +++ b/backend/repository/resources_test.go @@ -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) + } +} diff --git a/backend/repository/users_test.go b/backend/repository/users_test.go new file mode 100644 index 0000000..63aadd5 --- /dev/null +++ b/backend/repository/users_test.go @@ -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) + } +} diff --git a/backend/service/bootstrap_test.go b/backend/service/bootstrap_test.go new file mode 100644 index 0000000..18e1caf --- /dev/null +++ b/backend/service/bootstrap_test.go @@ -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) + } +} diff --git a/backend/service/ocr_test.go b/backend/service/ocr_test.go new file mode 100644 index 0000000..5081cec --- /dev/null +++ b/backend/service/ocr_test.go @@ -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) + } +} diff --git a/backend/service/resources_test.go b/backend/service/resources_test.go new file mode 100644 index 0000000..a98e96a --- /dev/null +++ b/backend/service/resources_test.go @@ -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//.. + 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)) + } +} diff --git a/backend/service/sync_test.go b/backend/service/sync_test.go new file mode 100644 index 0000000..2c55ea0 --- /dev/null +++ b/backend/service/sync_test.go @@ -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) + } +} diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt index e7ae7aa..568f574 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -48,19 +49,25 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.ui.components.FileCategoryIcon +import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale @@ -82,6 +89,7 @@ fun PdfBuilderScreen( var showNoteDialog by remember { mutableStateOf(false) } var showFileSheet by remember { mutableStateOf(false) } var showNameDialog by remember { mutableStateOf(false) } + var previewFile by remember { mutableStateOf(null) } // Nom suggéré par défaut : date du jour au format jour-mois-année. val defaultPdfName = remember { @@ -171,6 +179,13 @@ fun PdfBuilderScreen( ) } + previewFile?.let { file -> + FilePreviewDialog( + file = file, + onDismiss = { previewFile = null }, + ) + } + if (items.isEmpty()) { Box( modifier = Modifier @@ -198,6 +213,9 @@ fun PdfBuilderScreen( onMoveUp = { viewModel.moveUp(item.id) }, onMoveDown = { viewModel.moveDown(item.id) }, onRemove = { viewModel.removeItem(item.id) }, + onPreview = (item as? PdfBuilderItem.FileItem)?.let { + { previewFile = it.file } + }, ) } } @@ -371,7 +389,9 @@ private fun BuilderItemRow( onMoveUp: () -> Unit, onMoveDown: () -> Unit, onRemove: () -> Unit, + onPreview: (() -> Unit)?, ) { + val previewModifier = if (onPreview != null) Modifier.clickable(onClick = onPreview) else Modifier Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -379,6 +399,7 @@ private fun BuilderItemRow( Row( modifier = Modifier .fillMaxWidth() + .then(previewModifier) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -438,6 +459,62 @@ private fun BuilderItemRow( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilePreviewDialog( + file: FileEntity, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val onOpenExternalFailed: () -> Unit = { + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.document_open_error), + ) + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = file.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.pdf_builder_close_preview), + ) + } + }, + ) + }, + ) { padding -> + DocumentContentViewer( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + } + } +} + @Composable private fun BuilderActions( onAddFile: () -> Unit, diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 2697af4..b3cb9b9 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -176,5 +176,7 @@ ex. factures_janvier Enregistrer PDF créé + Aperçu + Fermer l\'aperçu PDF générés \ No newline at end of file