package api import ( "encoding/json" "net/http" "strconv" "testing" ) func sharesPath(gid int64) string { return "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/shares" } func sharePath(gid, userID int64) string { return sharesPath(gid) + "/" + strconv.FormatInt(userID, 10) } func TestSharingFlowAPI(t *testing.T) { r := authEngine(t, localCfg()) a := registerAndCookie(t, r, "a@example.com") b := registerAndCookie(t, r, "b@example.com") gid := createGardenAPI(t, r, a, "A's yard") // A bed to mutate later. w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "xCm": 500, "yCm": 500, "widthCm": 200, "heightCm": 200}, a) oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) // Unknown email → 404 SHARE_USER_NOT_FOUND. w = doJSON(t, r, http.MethodPost, sharesPath(gid), map[string]any{"email": "nobody@example.com", "role": "viewer"}, a) if w.Code != http.StatusNotFound { t.Fatalf("share unknown email = %d, want 404 (body %s)", w.Code, w.Body.String()) } // Share with B as viewer → 201. w = doJSON(t, r, http.MethodPost, sharesPath(gid), map[string]any{"email": "b@example.com", "role": "viewer"}, a) if w.Code != http.StatusCreated { t.Fatalf("share viewer = %d, body %s", w.Code, w.Body.String()) } bID := int64(decodeMap(t, w.Body.Bytes())["userId"].(float64)) // B lists → the garden with myRole viewer. w = doJSON(t, r, http.MethodGet, "/api/v1/gardens", nil, b) var list []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("decode b list: %v", err) } if len(list) != 1 || list[0]["myRole"] != "viewer" { t.Fatalf("b list = %+v, want one garden with myRole viewer", list) } // B reads /full (200) but can't mutate (403) or manage shares (403). if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, b); w.Code != http.StatusOK { t.Errorf("b read full = %d, want 200", w.Code) } if w := doJSON(t, r, http.MethodPatch, objectPath(oid), map[string]any{"xCm": 100, "version": 1}, b); w.Code != http.StatusForbidden { t.Errorf("viewer mutate object = %d, want 403", w.Code) } if w := doJSON(t, r, http.MethodGet, sharesPath(gid), nil, b); w.Code != http.StatusForbidden { t.Errorf("viewer list shares = %d, want 403", w.Code) } // A upgrades B to editor. if w := doJSON(t, r, http.MethodPatch, sharePath(gid, bID), map[string]any{"role": "editor"}, a); w.Code != http.StatusOK { t.Fatalf("upgrade role = %d, body %s", w.Code, w.Body.String()) } // B can now move the object, but still can't rename the garden (owner-only). if w := doJSON(t, r, http.MethodPatch, objectPath(oid), map[string]any{"xCm": 100, "version": 1}, b); w.Code != http.StatusOK { t.Errorf("editor move object = %d, want 200 (body %s)", w.Code, w.Body.String()) } if w := doJSON(t, r, http.MethodPatch, gardenPath(gid), map[string]any{"name": "hijack", "widthCm": 100, "heightCm": 100, "unitPref": "metric", "version": 1}, b); w.Code != http.StatusForbidden { t.Errorf("editor rename garden = %d, want 403", w.Code) } // A removes the share → B loses access. if w := doJSON(t, r, http.MethodDelete, sharePath(gid, bID), nil, a); w.Code != http.StatusNoContent { t.Fatalf("remove share = %d", w.Code) } if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, b); w.Code != http.StatusNotFound { t.Errorf("b read after unshare = %d, want 404", w.Code) } if w := doJSON(t, r, http.MethodGet, "/api/v1/gardens", nil, b); w.Body.String() != "[]" { t.Errorf("b list after unshare = %s, want []", w.Body.String()) } } func TestSharesRequireAuth(t *testing.T) { r := authEngine(t, localCfg()) if w := doJSON(t, r, http.MethodGet, "/api/v1/gardens/1/shares", nil, nil); w.Code != http.StatusUnauthorized { t.Errorf("unauthenticated list shares = %d, want 401", w.Code) } }