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) { func TestUploadTooLarge(t *testing.T) {
r, _, repo := setup(t) r, _, repo := setup(t)
device := repository.NewID() device := repository.NewID()
@@ -337,6 +361,30 @@ func TestUploadTooLarge(t *testing.T) {
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big") 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) { func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t) r, _, repo := setup(t)
deviceA := repository.NewID() deviceA := repository.NewID()
@@ -367,3 +415,81 @@ func TestFoldersListAndScoping(t *testing.T) {
t.Errorf("folders A: %+v", folders) 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 package handlers
import ( import (
"net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
) )
// RegisterRoutes wires the full /api/v1 surface (public + protected). // RegisterRoutes wires the full /api/v1 surface (public + protected).
@@ -36,6 +39,11 @@ func RegisterRoutes(r *gin.Engine) {
protected.POST("/sync/ops", SyncOpsPush) protected.POST("/sync/ops", SyncOpsPush)
protected.GET("/sync/permissions", SyncPermissionsGet) 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 // 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) { func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
r, _, repo := setup(t) r, _, repo := setup(t)
device := repository.NewID() 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") 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( _, err := r.DB.Exec(
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW() `UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`, WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
ownerID, resourceID, resourceID, ownerID,
) )
return err return err
} }
+115
View File
@@ -3,6 +3,7 @@ package repository
import ( import (
"errors" "errors"
"testing" "testing"
"time"
"github.com/vaultdrop/backend/dbtest" "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) 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)
}
}
@@ -37,6 +37,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -48,19 +49,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.ui.components.FileCategoryIcon 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.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
@@ -82,6 +89,7 @@ fun PdfBuilderScreen(
var showNoteDialog by remember { mutableStateOf(false) } var showNoteDialog by remember { mutableStateOf(false) }
var showFileSheet by remember { mutableStateOf(false) } var showFileSheet by remember { mutableStateOf(false) }
var showNameDialog by remember { mutableStateOf(false) } var showNameDialog by remember { mutableStateOf(false) }
var previewFile by remember { mutableStateOf<FileEntity?>(null) }
// Nom suggéré par défaut : date du jour au format jour-mois-année. // Nom suggéré par défaut : date du jour au format jour-mois-année.
val defaultPdfName = remember { val defaultPdfName = remember {
@@ -171,6 +179,13 @@ fun PdfBuilderScreen(
) )
} }
previewFile?.let { file ->
FilePreviewDialog(
file = file,
onDismiss = { previewFile = null },
)
}
if (items.isEmpty()) { if (items.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -198,6 +213,9 @@ fun PdfBuilderScreen(
onMoveUp = { viewModel.moveUp(item.id) }, onMoveUp = { viewModel.moveUp(item.id) },
onMoveDown = { viewModel.moveDown(item.id) }, onMoveDown = { viewModel.moveDown(item.id) },
onRemove = { viewModel.removeItem(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, onMoveUp: () -> Unit,
onMoveDown: () -> Unit, onMoveDown: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onPreview: (() -> Unit)?,
) { ) {
val previewModifier = if (onPreview != null) Modifier.clickable(onClick = onPreview) else Modifier
Card( Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
@@ -379,6 +399,7 @@ private fun BuilderItemRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(previewModifier)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, 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 @Composable
private fun BuilderActions( private fun BuilderActions(
onAddFile: () -> Unit, onAddFile: () -> Unit,
@@ -176,5 +176,7 @@
<string name="pdf_builder_name_hint">ex. factures_janvier</string> <string name="pdf_builder_name_hint">ex. factures_janvier</string>
<string name="pdf_builder_name_save">Enregistrer</string> <string name="pdf_builder_name_save">Enregistrer</string>
<string name="pdf_builder_saved">PDF créé</string> <string name="pdf_builder_saved">PDF créé</string>
<string name="pdf_builder_preview">Aperçu</string>
<string name="pdf_builder_close_preview">Fermer l\'aperçu</string>
<string name="pdf_generated_folder">PDF générés</string> <string name="pdf_generated_folder">PDF générés</string>
</resources> </resources>