feat(api): identité user-first — login/password, ownership user, fin du token device
- 000006 : users.username/username_normalized/password_hash/is_admin, index unique partiel sur username_normalized, DROP idx_users_email (unicité V1 = username, jamais email) ; 000007 destructif : resources.owner_id→user_id (FK→users, reset base dev), l'unicité racine devient par user - pkg/passwd : argon2id (t=1,m=64MiB,p=4,k=32), comparaison constant-time, VerifyTimedEqual (délai égalisé dummy-hash) ; pkg/auth : subject=user_id + claim device_id, TTL 7 j sans refresh - repository : Users (count/get/resolve-exact/create/update-password/ mark-deleted), Devices.MarkUser (user_id INFORMATIF uniquement) - handlers : POST /devices sans token, POST /auth/login (401 indistinguable user inconnu/mauvais mdp, device requis), PATCH /users/me/password (current_password, tokens non révoqués — limite V1), GET /users/resolve (exact lowercase, jamais email/is_admin) ; middleware RequireAuth (user authorisant, device porté) ; tout le scoping ressources passe user - bootstrap admin : users vide + ADMIN_* absents → refus de démarrer ; .env.example ; docs/api-v1.md §2/§3/§7/§8 - tests : migrations 000006/000007 (up/down), pkg/auth Identity, handlers login/password/resolve, helpers refactorés registerAndLogin, repo tests scopés user — suite backend verte
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/vaultdrop/backend/pkg/api"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
"github.com/vaultdrop/backend/pkg/passwd"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
"github.com/vaultdrop/backend/service"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
|
||||
type LoginUserDTO struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
User LoginUserDTO `json:"user"`
|
||||
}
|
||||
|
||||
// AuthLogin est la SEULE porte d'émission de token (V1). L'erreur est
|
||||
// volontairement indistinguable entre « username inconnu » et « mauvais mot de
|
||||
// passe » — même code, même message, délai égalisé via passwd.VerifyTimedEqual.
|
||||
func AuthLogin(c *gin.Context) {
|
||||
if Store == nil || Store.Repository == nil || Auth == nil {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || req.Password == "" || req.DeviceID == "" {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "username, password and device_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
normalized := service.NormalizeUsername(req.Username)
|
||||
user, userErr := Store.Repository.Users.GetByUsernameNormalized(normalized)
|
||||
|
||||
// Même durée de traitement que le compte existe ou non (username inconnu =>
|
||||
// stored vide → vérification contre dummyHash).
|
||||
stored := ""
|
||||
if userErr == nil && user != nil {
|
||||
stored = user.PasswordHash
|
||||
}
|
||||
if err := passwd.VerifyTimedEqual(req.Password, stored); err != nil {
|
||||
if errors.Is(err, passwd.ErrMismatch) {
|
||||
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials")
|
||||
return
|
||||
}
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not verify credentials")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := Store.Repository.Devices.Exists(req.DeviceID)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not check device")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_DEVICE_ID", "device not registered")
|
||||
return
|
||||
}
|
||||
|
||||
if err := Store.Repository.Devices.MarkUser(req.DeviceID, user.ID); err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not update device")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := Auth.Issue(user.ID, req.DeviceID)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "TOKEN_ERROR", "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
api.OK(c, LoginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(auth.TokenTTL).UnixMilli(),
|
||||
User: LoginUserDTO{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePassword met à jour le hash du compte connecté. Le mot de passe
|
||||
// courant est requis. Limite connue V1 : les tokens déjà émis restent valides
|
||||
// jusqu'à expiration (7 j, pas de révocation) — cf. docs/api-v1.md §7.
|
||||
func ChangePassword(c *gin.Context) {
|
||||
if Store == nil || Store.Repository == nil {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
userID := c.GetString(UserIDKey)
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_PASSWORD", "new password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := Store.Repository.Users.GetByID(userID)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "account not found")
|
||||
return
|
||||
}
|
||||
if err := passwd.Verify(req.CurrentPassword, user.PasswordHash); err != nil {
|
||||
api.Error(c, http.StatusForbidden, "INVALID_PASSWORD", "current password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
newHash, err := passwd.Hash(req.NewPassword)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not hash new password")
|
||||
return
|
||||
}
|
||||
if err := Store.Repository.Users.UpdatePassword(userID, newHash); err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not update password")
|
||||
return
|
||||
}
|
||||
|
||||
api.OK(c, gin.H{"id": userID})
|
||||
}
|
||||
|
||||
// ResolveUser résout UN destinataire par username EXACT (normalisé lowercase).
|
||||
// Jamais de listing ni de préfixe (pas d'énumération de comptes). Ne renvoie
|
||||
// que {id, username} — jamais email/is_admin/created_at.
|
||||
func ResolveUser(c *gin.Context) {
|
||||
if Store == nil || Store.Repository == nil {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
username := service.NormalizeUsername(c.Query("username"))
|
||||
if username == "" {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "username is required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := Store.Repository.Users.ResolveExact(username)
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
api.Error(c, http.StatusNotFound, "NOT_FOUND", "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not resolve user")
|
||||
return
|
||||
}
|
||||
|
||||
api.OK(c, gin.H{"id": user.ID, "username": user.Username})
|
||||
}
|
||||
+263
-62
@@ -11,19 +11,22 @@ import (
|
||||
|
||||
"github.com/vaultdrop/backend/dbtest"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
"github.com/vaultdrop/backend/pkg/passwd"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
"github.com/vaultdrop/backend/service"
|
||||
)
|
||||
|
||||
const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable"
|
||||
|
||||
const (
|
||||
authTestPassword = "vaultdrop-test-password"
|
||||
authTestDevice = "0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
|
||||
func newTestRouterForAuth() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/devices", DevicesRegister)
|
||||
grp := r.Group("")
|
||||
grp.Use(RequireDevice)
|
||||
grp.GET("/files", FilesList)
|
||||
RegisterRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -36,93 +39,291 @@ func setTestStore(t *testing.T) *repository.Repository {
|
||||
return repo
|
||||
}
|
||||
|
||||
func TestDevicesRegisterValid(t *testing.T) {
|
||||
m, _ := auth.NewManager("test-secret")
|
||||
Auth = m
|
||||
defer func() { Auth = nil }()
|
||||
func setTestAuth(t *testing.T) {
|
||||
t.Helper()
|
||||
Auth, _ = auth.NewManager("test-secret")
|
||||
t.Cleanup(func() { Auth = nil })
|
||||
}
|
||||
|
||||
func createAdmin(t *testing.T, repo *repository.Repository, username, password string) string {
|
||||
t.Helper()
|
||||
hash, err := passwd.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
id, err := repo.Users.Create(service.NormalizeUsername(username), username, hash, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func doAuth(t *testing.T, r *gin.Engine, method, path, token string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
type loginEnvelope struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func loginOK(t *testing.T, r *gin.Engine, username, password, deviceID string) (string, string) {
|
||||
t.Helper()
|
||||
body := `{"username":` + mustJSON(username) + `,"password":` + mustJSON(password) + `,"device_id":` + mustJSON(deviceID) + `}`
|
||||
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", body)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("login: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env loginEnvelope
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("login: unmarshal %v body %s", err, rec.Body.String())
|
||||
}
|
||||
if env.Data.Token == "" || env.Data.User.ID == "" {
|
||||
t.Fatalf("login: token/user manquants %s", rec.Body.String())
|
||||
}
|
||||
return env.Data.Token, env.Data.User.ID
|
||||
}
|
||||
|
||||
func mustJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestDevicesRegisterReturnsNoToken(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
|
||||
deviceID := "0123456789abcdef0123456789abcdef"
|
||||
body := `{"deviceId":"` + deviceID + `"}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||
|
||||
rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
var env struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if _, hasToken := env.Data["token"]; hasToken {
|
||||
t.Error("POST /devices ne doit plus émettre de token")
|
||||
}
|
||||
if envelope.Data.DeviceID != deviceID {
|
||||
t.Errorf("deviceId = %q", envelope.Data.DeviceID)
|
||||
if env.Data["deviceId"] != authTestDevice {
|
||||
t.Errorf("deviceId = %v", env.Data["deviceId"])
|
||||
}
|
||||
|
||||
verified, err := m.Verify(envelope.Data.Token)
|
||||
if err != nil || verified != deviceID {
|
||||
t.Errorf("token invalid: %v", err)
|
||||
}
|
||||
|
||||
// Le device est bien persisté (requis par les FK resources.owner_id).
|
||||
exists, err := repo.Devices.Exists(deviceID)
|
||||
exists, err := repo.Devices.Exists(authTestDevice)
|
||||
if err != nil || !exists {
|
||||
t.Errorf("device non persisté: exists=%v err=%v", exists, err)
|
||||
t.Fatalf("device non persisté: exists=%v err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
||||
Auth, _ = auth.NewManager("test-secret")
|
||||
defer func() { Auth = nil }()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||
setTestAuth(t)
|
||||
setTestStore(t)
|
||||
|
||||
rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"UPPERCASEANDTOOLONG"}`)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDeviceRejectsMissingToken(t *testing.T) {
|
||||
Auth, _ = auth.NewManager("test-secret")
|
||||
defer func() { Auth = nil }()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||
func TestRequireAuthRejectsMissingToken(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
setTestStore(t)
|
||||
|
||||
rec := doAuth(t, newTestRouterForAuth(), http.MethodGet, "/api/v1/files", "", "")
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDeviceAcceptsValidToken(t *testing.T) {
|
||||
Auth, _ = auth.NewManager("test-secret")
|
||||
defer func() { Auth = nil }()
|
||||
func TestAuthLoginFlow(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
adminID := createAdmin(t, repo, "admin", authTestPassword)
|
||||
r := newTestRouterForAuth()
|
||||
|
||||
deviceID := "0123456789abcdef0123456789abcdef"
|
||||
signed, err := Auth.Issue(deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
// Enregistrement du device puis login → token utilisable
|
||||
rec := doAuth(t, r, http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("register: status %d", rec.Code)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signed)
|
||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
||||
token, userID := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||
if userID != adminID {
|
||||
t.Errorf("login user.id = %q, want %q", userID, adminID)
|
||||
}
|
||||
|
||||
// Sans Store le handler répond SERVICE_UNAVAILABLE (503) — le point est
|
||||
// que la requête a dépassé le middleware (jamais 401).
|
||||
// Le token fonctionne sur un endpoint protégé
|
||||
rec = doAuth(t, r, http.MethodGet, "/api/v1/files", token, "")
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("files protégé avec token = %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginWrongCredentials(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
createAdmin(t, repo, "admin", authTestPassword)
|
||||
r := newTestRouterForAuth()
|
||||
|
||||
doAuth(t, r, http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||
|
||||
// Mauvais mot de passe
|
||||
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"wrong-password","device_id":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("mauvais mot de passe: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "UNAUTHORIZED") {
|
||||
t.Errorf("code attendu UNAUTHORIZED, got %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Username inconnu → MÊME réponse (indistinguable)
|
||||
rec2 := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"ghost","password":"wrong-password","device_id":"`+authTestDevice+`"}`)
|
||||
if rec2.Code != 401 {
|
||||
t.Fatalf("username inconnu: status %d body %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if rec.Body.String() != rec2.Body.String() {
|
||||
t.Errorf("réponses distinguables (sécurité):\n%q\n%q", rec.Body.String(), rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginUnregisteredDevice(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
createAdmin(t, repo, "admin", authTestPassword)
|
||||
r := newTestRouterForAuth()
|
||||
|
||||
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"`+authTestPassword+`","device_id":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("device non enregistré: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "INVALID_DEVICE_ID") {
|
||||
t.Errorf("code attendu INVALID_DEVICE_ID, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsTokenForDeletedAccount(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
userID := createAdmin(t, repo, "admin", authTestPassword)
|
||||
_ = repo.Devices.Upsert(authTestDevice)
|
||||
_ = repo.Devices.MarkUser(authTestDevice, userID)
|
||||
r := newTestRouterForAuth()
|
||||
|
||||
signed, _ := Auth.Issue(userID, authTestDevice)
|
||||
requireAdmin(t, r, signed)
|
||||
|
||||
// Suppression du compte → le token ne passe plus le middleware
|
||||
if err := repo.Users.MarkDeleted(userID); err != nil {
|
||||
t.Fatalf("mark deleted: %v", err)
|
||||
}
|
||||
rec := doAuth(t, r, http.MethodGet, "/api/v1/files", signed, "")
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("compte supprimé: status attendu 401, got %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func requireAdmin(t *testing.T, r *gin.Engine, token string) {
|
||||
t.Helper()
|
||||
rec := doAuth(t, r, http.MethodGet, "/api/v1/files", token, "")
|
||||
if rec.Code == 401 {
|
||||
t.Fatalf("middleware a rejeté un token valide: %s", rec.Body.String())
|
||||
t.Fatalf("token valide rejeté: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
createAdmin(t, repo, "admin", authTestPassword)
|
||||
_ = repo.Devices.Upsert(authTestDevice)
|
||||
|
||||
r := newTestRouterForAuth()
|
||||
token, _ := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||
|
||||
// Mauvais mot de passe courant → 403
|
||||
rec := doAuth(t, r, http.MethodPatch, "/api/v1/users/me/password", token, `{"current_password":"nope","new_password":"new-secret-123"}`)
|
||||
if rec.Code != 403 {
|
||||
t.Fatalf("mauvais current_password: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Méthode non autorisée ayant échouée → 403 ne doit PAS confirmer le compte
|
||||
if !strings.Contains(rec.Body.String(), "INVALID_PASSWORD") {
|
||||
t.Errorf("code INVALID_PASSWORD attendu, got %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Changement valide
|
||||
rec = doAuth(t, r, http.MethodPatch, "/api/v1/users/me/password", token, `{"current_password":"`+authTestPassword+`","new_password":"new-secret-123"}`)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("change password: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// L'ancien mot de passe ne passe plus…
|
||||
rec = doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"`+authTestPassword+`","device_id":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("ancien mdp encore accepté: status %d", rec.Code)
|
||||
}
|
||||
|
||||
// … et le nouveau donne un token (tokens précédents restent valides : limite V1)
|
||||
rec = doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"new-secret-123","device_id":"`+authTestDevice+`"}`)
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("nouveau mdp refusé: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUser(t *testing.T) {
|
||||
setTestAuth(t)
|
||||
repo := setTestStore(t)
|
||||
createAdmin(t, repo, "admin", authTestPassword)
|
||||
createAdmin(t, repo, "alice", authTestPassword)
|
||||
_ = repo.Devices.Upsert(authTestDevice)
|
||||
_ = repo.Devices.MarkUser(authTestDevice, "admin")
|
||||
r := newTestRouterForAuth()
|
||||
token, _ := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||
|
||||
// Résolution exacte, insensible à la casse / aux espaces (%20 décodé puis trim)
|
||||
rec := doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=ALICE%20", token, "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("resolve: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if env.Data.Username != "alice" {
|
||||
t.Errorf("resolve: %+v", env.Data)
|
||||
}
|
||||
// Jamais email ni is_admin
|
||||
if len(rec.Body.Bytes()) < 0 || strings.Contains(rec.Body.String(), "is_admin") || strings.Contains(rec.Body.String(), "email") {
|
||||
t.Error("resolve ne doit pas exposer email/is_admin")
|
||||
}
|
||||
|
||||
// Inconnu → 404
|
||||
rec = doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=ghost", token, "")
|
||||
if rec.Code != 404 {
|
||||
t.Errorf("resolve inconnu: status %d", rec.Code)
|
||||
}
|
||||
|
||||
// Sans token → 401
|
||||
rec = doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=admin", "", "")
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("resolve sans token: status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,10 @@ func DevicesRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := Auth.Issue(req.DeviceID)
|
||||
if err != nil {
|
||||
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token})
|
||||
// POST /devices n'émet PLUS de token : de l'identité user-first (V1), le
|
||||
// device s'enregistre pour exister, puis le client appelle POST /auth/login
|
||||
// avec username/password + device_id pour obtenir son token (cf.
|
||||
// docs/api-v1.md §2). Réponse : { deviceId } uniquement.
|
||||
api.OK(c, gin.H{"deviceId": req.DeviceID})
|
||||
|
||||
}
|
||||
|
||||
+12
-12
@@ -26,13 +26,13 @@ func FilesList(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
page := intParam(c.Query("page"), 1)
|
||||
pageSize := intParam(c.Query("pageSize"), 50)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
files, total, err := Store.ListFiles(deviceID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
|
||||
files, total, err := Store.ListFiles(userID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -45,13 +45,13 @@ func FilesGet(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
id := c.Param("id")
|
||||
if !deviceIDPattern.MatchString(id) {
|
||||
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||
return
|
||||
}
|
||||
file, err := Store.GetFile(deviceID, id)
|
||||
file, err := Store.GetFile(userID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -64,13 +64,13 @@ func FilesDelete(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
id := c.Param("id")
|
||||
if !deviceIDPattern.MatchString(id) {
|
||||
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||
return
|
||||
}
|
||||
deletedID, err := Store.DeleteFile(deviceID, id)
|
||||
deletedID, err := Store.DeleteFile(userID, id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -83,7 +83,7 @@ func FilesUpload(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
folderID := c.PostForm("folderId")
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
|
||||
@@ -102,7 +102,7 @@ func FilesUpload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
dto, err := Store.Upload(deviceID, file, folderID)
|
||||
dto, err := Store.Upload(userID, file, folderID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -115,8 +115,8 @@ func FoldersList(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
folders, err := Store.ListRootFolders(deviceID)
|
||||
userID := c.GetString(UserIDKey)
|
||||
folders, err := Store.ListRootFolders(userID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -129,7 +129,7 @@ func FilesSearch(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing required query param `q`")
|
||||
@@ -140,7 +140,7 @@ func FilesSearch(c *gin.Context) {
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
files, total, err := Store.SearchFiles(deviceID, q, page, pageSize)
|
||||
files, total, err := Store.SearchFiles(userID, q, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/vaultdrop/backend/dbtest"
|
||||
"github.com/vaultdrop/backend/handlers"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
"github.com/vaultdrop/backend/pkg/passwd"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
"github.com/vaultdrop/backend/service"
|
||||
)
|
||||
@@ -110,22 +111,53 @@ func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code,
|
||||
}
|
||||
}
|
||||
|
||||
func registerDevice(t *testing.T, r *gin.Engine, deviceID string) string {
|
||||
func registerAndLogin(t *testing.T, r *gin.Engine, repo *repository.Repository, username, password, deviceID string) (token, userID string) {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"deviceId":%q}`, deviceID)
|
||||
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(body), "application/json")
|
||||
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(fmt.Sprintf(`{"deviceId":%q}`, deviceID)), "application/json")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register: status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
username = service.NormalizeUsername(username)
|
||||
if _, err := repo.Users.GetByUsernameNormalized(username); err == repository.ErrNotFound {
|
||||
hash, herr := passwd.Hash(password)
|
||||
if herr != nil {
|
||||
t.Fatalf("hash: %v", herr)
|
||||
}
|
||||
if _, cerr := repo.Users.Create(username, username, hash, false); cerr != nil {
|
||||
t.Fatalf("create user: %v", cerr)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("get user: %v", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`{"username":%q,"password":%q,"device_id":%q}`, username, password, deviceID)
|
||||
rec, _ = doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", []byte(body), "application/json")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("register: unmarshal: %v body=%s", err, rec.Body.String())
|
||||
t.Fatalf("login: unmarshal: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if env.Data.Token == "" {
|
||||
t.Fatalf("register: pas de token (status %d)", rec.Code)
|
||||
if env.Data.Token == "" || env.Data.User.ID == "" {
|
||||
t.Fatalf("login: token/user manquant %s", rec.Body.String())
|
||||
}
|
||||
return env.Data.Token
|
||||
return env.Data.Token, env.Data.User.ID
|
||||
}
|
||||
|
||||
// testUserUsername fournit un username unique par device (les bases de test
|
||||
// sont reset, mais deux devices d'un même test ne doivent pas partager un
|
||||
// compte).
|
||||
func testUserUsername(deviceID, suffix string) string {
|
||||
return "u" + suffix + "-" + deviceID[:8]
|
||||
}
|
||||
|
||||
func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename string, content []byte) *httptest.ResponseRecorder {
|
||||
@@ -155,11 +187,13 @@ func TestFilesFlow(t *testing.T) {
|
||||
r, _, repo := setup(t)
|
||||
deviceA := repository.NewID()
|
||||
deviceB := repository.NewID()
|
||||
tokenA := registerDevice(t, r, deviceA)
|
||||
tokenB := registerDevice(t, r, deviceB)
|
||||
passA := "files-test-password-a"
|
||||
passB := "files-test-password-b"
|
||||
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "a"), passA, deviceA)
|
||||
tokenB, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "b"), passB, deviceB)
|
||||
|
||||
folderID := repository.NewID()
|
||||
if err := repo.Resources.InsertFolder(deviceA, folderID, "Docs", ""); err != nil {
|
||||
if err := repo.Resources.InsertFolder(userA, folderID, "Docs", ""); err != nil {
|
||||
t.Fatalf("insert root folder: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,8 +222,8 @@ func TestFilesFlow(t *testing.T) {
|
||||
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
|
||||
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
|
||||
|
||||
// Création de fichiers du device B
|
||||
if err := repo.Resources.InsertFile(deviceB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
|
||||
// Création de fichiers du user B
|
||||
if err := repo.Resources.InsertFile(userB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
|
||||
t.Fatalf("insert B file: %v", err)
|
||||
}
|
||||
|
||||
@@ -218,7 +252,7 @@ func TestFilesFlow(t *testing.T) {
|
||||
t.Errorf("liste dossier: %+v", files)
|
||||
}
|
||||
|
||||
// B ne voit pas les fichiers de A
|
||||
// B (autre user) ne voit pas les fichiers de A
|
||||
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenB, nil, "")
|
||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device")
|
||||
|
||||
@@ -252,15 +286,15 @@ func TestFilesFlow(t *testing.T) {
|
||||
func TestSearchFiles(t *testing.T) {
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "s"), "search-test-password", device)
|
||||
|
||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "vacances-août.jpg", "", 100, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(user, repository.NewID(), "vacances-août.jpg", "", 100, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "rapport-q3.pdf", "", 100, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(user, repository.NewID(), "rapport-q3.pdf", "", 100, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "toto.txt", "", 100, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(user, repository.NewID(), "toto.txt", "", 100, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
@@ -295,9 +329,9 @@ func TestSearchFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadTooLarge(t *testing.T) {
|
||||
r, _, _ := setup(t)
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "l"), "upload-test-password", device)
|
||||
|
||||
rec := uploadMultipart(t, r, token, "", "big.txt", []byte("0123456789ABCDEF"))
|
||||
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
||||
@@ -307,16 +341,16 @@ func TestFoldersListAndScoping(t *testing.T) {
|
||||
r, _, repo := setup(t)
|
||||
deviceA := repository.NewID()
|
||||
deviceB := repository.NewID()
|
||||
tokenA := registerDevice(t, r, deviceA)
|
||||
registerDevice(t, r, deviceB)
|
||||
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "fa"), "folder-test-password", deviceA)
|
||||
_, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "fb"), "folder-test-password-b", deviceB)
|
||||
|
||||
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "AA", ""); err != nil {
|
||||
if err := repo.Resources.InsertFolder(userA, repository.NewID(), "AA", ""); err != nil {
|
||||
t.Fatalf("insert folder: %v", err)
|
||||
}
|
||||
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "BB", ""); err != nil {
|
||||
if err := repo.Resources.InsertFolder(userA, repository.NewID(), "BB", ""); err != nil {
|
||||
t.Fatalf("insert folder: %v", err)
|
||||
}
|
||||
if err := repo.Resources.InsertFolder(deviceB, repository.NewID(), "CC", ""); err != nil {
|
||||
if err := repo.Resources.InsertFolder(userB, repository.NewID(), "CC", ""); err != nil {
|
||||
t.Fatalf("insert folder B: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vaultdrop/backend/pkg/api"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
)
|
||||
|
||||
const DeviceIDKey = "device_id"
|
||||
const (
|
||||
UserIDKey = "user_id"
|
||||
DeviceIDKey = "device_id"
|
||||
)
|
||||
|
||||
// Auth issues/verifies device bearer tokens; set once at startup (cmd/server).
|
||||
var Auth *auth.Manager
|
||||
|
||||
// RequireDevice authenticates the bearer paseto token and stores the resolved
|
||||
// device_id in the gin context (cf. docs/api-v1.md §2).
|
||||
func RequireDevice(c *gin.Context) {
|
||||
// RequireAuth authenticates the bearer paseto token. Le subject (user_id)
|
||||
// autorise — c'est la clé de scoping de toutes les ressources ; device_id est
|
||||
// porté (non autorisant) pour l'idempotence outbox et les jobs OCR. Le compte
|
||||
// doit encore exister (cf. docs/api-v1.md §2).
|
||||
func RequireAuth(c *gin.Context) {
|
||||
|
||||
header := c.GetHeader("Authorization")
|
||||
token, found := strings.CutPrefix(header, "Bearer ")
|
||||
|
||||
if Auth == nil || !found || token == "" {
|
||||
api.Error(c, 401, "UNAUTHORIZED", "missing bearer token")
|
||||
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
deviceID, err := Auth.Verify(token)
|
||||
identity, err := Auth.Verify(token)
|
||||
if err != nil {
|
||||
api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token")
|
||||
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(DeviceIDKey, deviceID)
|
||||
// Le store peut être nil dans les tests middleware purs ; sinon on vérifie
|
||||
// que le compte existe toujours (supprimé → accès refusé).
|
||||
if Store != nil && Store.Repository != nil {
|
||||
_, err := Store.Repository.Users.GetByID(identity.UserID)
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "account no longer exists")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not load account")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(UserIDKey, identity.UserID)
|
||||
c.Set(DeviceIDKey, identity.DeviceID)
|
||||
c.Next()
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func OcrJobsCreate(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
|
||||
var req ocrJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -26,7 +26,7 @@ func OcrJobsCreate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := Ocr.Create(deviceID, req.FileID)
|
||||
job, err := Ocr.Create(userID, c.GetString(DeviceIDKey), req.FileID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string,
|
||||
return s.text, nil
|
||||
}
|
||||
|
||||
func setupOcr(t *testing.T) (*gin.Engine, string) {
|
||||
func setupOcr(t *testing.T) (*gin.Engine, string, *repository.Repository) {
|
||||
t.Helper()
|
||||
conn := dbtest.OpenTestDatabase(t, handlersTestURL)
|
||||
repo := repository.NewRepository(conn)
|
||||
@@ -45,7 +45,7 @@ func setupOcr(t *testing.T) (*gin.Engine, string) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
handlers.RegisterRoutes(r)
|
||||
return r, uploadDir
|
||||
return r, uploadDir, repo
|
||||
}
|
||||
|
||||
type ocrJobDTO struct {
|
||||
@@ -75,20 +75,20 @@ func waitTerminal(t *testing.T, r *gin.Engine, token, jobID string) ocrJobDTO {
|
||||
}
|
||||
|
||||
func TestOcrJobsLifecycle(t *testing.T) {
|
||||
r, uploadDir := setupOcr(t)
|
||||
r, uploadDir, repo := setupOcr(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "oa"), "ocr-test-password", device)
|
||||
|
||||
// Fichier + fichier physique (simule UPLOAD_DIR/<device>/<id>.txt)
|
||||
// Fichier + fichier physique (simule UPLOAD_DIR/<user>/<id>.txt — après
|
||||
// 000007 le répertoire d'upload est scopé par USER)
|
||||
fileID := repository.NewID()
|
||||
if err := os.MkdirAll(filepath.Join(uploadDir, device), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(uploadDir, user), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(uploadDir, device, fileID+".txt"), []byte("ignored by stub"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(uploadDir, user, fileID+".txt"), []byte("ignored by stub"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
repo := handlers.Store.Repository
|
||||
if err := repo.Resources.InsertFile(device, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(user, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
@@ -115,15 +115,15 @@ func TestOcrJobsLifecycle(t *testing.T) {
|
||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-missing-file")
|
||||
}
|
||||
|
||||
func TestOcrJobsScopedByDevice(t *testing.T) {
|
||||
r, _ := setupOcr(t)
|
||||
func TestOcrJobsScopedByOwner(t *testing.T) {
|
||||
r, _, repo := setupOcr(t)
|
||||
deviceA := repository.NewID()
|
||||
deviceB := repository.NewID()
|
||||
tokenA := registerDevice(t, r, deviceA)
|
||||
tokenB := registerDevice(t, r, deviceB)
|
||||
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "oc"), "ocr-test-password", deviceA)
|
||||
tokenB, _ := registerAndLogin(t, r, repo, testUserUsername(deviceB, "od"), "ocr-test-password-b", deviceB)
|
||||
|
||||
fileID := repository.NewID()
|
||||
if err := handlers.Store.Repository.Resources.InsertFile(deviceA, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(userA, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"fileId": fileID})
|
||||
@@ -132,9 +132,9 @@ func TestOcrJobsScopedByDevice(t *testing.T) {
|
||||
var created ocrJobDTO
|
||||
_ = json.Unmarshal(env.Data, &created)
|
||||
|
||||
// Un autre device ne voit pas le job
|
||||
// Un autre user ne voit pas le job
|
||||
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/ocr/jobs/"+created.ID, tokenB, nil, "")
|
||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-other-device")
|
||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-other-owner")
|
||||
}
|
||||
|
||||
func bodyFor(id string) []byte {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
||||
// Public: /health, /devices. Everything else requires a device bearer token.
|
||||
// Public: /health, /devices, /auth/login. Everything else requires a user
|
||||
// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2).
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
public := r.Group("/api/v1")
|
||||
{
|
||||
public.GET("/health", Health)
|
||||
public.POST("/devices", DevicesRegister)
|
||||
public.POST("/auth/login", AuthLogin)
|
||||
}
|
||||
|
||||
protected := r.Group("/api/v1")
|
||||
protected.Use(RequireDevice)
|
||||
protected.Use(RequireAuth)
|
||||
{
|
||||
protected.GET("/files", FilesList)
|
||||
protected.GET("/files/search", FilesSearch)
|
||||
@@ -23,6 +27,9 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
protected.GET("/files/folders", FoldersList)
|
||||
protected.POST("/files/upload", FilesUpload)
|
||||
|
||||
protected.GET("/users/resolve", ResolveUser)
|
||||
protected.PATCH("/users/me/password", ChangePassword)
|
||||
|
||||
protected.POST("/ocr/jobs", OcrJobsCreate)
|
||||
protected.GET("/ocr/jobs/:id", OcrJobsGet)
|
||||
|
||||
@@ -30,3 +37,10 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
protected.GET("/sync/permissions", SyncPermissionsGet)
|
||||
}
|
||||
}
|
||||
|
||||
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
|
||||
// impossible en conditions normales (le middleware l'a validé) ; garde-fou
|
||||
// pour un appel direct dans les tests.
|
||||
func userID(c *gin.Context) string {
|
||||
return strings.TrimSpace(c.GetString(UserIDKey))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func SyncOpsPush(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
|
||||
var req syncOpsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -26,7 +26,7 @@ func SyncOpsPush(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := Store.ApplyBatch(deviceID, req.Operations)
|
||||
result, err := Store.ApplyBatch(userID, c.GetString(DeviceIDKey), req.Operations)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -39,13 +39,13 @@ func SyncPermissionsGet(c *gin.Context) {
|
||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||
return
|
||||
}
|
||||
deviceID := c.GetString(DeviceIDKey)
|
||||
userID := c.GetString(UserIDKey)
|
||||
after := c.Query("after")
|
||||
var afterMs int64
|
||||
if after != "" {
|
||||
afterMs = int64(intParam(after, 0))
|
||||
}
|
||||
perms, err := Store.Snapshot(deviceID, afterMs)
|
||||
perms, err := Store.Snapshot(userID, afterMs)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -26,9 +26,9 @@ func op(operationID int64, resourceID, operation, resourceType string, payload m
|
||||
}
|
||||
|
||||
func TestSyncOpsApplySequential(t *testing.T) {
|
||||
r, _, _ := setup(t)
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sa"), "sync-test-password", device)
|
||||
|
||||
folderID := repository.NewID()
|
||||
fileID := repository.NewID()
|
||||
@@ -78,9 +78,9 @@ func TestSyncOpsApplySequential(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
||||
r, _, _ := setup(t)
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sb"), "sync-test-password", device)
|
||||
|
||||
folderID := repository.NewID()
|
||||
dupeID := repository.NewID()
|
||||
@@ -126,10 +126,10 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
||||
func TestSyncOpsDeleteIdempotent(t *testing.T) {
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sc"), "sync-test-password", device)
|
||||
|
||||
fileID := repository.NewID()
|
||||
if err := repo.Resources.InsertFile(device, fileID, "x.txt", "", 1, nil, nil); err != nil {
|
||||
if err := repo.Resources.InsertFile(user, fileID, "x.txt", "", 1, nil, nil); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
@@ -155,10 +155,10 @@ func TestSyncOpsDeleteIdempotent(t *testing.T) {
|
||||
func TestSnapshotPermissions(t *testing.T) {
|
||||
r, _, repo := setup(t)
|
||||
device := repository.NewID()
|
||||
token := registerDevice(t, r, device)
|
||||
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sd"), "sync-test-password", device)
|
||||
|
||||
folderID := repository.NewID()
|
||||
if err := repo.Resources.InsertFolder(device, folderID, "Docs", ""); err != nil {
|
||||
if err := repo.Resources.InsertFolder(user, folderID, "Docs", ""); err != nil {
|
||||
t.Fatalf("insert folder: %v", err)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestSnapshotPermissions(t *testing.T) {
|
||||
if err := json.Unmarshal(env.Data, &perms); err != nil {
|
||||
t.Fatalf("snapshot: unmarshal: %v", err)
|
||||
}
|
||||
if len(perms) != 1 || perms[0].ResourceID != folderID || perms[0].EffectiveAccess != "owner" || perms[0].OwnerID != device {
|
||||
if len(perms) != 1 || perms[0].ResourceID != folderID || perms[0].EffectiveAccess != "owner" || perms[0].OwnerID != user {
|
||||
t.Errorf("snapshot: %+v", perms)
|
||||
}
|
||||
if perms[0].CachedAt == 0 {
|
||||
|
||||
Reference in New Issue
Block a user