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