From 824871bd245d19712c5842654a5cca50770d38d2 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 10:26:31 +0200 Subject: [PATCH] Espaces personnels et favoris : API par utilisateur, /api/me.id, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chaque route de schémas passe par la vue de l'utilisateur connecté (storage.Space) : arborescence commune, .perso et .favoris. - /api/public résout aussi les liens des espaces personnels. - /api/me renvoie id (dossier de l'utilisateur : email OIDC, sinon « local »). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0174sFqehj4uGqJHQZvWy1gn --- cmd/server/main.go | 22 ++- cmd/server/main_test.go | 2 +- internal/api/api.go | 161 ++++++++++++++++--- internal/api/api_test.go | 89 +++++++++++ internal/storage/spaces_test.go | 269 ++++++++++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 29 deletions(-) create mode 100644 internal/api/api_test.go create mode 100644 internal/storage/spaces_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index ff121bd..a653181 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -90,7 +90,8 @@ func main() { } mux := http.NewServeMux() - (&api.Handler{Store: store, MaxBody: maxMB << 20, Update: checker}).Register(mux) + spaces := storage.NewSpaces(store) + (&api.Handler{Store: store, Spaces: spaces, User: userID, MaxBody: maxMB << 20, Update: checker}).Register(mux) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) @@ -168,11 +169,28 @@ func authenticate(ctx context.Context, mux *http.ServeMux, dataDir string) (http } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(m) + // id : identifiant de l'utilisateur, qui nomme son espace personnel. + _ = json.NewEncoder(w).Encode(struct { + auth.Me + ID string `json:"id"` + }{m, userID(r)}) }) return h, nil } +// userID identifie l'utilisateur d'une requête pour son espace personnel et +// ses favoris : email OIDC (sinon sub), utilisateur unique storage.LocalUser +// en authentification locale ou sans authentification. +func userID(r *http.Request) string { + if u := auth.UserFrom(r.Context()); u != nil { + if u.Email != "" { + return storage.UserID(u.Email) + } + return storage.UserID("sub-" + u.Sub) + } + return storage.LocalUser +} + // oidcConfig lit les variables OIDC_* et SESSION_*. func oidcConfig(dataDir string) (auth.Config, error) { cfg := auth.Config{ diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 786d10e..fe40153 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -54,7 +54,7 @@ func TestAuthenticateModes(t *testing.T) { t.Setenv("AUTH_MODE", "") t.Setenv("AUTH_USER", "") h, err := authenticate(context.Background(), http.NewServeMux(), t.TempDir()) - if err != nil || me(t, h, "") != `{"mode":"none"}` { + if err != nil || me(t, h, "") != `{"mode":"none","id":"local"}` { t.Fatalf("%v %s", err, me(t, h, "")) } }) diff --git a/internal/api/api.go b/internal/api/api.go index c3193e0..56f50b3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -26,6 +26,10 @@ // GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal) // GET /api/logo / PUT (image/png) / DELETE logo 64x64 // GET /api/update dernière version d'Excalidraw sur npm +// +// Les chemins sont vus par l'utilisateur connecté (storage.Space) : l'arborescence +// commune, plus ".perso/..." (son espace personnel) et ".favoris" (ses favoris, +// liens symboliques vers les originaux ; DELETE .favoris/x retire le lien). package api import ( @@ -45,9 +49,26 @@ import ( // Handler regroupe les routes de l'API. type Handler struct { - Store *storage.Store - MaxBody int64 // taille max d'un schéma (images embarquées comprises) - Update *update.Checker // nil : vérification des mises à jour désactivée + Store *storage.Store // arborescence commune : bibliothèque, paramètres, logo + Spaces *storage.Spaces // schémas : arborescence commune, espaces personnels, favoris + User func(*http.Request) string // identifiant de l'utilisateur (storage.UserID) + MaxBody int64 // taille max d'un schéma (images embarquées comprises) + Update *update.Checker // nil : vérification des mises à jour désactivée +} + +// space renvoie la vue de l'utilisateur de la requête (espace personnel et +// favoris) ; utilisateur unique LocalUser sans OIDC. +func (h *Handler) space(w http.ResponseWriter, r *http.Request) (*storage.Space, bool) { + id := storage.LocalUser + if h.User != nil { + id = h.User(r) + } + u, err := h.Spaces.For(id) + if err != nil { + fail(w, err) + return nil, false + } + return u, true } // Register enregistre les routes sur mux (motifs Go 1.22+). @@ -138,7 +159,11 @@ func (h *Handler) decode(w http.ResponseWriter, r *http.Request, v any) bool { } func (h *Handler) list(w http.ResponseWriter, r *http.Request) { - entries, err := h.Store.List(r.URL.Query().Get("path")) + u, ok := h.space(w, r) + if !ok { + return + } + entries, err := u.List(r.URL.Query().Get("path")) if err != nil { fail(w, err) return @@ -147,9 +172,13 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) { } func (h *Handler) search(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } q := r.URL.Query() f := storage.Filter{Tag: q.Get("tag"), Favorite: q.Get("fav") == "true"} - entries, err := h.Store.Search(q.Get("q"), f, 200) + entries, err := u.Search(q.Get("q"), f, 200) if err != nil { fail(w, err) return @@ -158,7 +187,11 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) { } func (h *Handler) tags(w http.ResponseWriter, r *http.Request) { - tags, err := h.Store.Tags() + u, ok := h.space(w, r) + if !ok { + return + } + tags, err := u.Tags() if err != nil { fail(w, err) return @@ -187,11 +220,15 @@ func (h *Handler) decodeMeta(w http.ResponseWriter, r *http.Request) (metaReq, b } func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } req, ok := h.decodeMeta(w, r) if !ok { return } - if err := h.Store.SetFavorite(req.Paths, req.Favorite); err != nil { + if err := u.SetFavorite(req.Paths, req.Favorite); err != nil { fail(w, err) return } @@ -199,11 +236,15 @@ func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request) { } func (h *Handler) editTags(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } req, ok := h.decodeMeta(w, r) if !ok { return } - if err := h.Store.EditTags(req.Paths, req.Add, req.Remove); err != nil { + if err := u.EditTags(req.Paths, req.Add, req.Remove); err != nil { fail(w, err) return } @@ -211,7 +252,11 @@ func (h *Handler) editTags(w http.ResponseWriter, r *http.Request) { } func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) { - data, etag, err := h.Store.Read(r.URL.Query().Get("path")) + u, ok := h.space(w, r) + if !ok { + return + } + data, etag, err := u.Read(r.URL.Query().Get("path")) if err != nil { fail(w, err) return @@ -227,13 +272,17 @@ func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) { } func (h *Handler) writeFile(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } data, err := h.readBody(w, r) if err != nil { fail(w, err) return } create := r.Header.Get("If-None-Match") == "*" - entry, etag, err := h.Store.Write(r.URL.Query().Get("path"), data, r.Header.Get("If-Match"), create) + entry, etag, err := u.Write(r.URL.Query().Get("path"), data, r.Header.Get("If-Match"), create) if err != nil { fail(w, err) return @@ -252,11 +301,15 @@ type moveReq struct { } func (h *Handler) mkdir(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req pathReq if !h.decode(w, r, &req) { return } - entry, err := h.Store.Mkdir(req.Path) + entry, err := u.Mkdir(req.Path) if err != nil { fail(w, err) return @@ -265,11 +318,15 @@ func (h *Handler) mkdir(w http.ResponseWriter, r *http.Request) { } func (h *Handler) move(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req moveReq if !h.decode(w, r, &req) { return } - entry, err := h.Store.Move(req.From, req.To) + entry, err := u.Move(req.From, req.To) if err != nil { fail(w, err) return @@ -285,6 +342,10 @@ type moveManyReq struct { // moveMany renvoie toujours 200 avec le détail par élément : le client // affiche les échecs partiels (ex. nom déjà pris dans la destination). func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req moveManyReq if !h.decode(w, r, &req) { return @@ -293,7 +354,7 @@ func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus") return } - res, err := h.Store.MoveMany(req.Paths, req.Dir) + res, err := u.MoveMany(req.Paths, req.Dir) if err != nil { fail(w, err) return @@ -302,7 +363,11 @@ func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { } func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) { - data, mod, err := h.Store.ReadThumb(r.URL.Query().Get("path")) + u, ok := h.space(w, r) + if !ok { + return + } + data, mod, err := u.ReadThumb(r.URL.Query().Get("path")) if err != nil { // Pas de mise en cache d'une absence : la miniature va être générée. w.Header().Set("Cache-Control", "no-store") @@ -317,9 +382,13 @@ func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) { } func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxThumbSize)) if err == nil { - err = h.Store.WriteThumb(r.URL.Query().Get("path"), data) + err = u.WriteThumb(r.URL.Query().Get("path"), data) } if err != nil { fail(w, err) @@ -329,11 +398,15 @@ func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) { } func (h *Handler) copy(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req moveReq if !h.decode(w, r, &req) { return } - entry, err := h.Store.Copy(req.From, req.To) + entry, err := u.Copy(req.From, req.To) if err != nil { fail(w, err) return @@ -342,8 +415,12 @@ func (h *Handler) copy(w http.ResponseWriter, r *http.Request) { } func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } q := r.URL.Query() - if err := h.Store.Delete(q.Get("path"), q.Get("recursive") == "true"); err != nil { + if err := u.Delete(q.Get("path"), q.Get("recursive") == "true"); err != nil { fail(w, err) return } @@ -351,7 +428,11 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { } func (h *Handler) history(w http.ResponseWriter, r *http.Request) { - vs, err := h.Store.Versions(r.URL.Query().Get("path")) + u, ok := h.space(w, r) + if !ok { + return + } + vs, err := u.Versions(r.URL.Query().Get("path")) if err != nil { fail(w, err) return @@ -361,8 +442,12 @@ func (h *Handler) history(w http.ResponseWriter, r *http.Request) { } func (h *Handler) readVersion(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } q := r.URL.Query() - data, err := h.Store.ReadVersion(q.Get("path"), q.Get("id")) + data, err := u.ReadVersion(q.Get("path"), q.Get("id")) if err != nil { fail(w, err) return @@ -374,11 +459,15 @@ func (h *Handler) readVersion(w http.ResponseWriter, r *http.Request) { } func (h *Handler) snapshot(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req pathReq if !h.decode(w, r, &req) { return } - v, err := h.Store.Snapshot(req.Path) + v, err := u.Snapshot(req.Path) if err != nil { fail(w, err) return @@ -392,11 +481,15 @@ type restoreReq struct { } func (h *Handler) restore(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req restoreReq if !h.decode(w, r, &req) { return } - entry, etag, err := h.Store.Restore(req.Path, req.ID) + entry, etag, err := u.Restore(req.Path, req.ID) if err != nil { fail(w, err) return @@ -406,7 +499,11 @@ func (h *Handler) restore(w http.ResponseWriter, r *http.Request) { } func (h *Handler) shares(w http.ResponseWriter, r *http.Request) { - l, err := h.Store.Shares(r.URL.Query().Get("path")) + u, ok := h.space(w, r) + if !ok { + return + } + l, err := u.Shares(r.URL.Query().Get("path")) if err != nil { fail(w, err) return @@ -421,6 +518,10 @@ type shareReq struct { } func (h *Handler) createShare(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } var req shareReq if !h.decode(w, r, &req) { return @@ -429,7 +530,7 @@ func (h *Handler) createShare(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "durée de 0 (sans expiration) à 3650 jours") return } - sh, err := h.Store.CreateShare(req.Path, time.Duration(req.Days)*24*time.Hour) + sh, err := u.CreateShare(req.Path, time.Duration(req.Days)*24*time.Hour) if err != nil { fail(w, err) return @@ -438,7 +539,11 @@ func (h *Handler) createShare(w http.ResponseWriter, r *http.Request) { } func (h *Handler) revokeShare(w http.ResponseWriter, r *http.Request) { - if err := h.Store.RevokeShare(r.URL.Query().Get("id")); err != nil { + u, ok := h.space(w, r) + if !ok { + return + } + if err := u.RevokeShare(r.URL.Query().Get("id")); err != nil { fail(w, err) return } @@ -446,7 +551,11 @@ func (h *Handler) revokeShare(w http.ResponseWriter, r *http.Request) { } func (h *Handler) revokeAllShares(w http.ResponseWriter, r *http.Request) { - if err := h.Store.RevokeAllShares(); err != nil { + u, ok := h.space(w, r) + if !ok { + return + } + if err := u.RevokeAllShares(); err != nil { fail(w, err) return } @@ -458,7 +567,7 @@ func (h *Handler) revokeAllShares(w http.ResponseWriter, r *http.Request) { func (h *Handler) public(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Robots-Tag", "noindex, nofollow") - sh, data, err := h.Store.ReadShared(r.PathValue("token")) + sh, data, err := h.Spaces.ReadShared(r.PathValue("token")) if err != nil { if errors.Is(err, storage.ErrNotFound) { writeErr(w, http.StatusNotFound, "lien invalide, expiré ou révoqué") diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..223e44a --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vblogio/excalidraw-go/internal/storage" +) + +const doc = `{"type":"excalidraw","version":2,"elements":[]}` + +// L'utilisateur vient de l'en-tête X-User (à la place de la session OIDC). +func newServer(t *testing.T) *httptest.Server { + t.Helper() + st, err := storage.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + (&Handler{ + Store: st, + Spaces: storage.NewSpaces(st), + User: func(r *http.Request) string { return storage.UserID(r.Header.Get("X-User")) }, + MaxBody: 1 << 20, + }).Register(mux) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func call(t *testing.T, srv *httptest.Server, user, method, url, body string, out any) int { + t.Helper() + req, _ := http.NewRequest(method, srv.URL+url, strings.NewReader(body)) + req.Header.Set("X-User", user) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if out != nil { + _ = json.NewDecoder(res.Body).Decode(out) + } + return res.StatusCode +} + +func TestFavoritesPerUser(t *testing.T) { + srv := newServer(t) + for _, p := range []string{"a.excalidraw", ".perso/mine.excalidraw"} { + if c := call(t, srv, "alice@x.fr", "PUT", "/api/file?path="+p, doc, nil); c != 200 { + t.Fatalf("écriture %s : %d", p, c) + } + } + if c := call(t, srv, "alice@x.fr", "POST", "/api/meta/favorite", `{"paths":["a.excalidraw",".perso/mine.excalidraw"],"favorite":true}`, nil); c != 204 { + t.Fatalf("favori : %d", c) + } + var favs []storage.Entry + call(t, srv, "alice@x.fr", "GET", "/api/list?path=.favoris", "", &favs) + if len(favs) != 2 || favs[0].Path != "a.excalidraw" || favs[1].Path != ".perso/mine.excalidraw" { + t.Fatalf("favoris d'alice = %+v", favs) + } + call(t, srv, "bob@x.fr", "GET", "/api/list?path=.favoris", "", &favs) + if len(favs) != 0 { + t.Fatalf("favoris de bob = %+v", favs) + } + var root []storage.Entry + call(t, srv, "bob@x.fr", "GET", "/api/list?path=", "", &root) + if len(root) != 3 || root[0].Special != "favoris" || root[2].Favorite { + t.Fatalf("racine de bob = %+v", root) + } + if c := call(t, srv, "bob@x.fr", "GET", "/api/file?path=.perso/mine.excalidraw", "", nil); c != 404 { + t.Fatalf("bob lit l'espace d'alice : %d", c) + } + // Supprimer depuis Favoris ne retire que le lien. + call(t, srv, "alice@x.fr", "DELETE", "/api/entry?path=.favoris/a.excalidraw", "", nil) + if c := call(t, srv, "alice@x.fr", "GET", "/api/file?path=a.excalidraw", "", nil); c != 200 { + t.Fatalf("original supprimé avec le lien : %d", c) + } + call(t, srv, "alice@x.fr", "GET", "/api/list?path=.favoris", "", &favs) + if len(favs) != 1 { + t.Fatalf("favoris après retrait = %+v", favs) + } + // Écrire dans .favoris est refusé. + if c := call(t, srv, "alice@x.fr", "PUT", "/api/file?path=.favoris/x.excalidraw", doc, nil); c != 400 { + t.Fatalf("écriture dans Favoris : %d", c) + } +} diff --git a/internal/storage/spaces_test.go b/internal/storage/spaces_test.go new file mode 100644 index 0000000..74642eb --- /dev/null +++ b/internal/storage/spaces_test.go @@ -0,0 +1,269 @@ +package storage + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestUserID(t *testing.T) { + for in, want := range map[string]string{ + " Cedric.Killy@VLab.bzh ": "cedric.killy@vlab.bzh", + "jean dupont/../x": "jean_dupont_.._x", + "..": "_.", + "": LocalUser, + } { + if got := UserID(in); got != want { + t.Errorf("UserID(%q) = %q ; attendu %q", in, got, want) + } + } +} + +func newSpaces(t *testing.T) *Spaces { + t.Helper() + return NewSpaces(newStore(t)) +} + +func space(t *testing.T, sp *Spaces, id string) *Space { + t.Helper() + u, err := sp.For(id) + if err != nil { + t.Fatal(err) + } + return u +} + +func write(t *testing.T, u *Space, p string) { + t.Helper() + if _, _, err := u.Write(p, []byte(doc), "", true); err != nil { + t.Fatalf("écriture de %s : %v", p, err) + } +} + +func favPaths(t *testing.T, u *Space) []string { + t.Helper() + es, err := u.Favorites() + if err != nil { + t.Fatal(err) + } + out := []string{} + for _, e := range es { + if e.Broken { + out = append(out, "!"+e.Path) + } else { + out = append(out, e.Path) + } + } + return out +} + +func same(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestSpacesListAndPrivacy(t *testing.T) { + sp := newSpaces(t) + a, b := space(t, sp, "a@x.fr"), space(t, sp, "b@x.fr") + write(t, a, ".perso/secret.excalidraw") + write(t, a, "commun.excalidraw") + + l, err := a.List("") + if err != nil || len(l) != 3 || l[0].Special != "favoris" || l[1].Special != "perso" || l[2].Path != "commun.excalidraw" { + t.Fatalf("racine = %+v, %v", l, err) + } + if l, _ := a.List(".perso"); len(l) != 1 || l[0].Path != ".perso/secret.excalidraw" { + t.Fatalf("espace personnel de a = %+v", l) + } + if l, _ := b.List(".perso"); len(l) != 0 { + t.Fatalf("b voit l'espace de a : %+v", l) + } + if _, _, err := b.Read(".perso/secret.excalidraw"); !errors.Is(err, ErrNotFound) { + t.Fatalf("b lit le schéma de a : %v", err) + } + // Ni l'arborescence commune ni la recherche ne montrent .users. + if _, _, err := b.Read(".users/a@x.fr/home/secret.excalidraw"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("accès direct à .users : %v", err) + } + if res, _ := b.Search("secret", Filter{}, 10); len(res) != 0 { + t.Fatalf("recherche de b = %+v", res) + } + if res, _ := a.Search("secret", Filter{}, 10); len(res) != 1 || res[0].Path != ".perso/secret.excalidraw" { + t.Fatalf("recherche de a = %+v", res) + } +} + +func TestSpacesFavorites(t *testing.T) { + sp := newSpaces(t) + a, b := space(t, sp, "a@x.fr"), space(t, sp, "b@x.fr") + write(t, a, "d/s.excalidraw") + write(t, a, ".perso/p.excalidraw") + if err := a.SetFavorite([]string{"d"}, true); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("dossier en favori : %v", err) + } + if err := a.SetFavorite([]string{"d/s.excalidraw", ".perso/p.excalidraw"}, true); err != nil { + t.Fatal(err) + } + if err := b.SetFavorite([]string{"d/s.excalidraw"}, true); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); !same(got, []string{".perso/p.excalidraw", "d/s.excalidraw"}) { + t.Fatalf("favoris de a = %v", got) + } + if l, _ := a.List("d"); len(l) != 1 || !l[0].Favorite { + t.Fatalf("étoile absente : %+v", l) + } + if res, _ := a.Search("", Filter{Favorite: true}, 10); len(res) != 2 { + t.Fatalf("filtre favoris = %+v", res) + } + // Le lien est relatif et vise l'original. + fi, err := os.Lstat(filepath.Join(sp.favDir("a@x.fr"), "s.excalidraw")) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("lien symbolique attendu : %v", err) + } + + // Renommage du dossier : les favoris des deux utilisateurs suivent. + if _, err := a.Move("d", "e"); err != nil { + t.Fatal(err) + } + if got := favPaths(t, b); !same(got, []string{"e/s.excalidraw"}) { + t.Fatalf("favoris de b après renommage = %v", got) + } + + // Passage dans l'espace personnel de a : a garde son favori, b le perd. + if _, err := a.Move("e/s.excalidraw", ".perso/s.excalidraw"); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); !same(got, []string{".perso/p.excalidraw", ".perso/s.excalidraw"}) { + t.Fatalf("favoris de a après déplacement = %v", got) + } + if got := favPaths(t, b); len(got) != 0 { + t.Fatalf("b garde un favori vers l'espace de a : %v", got) + } + + // Retour dans l'arborescence commune par MoveMany. + res, err := a.MoveMany([]string{".perso/p.excalidraw"}, "") + if err != nil || res[0].To != "p.excalidraw" || res[0].Err() != nil { + t.Fatalf("MoveMany = %+v, %v", res, err) + } + if got := favPaths(t, a); !same(got, []string{"p.excalidraw", ".perso/s.excalidraw"}) { + t.Fatalf("favoris de a après MoveMany = %v", got) + } + + // Suppression : le favori disparaît. + if err := a.Delete("p.excalidraw", false); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); !same(got, []string{".perso/s.excalidraw"}) { + t.Fatalf("favoris de a après suppression = %v", got) + } + + // Original supprimé hors de l'application : favori cassé, retirable. + if err := os.Remove(filepath.Join(a.home.root, "s.excalidraw")); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); !same(got, []string{"!.favoris/s.excalidraw"}) { + t.Fatalf("favori cassé = %v", got) + } + if err := a.Delete(".favoris/s.excalidraw", false); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); len(got) != 0 { + t.Fatalf("favori cassé non retiré : %v", got) + } +} + +func TestSpacesFavoriteNames(t *testing.T) { + sp := newSpaces(t) + a := space(t, sp, "a@x.fr") + write(t, a, "x/s.excalidraw") + write(t, a, "y/s.excalidraw") + if err := a.SetFavorite([]string{"x/s.excalidraw", "y/s.excalidraw", "x/s.excalidraw"}, true); err != nil { + t.Fatal(err) + } + es, _ := a.Favorites() + if len(es) != 2 || es[0].Link == es[1].Link { + t.Fatalf("favoris homonymes = %+v", es) + } + if err := a.SetFavorite([]string{"y/s.excalidraw"}, false); err != nil { + t.Fatal(err) + } + if got := favPaths(t, a); !same(got, []string{"x/s.excalidraw"}) { + t.Fatalf("après retrait = %v", got) + } +} + +func TestSpacesSeedFromMeta(t *testing.T) { + sp := newSpaces(t) + write(t, space(t, sp, "a@x.fr"), "old.excalidraw") + if err := sp.Main.SetFavorite([]string{"old.excalidraw"}, true); err != nil { + t.Fatal(err) + } + // Un nouvel utilisateur reprend les favoris communs, une seule fois. + c := space(t, sp, "c@x.fr") + if got := favPaths(t, c); !same(got, []string{"old.excalidraw"}) { + t.Fatalf("favoris repris = %v", got) + } + if err := c.SetFavorite([]string{"old.excalidraw"}, false); err != nil { + t.Fatal(err) + } + sp.homes = map[string]*Store{} // redémarrage + if got := favPaths(t, space(t, sp, "c@x.fr")); len(got) != 0 { + t.Fatalf("favoris repris deux fois : %v", got) + } +} + +func TestSpacesSharesAndCrossMove(t *testing.T) { + sp := newSpaces(t) + a := space(t, sp, "a@x.fr") + write(t, a, ".perso/p.excalidraw") + if err := a.EditTags([]string{".perso/p.excalidraw"}, []string{"NSX"}, nil); err != nil { + t.Fatal(err) + } + if _, err := a.home.Snapshot("p.excalidraw"); err != nil { + t.Fatal(err) + } + sh, err := a.CreateShare(".perso/p.excalidraw", 0) + if err != nil || sh.Path != ".perso/p.excalidraw" { + t.Fatalf("partage = %+v, %v", sh, err) + } + if got, _, err := sp.ReadShared(sh.Token); err != nil || got.Path != "p.excalidraw" { + t.Fatalf("lien personnel : %+v, %v", got, err) + } + // Vers l'arborescence commune : tags, historique et lien suivent. + if _, err := a.Move(".perso/p.excalidraw", "q.excalidraw"); err != nil { + t.Fatal(err) + } + if l, _ := a.List(""); len(l) != 3 || len(l[2].Tags) != 1 { + t.Fatalf("tags après déplacement : %+v", l) + } + if vs, err := a.Versions("q.excalidraw"); err != nil || len(vs) != 1 { + t.Fatalf("historique après déplacement : %+v, %v", vs, err) + } + if got, _, err := sp.ReadShared(sh.Token); err != nil || got.Path != "q.excalidraw" { + t.Fatalf("lien après déplacement : %+v, %v", got, err) + } + if tags, _ := a.Tags(); len(tags) != 1 || tags[0].Count != 1 { + t.Fatalf("tags = %+v", tags) + } + // Révocation globale : clé changée, liens personnels compris. + sh2, _ := a.CreateShare("q.excalidraw", 0) + write(t, a, ".perso/r.excalidraw") + sh3, _ := a.CreateShare(".perso/r.excalidraw", 0) + if err := a.RevokeAllShares(); err != nil { + t.Fatal(err) + } + for _, tok := range []string{sh2.Token, sh3.Token} { + if _, _, err := sp.ReadShared(tok); !errors.Is(err, ErrNotFound) { + t.Fatalf("lien encore valide après révocation : %v", err) + } + } +}