From 62614f5075ca2849f5a815c5b83d84b9039e6cb1 Mon Sep 17 00:00:00 2001 From: Sebastien DUMETZ <s.dumetz@holusion.com> Date: Mon, 1 Jul 2024 08:26:24 +0200 Subject: [PATCH] server-side tags support --- source/server/auth/UserManager.ts | 1 + source/server/migrations/005-collections.sql | 17 ++ source/server/routes/api/v1/index.ts | 5 + .../routes/api/v1/scenes/scene/patch.test.ts | 15 +- .../routes/api/v1/scenes/scene/patch.ts | 23 ++- source/server/routes/api/v1/tags/get.ts | 11 ++ source/server/routes/api/v1/tags/tag/get.ts | 15 ++ source/server/routes/scenes/get/file.test.ts | 2 +- source/server/vfs/Scenes.ts | 58 +++++-- source/server/vfs/Tags.ts | 104 ++++++++++++ source/server/vfs/index.ts | 5 +- source/server/vfs/types.ts | 7 + source/server/vfs/vfs.test.ts | 148 ++++++++++++++++++ 13 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 source/server/migrations/005-collections.sql create mode 100644 source/server/routes/api/v1/tags/get.ts create mode 100644 source/server/routes/api/v1/tags/tag/get.ts create mode 100644 source/server/vfs/Tags.ts diff --git a/source/server/auth/UserManager.ts b/source/server/auth/UserManager.ts index 44beca3a..048c3224 100644 --- a/source/server/auth/UserManager.ts +++ b/source/server/auth/UserManager.ts @@ -42,6 +42,7 @@ export function isAccessType(type :any) :type is AccessType{ export type AccessType = typeof AccessTypes[number]; +export type AccessMap = {[id: `${number}`|string]:AccessType}; export const any_id = 1 as const; export const default_id = 0 as const; diff --git a/source/server/migrations/005-collections.sql b/source/server/migrations/005-collections.sql new file mode 100644 index 00000000..84ac869e --- /dev/null +++ b/source/server/migrations/005-collections.sql @@ -0,0 +1,17 @@ +-------------------------------------------------------------------------------- +-- Up +-------------------------------------------------------------------------------- + +CREATE TABLE tags( + tag_name TEXT NOT NULL COLLATE NOCASE, + fk_scene_id INTEGER NOT NULL, + FOREIGN KEY(fk_scene_id) REFERENCES scenes(scene_id), + UNIQUE(tag_name, fk_scene_id) +); + +PRAGMA foreign_keys = ON; +-------------------------------------------------------------------------------- +-- Down +-------------------------------------------------------------------------------- + +DROP TABLE tags; \ No newline at end of file diff --git a/source/server/routes/api/v1/index.ts b/source/server/routes/api/v1/index.ts index 9af7e346..2592b1e3 100644 --- a/source/server/routes/api/v1/index.ts +++ b/source/server/routes/api/v1/index.ts @@ -27,6 +27,8 @@ import handleGetStats from "./admin/stats/index.js"; import postScenes from "./scenes/post.js"; import patchScene from "./scenes/scene/patch.js"; import handleMailtest from "./admin/mailtest.js"; +import getTags from "./tags/get.js"; +import getTag from "./tags/tag/get.js"; const router = Router(); @@ -87,4 +89,7 @@ router.get("/scenes/:scene/files", wrap(getFiles)); router.get("/scenes/:scene/permissions", wrap(getPermissions)); router.patch("/scenes/:scene/permissions", canAdmin, bodyParser.json(), wrap(patchPermissions)); + +router.get("/tags", isUser, wrap(getTags)); +router.get("/tags/:tag", isUser, wrap(getTag)); export default router; diff --git a/source/server/routes/api/v1/scenes/scene/patch.test.ts b/source/server/routes/api/v1/scenes/scene/patch.test.ts index 841b5dd8..30a594e6 100644 --- a/source/server/routes/api/v1/scenes/scene/patch.test.ts +++ b/source/server/routes/api/v1/scenes/scene/patch.test.ts @@ -21,8 +21,8 @@ describe("PATCH /api/v1/scenes/:scene", function(){ await cleanIntegrationContext(this); }); - describe("rename a scene", function(){ - it("get scene info", async function(){ + describe("name", function(){ + it("can rename", async function(){ await request(this.server).patch("/api/v1/scenes/foo") .send({name: "foofoo"}) .expect(200); @@ -50,4 +50,15 @@ describe("PATCH /api/v1/scenes/:scene", function(){ }); }); + describe("tags", async function(){ + it("add", async function(){ + let r = await request(this.server).patch("/api/v1/scenes/foo") + .send({tags: ["foo", "bar"]}) + .expect(200); + + expect(r.body).to.have.property("tags").to.deep.equal(["foo", "bar"]); + }) + + }) + }); diff --git a/source/server/routes/api/v1/scenes/scene/patch.ts b/source/server/routes/api/v1/scenes/scene/patch.ts index 1d9b424d..4a70130b 100644 --- a/source/server/routes/api/v1/scenes/scene/patch.ts +++ b/source/server/routes/api/v1/scenes/scene/patch.ts @@ -12,16 +12,15 @@ import { Request, Response } from "express"; * @returns {Promise<void>} */ export default async function patchScene(req :Request, res :Response){ - let vfs = getVfs(req); let user_id = getUserId(req); - let {scene} = req.params; - let {name} = req.body; + let {scene: sceneName} = req.params; + let {name, tags} = req.body; //Ensure all or none of the changes are comitted let result = await getVfs(req).isolate(async (vfs)=>{ - let {id} = await vfs.getScene(scene, user_id); - if(name && name !== scene){ + let scene = await vfs.getScene(sceneName, user_id); + if(name && name !== sceneName){ try{ - await vfs.renameScene(id, name); + await vfs.renameScene(scene.id, name); }catch(e:any){ if(e.code == "SQLITE_CONSTRAINT" && /UNIQUE constraint failed: scenes.scene_name/.test(e.message)){ throw new ConflictError(`A scene named ${name} already exists`); @@ -30,8 +29,18 @@ export default async function patchScene(req :Request, res :Response){ } } } + if(tags){ + for(let tag of tags){ + if (scene.tags.indexOf(tag) !== -1) continue; + await vfs.addTag(scene.id, tag); + } + for(let ex_tag of scene.tags){ + if(tags.indexOf(ex_tag) !== -1) continue; + await vfs.removeTag(scene.id, ex_tag); + } + } - return await vfs.getScene(id, user_id); + return await vfs.getScene(scene.id, user_id); }); res.status(200).send(result); }; diff --git a/source/server/routes/api/v1/tags/get.ts b/source/server/routes/api/v1/tags/get.ts new file mode 100644 index 00000000..54210e5d --- /dev/null +++ b/source/server/routes/api/v1/tags/get.ts @@ -0,0 +1,11 @@ + +import { Request, Response } from "express"; +import { getVfs } from "../../../../utils/locals.js"; + +export default async function getTags(req :Request, res :Response){ + + const vfs = getVfs(req); + + const tags = await vfs.getTags(); + res.status(200).send(tags); +} \ No newline at end of file diff --git a/source/server/routes/api/v1/tags/tag/get.ts b/source/server/routes/api/v1/tags/tag/get.ts new file mode 100644 index 00000000..9c74e49a --- /dev/null +++ b/source/server/routes/api/v1/tags/tag/get.ts @@ -0,0 +1,15 @@ + +import { Request, Response } from "express"; +import { getUser, getVfs } from "../../../../../utils/locals.js"; + +export default async function getTag(req :Request, res :Response){ + const requester = getUser(req); + const vfs = getVfs(req); + const {tag} = req.params; + + const scenes = await Promise.all((await vfs.getTag(tag, requester.uid)).map(s=>{ + return vfs.getScene(s, requester.uid); + })); + + res.status(200).send(scenes); +} \ No newline at end of file diff --git a/source/server/routes/scenes/get/file.test.ts b/source/server/routes/scenes/get/file.test.ts index dc94f321..7b7b4c44 100644 --- a/source/server/routes/scenes/get/file.test.ts +++ b/source/server/routes/scenes/get/file.test.ts @@ -38,7 +38,7 @@ describe("GET /scenes/:scene/:filename(.*)", function(){ }); it("can't get a private scene's file (obfuscated as 404)", async function(){ - let scene_id = await vfs.createScene("foo", {"0":"none", [user.uid]: "admin"}); + let scene_id = await vfs.createScene("foo", {"0":"none", "1": "none", [user.uid]: "admin"}); await vfs.writeDoc("{}", scene_id, user.uid); await vfs.writeFile(dataStream(), {scene: "foo", mime:"model/gltf-binary", name: "models/foo.glb", user_id: user.uid}); diff --git a/source/server/vfs/Scenes.ts b/source/server/vfs/Scenes.ts index 270e4470..ddc4f0d9 100644 --- a/source/server/vfs/Scenes.ts +++ b/source/server/vfs/Scenes.ts @@ -1,4 +1,4 @@ -import { AccessType, AccessTypes } from "../auth/UserManager.js"; +import { AccessMap, AccessType, AccessTypes } from "../auth/UserManager.js"; import config from "../utils/config.js"; import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js"; import { Uid } from "../utils/uid.js"; @@ -10,9 +10,9 @@ export default abstract class ScenesVfs extends BaseVfs{ async createScene(name :string):Promise<number> async createScene(name :string, author_id :number):Promise<number> - async createScene(name :string, permissions:Record<string,AccessType>):Promise<number> - async createScene(name :string, perms ?:Record<string,AccessType>|number) :Promise<number>{ - let permissions :Record<string,AccessType> = (typeof perms === "object")? perms : {}; + async createScene(name :string, permissions:AccessMap):Promise<number> + async createScene(name :string, perms ?:AccessMap|number) :Promise<number>{ + let permissions :AccessMap = (typeof perms === "object")? perms : {}; //Always provide permissions for default user permissions['0'] ??= (config.public?"read":"none"); permissions['1'] ??= "read"; @@ -88,6 +88,23 @@ export default abstract class ScenesVfs extends BaseVfs{ if(!r?.changes) throw new NotFoundError(`no scene found with id: ${$scene_id}`); } + /** + * Reusable fragment to check if a user has the required access level for an operation on a scene. + * Most permission checks are done outside of this module in route middlewares, + * but we sometimes need to check for permissions to filter list results + * @param user_id User_id, to detect "default" special case + * @param accessMin Minimum expected acccess level, defaults to read + * @returns + */ + static _fragUserCanAccessScene(user_id :number, accessMin:AccessType = "read"){ + return `COALESCE( + json_extract(scenes.access, '$.' || $user_id), + ${(0 < user_id)? `json_extract(scenes.access, '$.1'),`:""} + json_extract(scenes.access, '$.0') + ) IN (${ AccessTypes.slice(AccessTypes.indexOf(accessMin)).map(s=>`'${s}'`).join(", ") }) + `; + } + /** * get all scenes, including archvied scenes Generally not used outside of tests and internal routines */ @@ -188,6 +205,7 @@ export default abstract class ScenesVfs extends BaseVfs{ SELECT username FROM users WHERE fk_author_id = user_id ), "default") AS author, json_extract(thumb.value, '$.uri') AS thumb, + tags.names AS tags, json_object( ${(typeof user_id === "number" && 0 < user_id)? ` "user", IFNULL(json_extract(scenes.access, '$.' || $user_id), "none"), @@ -197,17 +215,17 @@ export default abstract class ScenesVfs extends BaseVfs{ ) AS access FROM scenes - LEFT JOIN last_docs AS document ON fk_scene_id = scene_id + LEFT JOIN last_docs AS document ON document.fk_scene_id = scene_id LEFT JOIN json_tree(document.metas) AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb' - + LEFT JOIN ( + SELECT + json_group_array(tag_name) AS names, + fk_scene_id + FROM tags + GROUP BY fk_scene_id + ) AS tags ON tags.fk_scene_id = scene_id ${with_filter? "WHERE true": ""} - ${(typeof user_id === "number")? `AND - COALESCE( - json_extract(scenes.access, '$.' || $user_id), - ${(0 < user_id)? `json_extract(scenes.access, '$.1'),`:""} - json_extract(scenes.access, '$.0') - ) IN (${ AccessTypes.slice(2).map(s=>`'${s}'`).join(", ") }) - `:""} + ${typeof user_id === "number"? `AND ${ScenesVfs._fragUserCanAccessScene(user_id, "read")}`:""} ${(access?.length)? `AND json_extract(scenes.access, '$.' || $user_id) IN (${ access.map(s=>`'${s}'`).join(", ") })`:""} ${likeness} @@ -222,6 +240,7 @@ export default abstract class ScenesVfs extends BaseVfs{ })).map(({ctime, mtime, id, access, ...m})=>({ ...m, id, + tags: m.tags ? JSON.parse(m.tags): [], access: JSON.parse(access), ctime: BaseVfs.toDate(ctime), mtime: BaseVfs.toDate(mtime), @@ -245,14 +264,22 @@ export default abstract class ScenesVfs extends BaseVfs{ SELECT username FROM users WHERE user_id = fk_author_id ), 'default') AS author, json_extract(thumb.value, '$.uri') AS thumb, + tags.names AS tags, json_object( ${(user_id)? `"user", IFNULL(json_extract(scenes.access, '$.' || $user_id), "none"),`: ``} "any", json_extract(scenes.access, '$.1'), "default", json_extract(scenes.access, '$.0') ) AS access FROM scenes - LEFT JOIN documents ON fk_scene_id = scene_id - LEFT JOIN json_tree(documents.data, "$.metas") AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb' + LEFT JOIN documents ON documents.fk_scene_id = scene_id + LEFT JOIN json_tree(documents.data, "$.metas") AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb' + LEFT JOIN ( + SELECT + json_group_array(tag_name) AS names, + fk_scene_id + FROM tags + GROUP BY fk_scene_id + ) AS tags ON tags.fk_scene_id = scene_id WHERE ${key} = $value ORDER BY generation DESC LIMIT 1 @@ -260,6 +287,7 @@ export default abstract class ScenesVfs extends BaseVfs{ if(!r|| !r.name) throw new NotFoundError(`No scene found with ${key}: ${nameOrId}`); return { ...r, + tags: r.tags ? JSON.parse(r.tags): [], access: JSON.parse(r.access), ctime: BaseVfs.toDate(r.ctime), mtime: BaseVfs.toDate(r.mtime), diff --git a/source/server/vfs/Tags.ts b/source/server/vfs/Tags.ts new file mode 100644 index 00000000..86f4e288 --- /dev/null +++ b/source/server/vfs/Tags.ts @@ -0,0 +1,104 @@ +import { NotFoundError } from "../utils/errors.js"; +import BaseVfs from "./Base.js"; +import ScenesVfs from "./Scenes.js"; +import { Tag } from "./types.js"; + + +export default abstract class TagsVfs extends BaseVfs{ + + /** + * Set tags for a scene. Does not check for permissions + * Returns true if a tag was added, false otherwise. + * Throws an error on invalid scene ID + */ + async addTag(scene_name :string, tag :string) :Promise<boolean>; + async addTag(scene_id :number, tag :string) :Promise<boolean>; + async addTag(scene :string|number, tag :string) :Promise<boolean>{ + let match = ((typeof scene ==="number")?'$scene':`scene_id FROM scenes WHERE scene_name = $scene`); + try{ + let r = await this.db.run(` + INSERT INTO tags + (tag_name, fk_scene_id) + SELECT $tag, ${match} + `, { + $tag: tag.toLowerCase(), + $scene: scene + }); + if(!r.changes) throw new NotFoundError(`Can't find scene matching ${scene}`); + }catch(e:any){ + if(e.code === "SQLITE_CONSTRAINT" && /FOREIGN KEY/.test(e.message)){ + throw new NotFoundError(`Can't find scene matching ${scene}`); + }else if(e.code === "SQLITE_CONSTRAINT" && /UNIQUE constraint/.test(e.message)){ + return false; + } + throw e; + } + return true; + } + + /** + * Returns true if something was changed, false otherwise + */ + async removeTag(scene_name :string, tag :string): Promise<boolean>; + async removeTag(scene_id :number, tag: string): Promise<boolean>; + async removeTag(scene :number|string, tag :string):Promise<boolean>{ + let match = ((typeof scene === "number")?`fk_scene_id = $scene`: ` + fk_scene_id IN ( + SELECT scene_id + FROM scenes + WHERE scene_name = $scene + ) + `); + let r = await this.db.run(` + DELETE FROM tags + WHERE tag_name = $tag AND ${match} + `, { + $tag: tag, + $scene: scene + }); + return !!r.changes; + } + + async getTags(like ?:string):Promise<Tag[]>{ + let where :string = like?`WHERE tag_name LIKE '%' || $like || '%'` :""; + return await this.db.all<Tag[]>( + ` + SELECT + tag_name AS name, + COUNT(fk_scene_id) as size + FROM + tags, + scenes + ON fk_scene_id = scene_id + ${where} + GROUP BY name + ORDER BY name ASC + `, + {$like: like} + ); + } + + /** + * Get all scenes that have this tag, regrdless of permissions + * @fixme the JOIN could be optimized away in this case + */ + async getTag(name :string):Promise<number[]> + /** Get all scenes that have this tag that this user can read */ + async getTag(name :string, user_id :number):Promise<number[]> + async getTag(name :string, user_id ?:number):Promise<number[]>{ + + let scenes = await this.db.all<{scene_id:number}[]>(` + SELECT scene_id + FROM + tags, + scenes + ON fk_scene_id = scene_id + WHERE + tags.tag_name = $name + ${typeof user_id === "number"?`AND ${ScenesVfs._fragUserCanAccessScene(user_id, "read")}`:""} + ORDER BY scene_name ASC + `, {$name: name, $user_id: user_id?.toString(10)}); + + return scenes.map(s=>s.scene_id); + } +} \ No newline at end of file diff --git a/source/server/vfs/index.ts b/source/server/vfs/index.ts index 8d01ef1a..6a6752db 100644 --- a/source/server/vfs/index.ts +++ b/source/server/vfs/index.ts @@ -9,6 +9,7 @@ import DocsVfs from "./Docs.js"; import ScenesVfs from "./Scenes.js"; import CleanVfs from "./Clean.js"; import StatsVfs from "./Stats.js"; +import TagsVfs from "./Tags.js"; export * from "./types.js"; @@ -46,8 +47,8 @@ class Vfs extends BaseVfs{ } } -interface Vfs extends FilesVfs, DocsVfs, ScenesVfs, StatsVfs, CleanVfs {}; -applyMixins(Vfs, [FilesVfs, DocsVfs, ScenesVfs, StatsVfs, CleanVfs]); +interface Vfs extends FilesVfs, DocsVfs, ScenesVfs, StatsVfs, TagsVfs, CleanVfs {}; +applyMixins(Vfs, [FilesVfs, DocsVfs, ScenesVfs, StatsVfs, TagsVfs, CleanVfs]); export default Vfs; diff --git a/source/server/vfs/types.ts b/source/server/vfs/types.ts index 38cf0887..92e6de18 100644 --- a/source/server/vfs/types.ts +++ b/source/server/vfs/types.ts @@ -57,6 +57,8 @@ export interface GetFileResult extends FileProps{ export interface Scene extends ItemProps{ /** optional name of the scene's thumbnail. Will generally be null due to sql types */ thumb ?:string|null; + /** Freeform list of attached tags for this collection */ + tags :string[]; /** Access level. Only makes sense when in reference to a user ID */ access :{ user ?:AccessType, @@ -84,3 +86,8 @@ export interface SceneQuery { orderBy ?:"ctime"|"mtime"|"name"; orderDirection ?:"asc"|"desc"; } + +export interface Tag{ + name :string; + size :number; +} \ No newline at end of file diff --git a/source/server/vfs/vfs.test.ts b/source/server/vfs/vfs.test.ts index 70cf3c19..57dabd29 100644 --- a/source/server/vfs/vfs.test.ts +++ b/source/server/vfs/vfs.test.ts @@ -28,6 +28,7 @@ function sceneProps(id:number): {[P in keyof Required<Scene>]: Function|any}{ author: "default", author_id: 0, thumb: null, + tags: [], access: { any: 'read', default: 'read' } }; } @@ -499,6 +500,153 @@ describe("Vfs", function(){ }); }); + describe("tags", function(){ + let scene_id :number; + //Create a dummy scene for future tests + this.beforeEach(async function(){ + scene_id = await vfs.createScene("foo"); + }); + + describe("addSceneTag() / removeSceneTag()", function(){ + it("adds a tag to a scene", async function(){ + await vfs.addTag(scene_id, "foo"); + let s = await vfs.getScene(scene_id); + expect(s).to.have.property("tags").to.deep.equal(["foo"]); + await vfs.addTag(scene_id, "bar"); + s = await vfs.getScene(scene_id); + //Ordering is loosely expected to hold: we do not enforce AUTOINCREMENT on rowids but it's generally true + expect(s).to.have.property("tags").to.deep.equal(["foo", "bar"]); + }); + + it("can remove tag", async function(){ + await expect(vfs.addTag(scene_id, "foo")).to.eventually.equal(true); + await expect(vfs.addTag(scene_id, "bar")).to.eventually.equal(true); + await expect(vfs.removeTag(scene_id, "foo")).to.eventually.equal(true); + + let s = await vfs.getScene(scene_id); + expect(s).to.have.property("tags").to.deep.equal(["bar"]); + }); + + it("can be called with scene name", async function(){ + await expect(vfs.addTag("foo", "foo")).to.eventually.equal(true); + let s = await vfs.getScene(scene_id); + expect(s).to.have.property("tags").to.deep.equal(["foo"]); + + await expect(vfs.removeTag("foo", "foo")).to.eventually.equal(true); + }); + + it("throws a 404 errors if scene doesn't exist", async function(){ + // by id + await expect(vfs.addTag(scene_id+1, "foo")).to.be.rejectedWith(NotFoundError); + // by name + await expect(vfs.addTag("baz", "foo")).to.be.rejectedWith(NotFoundError); + + }); + + + it("returns false if nothing was changed", async function(){ + await vfs.addTag(scene_id, "foo"); + //When tag is added twice, by scene_id + await expect(vfs.addTag(scene_id, "foo")).to.eventually.equal(false); + //When tag is added twice, by name + await expect(vfs.addTag("foo", "foo")).to.eventually.equal(false); + + //When tag doesn't exist + await expect(vfs.removeTag(scene_id, "bar")).to.be.eventually.equal(false); + //When scene doesn't exist + await expect(vfs.removeTag(scene_id+1, "foo")).to.eventually.equal(false); + }); + + it("force lower case ascii characters", async function(){ + await expect(vfs.addTag(scene_id, "Foo")).to.eventually.equal(true); + await expect(vfs.addTag(scene_id, "foo")).to.eventually.equal(false); + expect(await vfs.getTags()).to.deep.equal([{name: "foo", size: 1}]); + }); + + it("force lower case for non-ascii characters", async function(){ + await expect(vfs.addTag(scene_id, "Électricité")).to.eventually.equal(true); + await expect(vfs.addTag(scene_id, "électricité")).to.eventually.equal(false); + expect(await vfs.getTags()).to.deep.equal([{name: "électricité", size: 1}]); + }); + + it("force lower case for non-latin characters", async function(){ + await expect(vfs.addTag(scene_id, "ΑΒΓΔΕ")).to.eventually.equal(true); + await expect(vfs.addTag(scene_id, "αβγδε")).to.eventually.equal(false); + expect(await vfs.getTags()).to.deep.equal([{name: "αβγδε", size: 1}]); + }); + }); + + + describe("getTags()", function(){ + it("get all tags", async function(){ + //Create a bunch of additional test scenes + for(let i=0; i < 3; i++){ + let id = await vfs.createScene(`test_${i}`); + for(let j=0; j <= i; j++ ){ + await vfs.addTag(id, `tag_${j}`); + } + } + expect(await vfs.getTags()).to.deep.equal([ + {name: "tag_0", size: 3}, + {name: "tag_1", size: 2}, + {name: "tag_2", size: 1}, + ]); + }); + + it("get tags matching a string", async function(){ + await vfs.addTag(scene_id, `tag_foo`); + await vfs.addTag(scene_id, `foo_tag`); + await vfs.addTag(scene_id, `tag_bar`); + expect(await vfs.getTags("foo")).to.deep.equal([ + {name:"foo_tag", size: 1}, + {name: "tag_foo", size: 1}, + ]); + }); + }); + + describe("getTag()", function(){ + it("Get all scenes attached to a tag", async function(){ + for(let i=0; i < 3; i++){ + let id = await vfs.createScene(`test_${i}`); + await vfs.addTag(id, `tag_foo`); + } + for(let i=3; i < 6; i++){ + let id = await vfs.createScene(`test_${i}`); + await vfs.addTag(id, `tag_bar`); + } + let scenes = await vfs.getTag("tag_foo"); + expect(scenes).to.deep.equal(["test_0", "test_1", "test_2"]); + }); + + + describe("respects permissions", function(){ + let userManager :UserManager, alice :User, bob :User; + this.beforeEach(async function(){ + let userManager = new UserManager(vfs._db); + alice = await userManager.addUser("alice", "12345678", true); + bob = await userManager.addUser("bob", "12345678", false); + }); + + it("return scenes with read access", async function(){ + await vfs.addTag("foo", "foo"); + expect(await vfs.getTag("foo", alice.uid), "with admin user_id").to.deep.equal(["foo"]); + + expect(await vfs.getTag("foo", bob.uid), "with normal user id").to.deep.equal(["foo"]); + }); + + it("won't return non-readable scene", async function(){ + await vfs.createScene("admin-only", {"0": "none", "1": "none", [alice.uid.toString(10)]: "admin"}); + await vfs.addTag("admin-only", "foo"); + expect(await vfs.getTag("foo"), "without user_id").to.deep.equal(["admin-only"]); + + expect(await vfs.getTag("foo", alice.uid), "with admin user_id").to.deep.equal(["admin-only"]); + + expect(await vfs.getTag("foo", bob.uid)).to.deep.equal([]); + }); + }) + }); + }); + describe("", function(){ let scene_id :number; //Create a dummy scene for future tests