From 8f7e098f896f1738f1465a028d5d3537cfe8cd1b Mon Sep 17 00:00:00 2001
From: Felipe Morato
Date: Thu, 9 Dec 2021 07:33:24 +0200
Subject: [PATCH] Add possibility to tag objects.
This is a continuation to the tagging buckets feature from PR #419.
The openApi spec supports tuples in 3.1 with prefixItems. We may want to change the api docs when that comes into use.
---
docs/_static/api.yml | 67 ++++++++--
swift_browser_ui/ui/api.py | 47 ++++++-
swift_browser_ui/ui/server.py | 2 +
swift_browser_ui_frontend/src/common/api.js | 42 +++++-
swift_browser_ui_frontend/src/common/conv.js | 36 ++++-
swift_browser_ui_frontend/src/common/lang.js | 12 +-
.../src/common/router.js | 6 +
swift_browser_ui_frontend/src/common/store.js | 123 ++++++++++++------
.../src/components/ObjectDeleteButton.vue | 5 +-
.../src/components/ObjectTable.vue | 51 +++++++-
swift_browser_ui_frontend/src/entries/main.js | 5 +-
.../src/views/AddContainer.vue | 11 +-
.../src/views/EditObject.vue | 119 +++++++++++++++++
tests/common/mockups.py | 47 ++++---
tests/cypress/integration/browser.spec.js | 39 +++++-
tests/ui_unit/test_api.py | 50 ++++++-
16 files changed, 551 insertions(+), 111 deletions(-)
create mode 100644 swift_browser_ui_frontend/src/views/EditObject.vue
diff --git a/docs/_static/api.yml b/docs/_static/api.yml
index c906c3baa..e9b65613d 100644
--- a/docs/_static/api.yml
+++ b/docs/_static/api.yml
@@ -718,6 +718,37 @@ paths:
description: Unauthorized
404:
description: Not Found
+ post:
+ tags:
+ - Frontend
+ summary: Update user created container metadata.
+ parameters:
+ - name: container
+ in: query
+ description: The container which metadata will be updated.
+ schema:
+ type: string
+ example: test-container-1
+ required: true
+ requestBody:
+ description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend.
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ key:
+ type: string
+ example:
+ owner: project-team
+ responses:
+ 204:
+ description: Container metadata was updated. No Content.
+ 403:
+ description: Unauthorized
+ 404:
+ description: Container was not found
/api/bucket/object/meta:
get:
tags:
@@ -750,34 +781,48 @@ paths:
post:
tags:
- Frontend
- summary: Update user created container metadata.
+ summary: Update user created object metadata.
parameters:
- name: container
in: query
- description: The container which metadata will be updated.
+ description: The container which contains the objects to be updated.
schema:
type: string
example: test-container-1
required: true
requestBody:
- description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend.
+ description: Object metadata as an array of tuples with the object name and a key-value object. Updates must include all the metadata for the object. Omitted keys are removed by the swift backend.
required: true
content:
application/json:
schema:
- type: object
- properties:
- key:
- type: string
+ type: array
example:
- owner: project-team
+ - - object-name
+ - key: value
+ items:
+ type: array
+ items:
+ oneOf:
+ - type: string
+ - type: object
+ minItems: 2
+ maxItems: 2
+ example:
+ - object-name
+ - key: value
+ example:
+ - - object-name
+ - key: value
responses:
204:
- description: Container metadata was updated. No Content.
+ description: Object metadata was updated. No Content.
+ 400:
+ description: Payload malformed. Bad request.
403:
- description: Unauthorized
+ description: Unauthorized.
404:
- description: Container was not found
+ description: At least one object was not found.
/api/shared/objects:
get:
tags:
diff --git a/swift_browser_ui/ui/api.py b/swift_browser_ui/ui/api.py
index a5c1328f4..f44d7ff67 100644
--- a/swift_browser_ui/ui/api.py
+++ b/swift_browser_ui/ui/api.py
@@ -6,7 +6,7 @@
import aiohttp.web
from swiftclient.exceptions import ClientException
-from swiftclient.service import SwiftError
+from swiftclient.service import SwiftError, SwiftPostObject
from swiftclient.service import SwiftService, get_conn # for type hints
from swiftclient.utils import generate_temp_url
@@ -492,7 +492,7 @@ async def get_object_metadata(
for i in res
]
- # Strip unnecessary specifcations from header names and split open s3
+ # Strip unnecessary specifications from header names and split open s3
# information so that it doesn't have to be done in the browser
for i in res:
i[1] = {k.replace("x-object-meta-", ""): v for k, v in i[1].items()}
@@ -549,7 +549,7 @@ async def update_metadata_bucket(request: aiohttp.web.Request) -> aiohttp.web.Re
container = request.query.get("container", "") or None
meta = await request.json()
- meta = [(key, value) for key, value in meta.items()]
+ meta = [(key, value) for key, value in meta.items() if value]
conn = request.app["Sessions"][session]["ST_conn"]
ret = conn.post(container=container, options={"meta": meta})
@@ -589,6 +589,47 @@ async def get_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Respo
return aiohttp.web.json_response(await get_object_metadata(conn, meta_cont, meta_obj))
+async def update_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Response:
+ """Update metadata for an object."""
+ session = api_check(request)
+ request.app["Log"].info(
+ "API cal for updating container metadata from "
+ f"{request.remote}, sess: {session} :: {time.ctime()}"
+ )
+
+ # Get required variables from query string
+ container = request.query.get("container", "") or None
+ objects = await request.json()
+
+ if not (container or objects):
+ raise aiohttp.web.HTTPBadRequest
+
+ objects_post = []
+ try:
+ for (name, meta) in objects:
+ meta = [(key, value) for key, value in meta.items()]
+ objects_post.append(
+ SwiftPostObject(
+ object_name=name,
+ options={
+ "meta": meta,
+ },
+ )
+ )
+ except ValueError as e:
+ request.app["Log"].error(f"Payload seems to be malformed: {e}")
+ raise aiohttp.web.HTTPBadRequest
+
+ conn = request.app["Sessions"][session]["ST_conn"]
+ ret = conn.post(container=container, objects=objects_post)
+
+ for r in ret:
+ if not r["success"]:
+ raise aiohttp.web.HTTPNotFound
+
+ return aiohttp.web.HTTPNoContent()
+
+
async def get_project_metadata(request: aiohttp.web.Request) -> aiohttp.web.Response:
"""Get the bare minimum required project metadata from OS."""
# The project metadata needs to be filtered for sensitive information, as
diff --git a/swift_browser_ui/ui/server.py b/swift_browser_ui/ui/server.py
index 5bbfab4a8..04c629be7 100644
--- a/swift_browser_ui/ui/server.py
+++ b/swift_browser_ui/ui/server.py
@@ -45,6 +45,7 @@
swift_check_object_chunk,
swift_replicate_container,
update_metadata_bucket,
+ update_metadata_object,
)
from swift_browser_ui.ui.health import handle_health_check
from swift_browser_ui.ui.settings import setd
@@ -161,6 +162,7 @@ async def servinit() -> aiohttp.web.Application:
aiohttp.web.get("/api/bucket/meta", get_metadata_bucket),
aiohttp.web.post("/api/bucket/meta", update_metadata_bucket),
aiohttp.web.get("/api/bucket/object/meta", get_metadata_object),
+ aiohttp.web.post("/api/bucket/object/meta", update_metadata_object),
aiohttp.web.get("/api/project/meta", get_project_metadata),
aiohttp.web.get("/api/project/acl", get_access_control_metadata),
aiohttp.web.post("/api/access/{container}", add_project_container_acl),
diff --git a/swift_browser_ui_frontend/src/common/api.js b/swift_browser_ui_frontend/src/common/api.js
index 6229ba535..5a060e809 100644
--- a/swift_browser_ui_frontend/src/common/api.js
+++ b/swift_browser_ui_frontend/src/common/api.js
@@ -1,6 +1,9 @@
// API fetch functions.
-import { getHumanReadableSize } from "@/common/conv";
+import {
+ getHumanReadableSize,
+ makeGetObjectsMetaURL,
+} from "@/common/conv";
export async function getUser() {
// Function to get the username of the currently displayed user.
@@ -131,7 +134,42 @@ export async function getObjects(container) {
return objects;
}
-export async function getSharedObjects(
+export async function getObjectsMeta (
+ container,
+ objects,
+ url,
+){
+ if (url === undefined) {
+ url = makeGetObjectsMetaURL(container, objects);
+ }
+
+ let ret = await fetch(
+ url, {method: "GET", credentials: "same-origin"},
+ );
+ return ret.json();
+}
+
+export async function updateObjectMeta (
+ container,
+ objectMeta,
+){
+ let url = new URL(
+ "/api/bucket/object/meta?container=".concat(encodeURI(container)),
+ document.location.origin,
+ );
+
+ let ret = await fetch(
+ url,
+ {
+ method: "POST",
+ credentials: "same-origin",
+ body: JSON.stringify([objectMeta]),
+ },
+ );
+ return ret;
+}
+
+export async function getSharedObjects (
project,
container,
url,
diff --git a/swift_browser_ui_frontend/src/common/conv.js b/swift_browser_ui_frontend/src/common/conv.js
index 263bd1662..1f477e025 100644
--- a/swift_browser_ui_frontend/src/common/conv.js
+++ b/swift_browser_ui_frontend/src/common/conv.js
@@ -1,6 +1,8 @@
+
import {
getBucketMeta,
getAccessControlMeta,
+ getObjectsMeta,
} from "./api";
export default function getLangCookie() {
@@ -153,12 +155,32 @@ export function getHumanReadableSize(val) {
return ret;
}
+function extractTags(meta) {
+ if ("usertags" in meta[1]) {
+ return meta[1]["usertags"].split(";");
+ }
+ return [];
+}
+
export async function getTagsForContainer(containerName) {
- let tags = [];
- await getBucketMeta(containerName).then(meta => {
- if ("usertags" in meta[1]) {
- tags = meta[1]["usertags"].split(";");
- }
- });
- return tags;
+ let meta = await getBucketMeta(containerName);
+ return extractTags(meta);
}
+
+export async function getTagsForObjects(containerName, objectList, url) {
+ let meta = await getObjectsMeta(containerName, objectList, url);
+ meta.map(item => item[1] = extractTags(item));
+ return meta;
+}
+
+export function makeGetObjectsMetaURL(container, objects) {
+ return new URL(
+ "/api/bucket/object/meta?container="
+ .concat(encodeURI(container))
+ .concat("&object=")
+ .concat(encodeURI(objects.join(","))),
+ document.location.origin,
+ );
+}
+
+export const taginputConfirmKeys = [",", ";", ":", ".", " ", "Tab", "Enter"];
diff --git a/swift_browser_ui_frontend/src/common/lang.js b/swift_browser_ui_frontend/src/common/lang.js
index 531b994fd..f2869417b 100644
--- a/swift_browser_ui_frontend/src/common/lang.js
+++ b/swift_browser_ui_frontend/src/common/lang.js
@@ -192,6 +192,8 @@ let default_translations = {
copysuccess: "Started copying the bucket in the background",
copyfail: "Failed to copy the bucket",
renderFolders: "Render as Folders",
+ tagName: "Tags",
+ tagMessage: "Press enter to add.",
container_ops: {
addContainer: "Add a new bucket",
editContainer: "Editing bucket: ",
@@ -203,10 +205,10 @@ let default_translations = {
containerMessage: "The name of the new bucket",
fullDelete: "Deleting a bucket with contents requires deleting " +
"all objects inside it first.",
- tagName: "Tags",
- tagMessage: "Press enter to add.",
},
objects: {
+ objectName: "Object",
+ editObject: "Editing object: ",
deleteConfirm: "Delete Objects",
deleteObjects: "Delete Object / Objects",
deleteSuccess: "Objects deleted",
@@ -422,6 +424,8 @@ let default_translations = {
copysuccess: "Aloitettiin säiliön kopiointi taustalla",
copyfail: "Säiliön kopiointi epäonnistui",
renderFolders: "Näytä kansioina",
+ tagName: "Tägit",
+ tagMessage: "Paina 'enter' lisätäksesi.",
container_ops: {
addContainer: "Luo uusi säiliö",
editContainer: "Muokataan säiliötä: ",
@@ -431,10 +435,10 @@ let default_translations = {
containerName: "Säiliö",
containerMessage: "Uuden säiliön nimi",
fullDelete: "Säiliön sisältö on poistettava ennen säiliön postamista.",
- tagName: "Tägit",
- tagMessage: "Paina 'enter' lisätäksesi.",
},
objects: {
+ objectName: "Objekti",
+ editObject: "Muokataan objekti: ",
deleteConfirm: "Poista objektit",
deleteObjects: "Poista objekti / objektit",
deleteSuccess: "Objektit poistettu",
diff --git a/swift_browser_ui_frontend/src/common/router.js b/swift_browser_ui_frontend/src/common/router.js
index 6329b1dcd..04cb9f94b 100644
--- a/swift_browser_ui_frontend/src/common/router.js
+++ b/swift_browser_ui_frontend/src/common/router.js
@@ -3,6 +3,7 @@ import Router from "vue-router";
import DashboardView from "@/views/Dashboard.vue";
import ContainersView from "@/views/Containers.vue";
import ObjectsView from "@/views/Objects.vue";
+import EditObjectView from "@/views/EditObject.vue";
import SharedObjects from "@/views/SharedObjects";
import ShareRequests from "@/views/ShareRequests";
import SharedTo from "@/views/SharedTo";
@@ -89,5 +90,10 @@ export default new Router({
name: "ObjectsView",
component: ObjectsView,
},
+ {
+ path: "/browse/:user/:project/:container/:object/edit",
+ name: "EditObjectView",
+ component: EditObjectView,
+ },
],
});
diff --git a/swift_browser_ui_frontend/src/common/store.js b/swift_browser_ui_frontend/src/common/store.js
index 5239d7f19..513f43706 100644
--- a/swift_browser_ui_frontend/src/common/store.js
+++ b/swift_browser_ui_frontend/src/common/store.js
@@ -9,6 +9,8 @@ import {
} from "./api";
import {
getTagsForContainer,
+ getTagsForObjects,
+ makeGetObjectsMetaURL,
} from "./conv";
Vue.use(Vuex);
@@ -22,6 +24,7 @@ const store = new Vuex.Store({
isLoading: false,
isFullPage: true,
objectCache: [],
+ objectTagsCache: {}, // {"objectName": ["tag1", "tag2"]}
containerCache: [],
containerTagsCache: {}, // {"containerName": ["tag1", "tag2"]}
langs: [
@@ -44,53 +47,23 @@ const store = new Vuex.Store({
// Update container cache with the new container listing.
state.containerCache = payload;
},
+ updateContainerTags(state, payload) {
+ state.containerTagsCache = {
+ ...state.containerTagsCache,
+ [payload.containerName]: payload.tags,
+ };
+ },
updateObjects (
state,
payload,
) {
// Update object cache with the new object listing.
- let container = payload.route.params.container;
- state.isLoading = true;
- if (payload.route.name == "SharedObjects") {
- state.client.getAccessDetails(
- payload.route.params.project,
- container,
- payload.route.params.owner,
- ).then(
- (ret) => {
- return getSharedObjects(
- payload.route.params.owner,
- container,
- ret.address,
- );
- },
- ).then(
- (ret) => {
- state.isLoading = false;
- state.objectCache = ret;
- },
- ).catch(() => {
- state.objectCache = [];
- state.isLoading = false;
- });
- }
- else {
- getObjects(container).then((ret) => {
- if (ret.status != 200) {
- state.isLoading = false;
- }
- state.objectCache = ret;
- state.isLoading = false;
- }).catch(() => {
- state.objectCache = [];
- state.isLoading = false;
- });
- }
+ state.objectCache = payload;
},
- updateContainerTags(state, payload) {
- state.containerTagsCache = {
- ...state.containerTagsCache,
- [payload.containerName]: payload.tags,
+ updateObjectTags (state, payload) {
+ state.objectTagsCache = {
+ ...state.objectTagsCache,
+ [payload.objectName]: payload.tags,
};
},
eraseObjects(state) {
@@ -176,6 +149,74 @@ const store = new Vuex.Store({
);
});
},
+ updateObjects: async function ({ commit, dispatch, client }, route) {
+ let container = route.params.container;
+ commit("loading", true);
+ if (route.name == "SharedObjects") {
+ await client.getAccessDetails(
+ route.params.project,
+ container,
+ route.params.owner,
+ ).then(
+ (ret) => {
+ return getSharedObjects(
+ route.params.owner,
+ container,
+ ret.address,
+ );
+ },
+ ).then(
+ (ret) => {
+ commit("loading", false);
+ commit("updateObjects", ret);
+ },
+ ).catch(() => {
+ commit("updateObjects", []);
+ commit("loading", false);
+ });
+ } else {
+ await getObjects(container).then((ret) => {
+ if (ret.status != 200) {
+ commit("loading", false);
+ }
+ commit("updateObjects", ret);
+ commit("loading", false);
+ }).catch(() => {
+ commit("updateObjects", []);
+ commit("loading", false);
+ });
+ }
+ dispatch("updateObjectTags", route);
+ },
+ updateObjectTags: async function ({ commit, state }, route) {
+ if (!state.objectCache.length) {
+ return;
+ }
+ let objectList = [];
+ for (let i = 0; i < state.objectCache.length; i++) {
+ // Object names end up in the URL, which has hard length limits.
+ // The aiohttp backend has a limit of 2048. The maximum size
+ // for object name is 1024. Set it to a safe enough amount.
+ // We split the requests to prevent reaching said limits.
+ objectList.push(state.objectCache[i].name);
+ const url = makeGetObjectsMetaURL(route.params.container, objectList);
+ if (
+ i === state.objectCache.length - 1
+ || url.href.length > 2000
+ ) {
+ getTagsForObjects(route.params.container, objectList, url)
+ .then(tags =>
+ tags.map(item => {
+ commit(
+ "updateObjectTags",
+ {objectName: item[0], tags: item[1]},
+ );
+ }),
+ );
+ objectList = [];
+ }
+ }
+ },
},
});
diff --git a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue
index b8cdc6573..5956e958e 100644
--- a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue
+++ b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue
@@ -53,10 +53,7 @@ export default {
this.$route.params.container,
to_remove,
).then(() => {
- this.$store.commit({
- type: "updateObjects",
- route: this.$route,
- });
+ this.$store.dispatch("updateObjects", this.$route);
});
},
},
diff --git a/swift_browser_ui_frontend/src/components/ObjectTable.vue b/swift_browser_ui_frontend/src/components/ObjectTable.vue
index 58f23aa12..78b54af22 100644
--- a/swift_browser_ui_frontend/src/components/ObjectTable.vue
+++ b/swift_browser_ui_frontend/src/components/ObjectTable.vue
@@ -42,6 +42,11 @@
{{ $t('message.renderFolders') }}
+
+
+ {{ $t('message.table.showTags') }}
+
+
{{ props.row.name | truncate(100) }}
+
+
+ {{ tag }}
+
+
+
+
+ {{ $t('message.edit') }}
+
+
@@ -317,9 +352,11 @@ export default {
data: function () {
return {
oList: [],
+ tags: {},
selected: undefined,
isPaginated: true,
renderFolders: false,
+ showTags: true,
perPage: 15,
defaultSortDirection: "asc",
searchQuery: "",
@@ -334,9 +371,15 @@ export default {
queryPage () {
return this.$route.query.page || 1;
},
+ container () {
+ return this.$route.params.container;
+ },
objects () {
return this.$store.state.objectCache;
},
+ objectTags() {
+ return this.$store.state.objectTagsCache;
+ },
},
watch: {
searchQuery: function () {
@@ -358,6 +401,9 @@ export default {
}
this.checkedRows = [];
},
+ objectTags: function () {
+ this.tags = this.objectTags; // {"objectName": ["tag1", "tag2"]}
+ },
prefix: function () {
if (this.renderFolders) {
this.oList = this.getFolderContents();
@@ -382,10 +428,7 @@ export default {
methods: {
updateObjects: function () {
// Update current object listing in Vuex if length is too little
- this.$store.commit({
- type: "updateObjects",
- route: this.$route,
- });
+ this.$store.dispatch("updateObjects", this.$route);
},
isRowCheckable: function (row) {
return this.renderFolders ? this.isFile(row.name) : true;
diff --git a/swift_browser_ui_frontend/src/entries/main.js b/swift_browser_ui_frontend/src/entries/main.js
index 48a49c46a..8557688c9 100644
--- a/swift_browser_ui_frontend/src/entries/main.js
+++ b/swift_browser_ui_frontend/src/entries/main.js
@@ -170,10 +170,7 @@ new Vue({
type: "is-success",
});
if (this.$route.params.container != undefined) {
- this.$store.commit({
- type: "updateObjects",
- route: this.$route,
- });
+ this.$store.dispatch("updateObjects", this.$route);
}
},
fileFailureToast: function (file) {
diff --git a/swift_browser_ui_frontend/src/views/AddContainer.vue b/swift_browser_ui_frontend/src/views/AddContainer.vue
index e54996195..e16cc1f66 100644
--- a/swift_browser_ui_frontend/src/views/AddContainer.vue
+++ b/swift_browser_ui_frontend/src/views/AddContainer.vue
@@ -12,8 +12,8 @@
+
+
+ {{ $t('message.objects.editObject') + object }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('message.save') }}
+
+
+
+
+
+
+
+
+
diff --git a/tests/common/mockups.py b/tests/common/mockups.py
index 966d32428..d405eef3f 100644
--- a/tests/common/mockups.py
+++ b/tests/common/mockups.py
@@ -361,18 +361,13 @@ def stat(self, *args):
def ret_obj_stat(self, args):
"""Return object stats from stat query."""
ret = []
- for i in args[1]:
- to_add = {}
- to_add["headers"] = {}
- if "Obj_example" in self.obj_meta[args[0]][i].keys():
- to_add["headers"]["x-object-meta-obj-example"] = "example"
- if "Obj_S3_example" in self.obj_meta[args[0]][i].keys():
- to_add["headers"]["x-object-meta-s3cmd-attrs"] = self.obj_meta[args[0]][
- i
- ]["Obj_S3_example"]
- to_add["success"] = True
+ for obj in args[1]:
+ to_add = {
+ "headers": self.obj_meta[args[0]][obj],
+ "success": True,
+ "object": obj,
+ }
to_add["headers"]["content-type"] = "binary/octet-stream"
- to_add["object"] = i
ret.append(to_add)
return ret
@@ -386,14 +381,26 @@ def ret_cont_stat(self, args):
ret["success"] = True
return ret
- def post(self, container=None, options=None):
+ def post(self, container=None, objects=None, options=None):
"""Mock the post call of SwiftService."""
- if container:
- for (meta, value) in options.get("meta", []):
+
+ def update_meta(target, headers, meta):
+ for (meta, value) in meta:
if not value:
- del self.cont_meta[container][f"x-container-meta-{meta}"]
+ del headers[f"x-{target}-meta-{meta}"]
continue
- self.cont_meta[container][f"x-container-meta-{meta}"] = value
+ headers[f"x-{target}-meta-{meta}"] = value
+
+ if container and objects:
+ for obj in objects:
+ update_meta(
+ "object",
+ self.obj_meta[container][obj.object_name],
+ obj.options.get("meta", []),
+ )
+ return [{"success": True} for _ in objects]
+ elif container:
+ update_meta("container", self.cont_meta[container], options.get("meta", []))
else:
# Get the URL key 2
key = options["meta"][0].split(":")[1]
@@ -412,14 +419,16 @@ def set_swift_meta_container(self, container):
def set_swift_meta_object(self, container, obj):
"""Generate test swift metadata for an object."""
- self.obj_meta[container][obj] = {}
- self.obj_meta[container][obj]["Obj_example"] = "example"
+ self.obj_meta[container][obj] = {
+ "x-object-meta-usertags": "objects;with;tags",
+ "x-object-meta-obj-example": "example",
+ }
def set_s3_meta_object(self, container, obj):
"""Generate test s3 metadata for an object."""
self.obj_meta[container][obj] = {}
self.obj_meta[container][obj][
- "Obj_S3_example"
+ "x-object-meta-s3cmd-attrs"
] = "atime:1536648772/ctime:1536648921/gid:101/gname:example"
diff --git a/tests/cypress/integration/browser.spec.js b/tests/cypress/integration/browser.spec.js
index 702a62578..887c88560 100644
--- a/tests/cypress/integration/browser.spec.js
+++ b/tests/cypress/integration/browser.spec.js
@@ -38,7 +38,7 @@ describe("Browse buckets and test operations", function () {
.within(() => {
cy.get('td').eq(0).click()
cy.get('td').eq(1).then(($elem) => {
- expect($elem.get(0).innerText.trim()).to.have.lengthOf(40)
+ expect($elem.get(0).innerText.split('\n')[0].trim()).to.have.lengthOf(40)
})
})
})
@@ -64,19 +64,19 @@ describe("Browse buckets and test operations", function () {
})
- it("should display, add, remove tags", () => {
+ it("should display, add, remove container tags", () => {
// container list loads with tags
cy.get('tbody .tags .tag').should('have.length', 40)
cy.get('tbody tr .tags').first().children('.tag').should('have.length', 4)
- // // remove one tag
+ // remove one tag
cy.get('tbody tr').contains('Edit').click()
cy.get('h1').should('contain', 'Editing bucket')
cy.get('.delete').first().click()
cy.get('button').contains('Save').click()
cy.get('tbody tr .tags').first().children('.tag').should('have.length', 3)
- // // add few tags
+ // add few tags
cy.get('tbody tr').contains('Edit').click()
cy.get('.taginput input').type('adding.couple more')
cy.get('button').contains('Save').click()
@@ -87,10 +87,39 @@ describe("Browse buckets and test operations", function () {
cy.get('.taginput-container').children('span').should('have.length', 6)
cy.get('.delete').each(el => {
cy.get('.delete').first().click()
- cy.wait(100)
});
cy.get('.taginput-container').children('span').should('have.length', 0)
cy.get('button').contains('Save').click()
cy.get('tbody .tags .tag').should('have.length', 36)
})
+
+ it("should display, add, remove object tags", () => {
+ cy.get('tbody tr').first().dblclick()
+
+ // object list loads with tags
+ cy.get('tbody tr .tags').first().children('.tag').should('have.length', 3)
+
+ // remove one tag
+ cy.get('tbody tr').contains('Edit').click()
+ cy.get('h1').should('contain', 'Editing object')
+ cy.get('.delete').first().click()
+ cy.get('button').contains('Save').click()
+ cy.get('tbody tr .tags').first().children('.tag').should('have.length', 2)
+
+ // add few tags
+ cy.get('tbody tr').contains('Edit').click()
+ cy.get('.taginput input').type('adding.couple more')
+ cy.get('button').contains('Save').click()
+ cy.get('tbody tr .tags').first().children('.tag').should('have.length', 5)
+
+ // remove all tags from an object
+ cy.get('tbody tr').contains('Edit').click()
+ cy.get('.taginput-container').children('span').should('have.length', 5)
+ cy.get('.delete').each(el => {
+ cy.get('.delete').first().click()
+ });
+ cy.get('.taginput-container').children('span').should('have.length', 0)
+ cy.get('button').contains('Save').click()
+ cy.get('tbody tr .tags').first().children('.tag').should('have.length', 0)
+ })
})
diff --git a/tests/ui_unit/test_api.py b/tests/ui_unit/test_api.py
index 179be07ec..f391c812f 100644
--- a/tests/ui_unit/test_api.py
+++ b/tests/ui_unit/test_api.py
@@ -16,6 +16,7 @@
get_os_user,
os_list_projects,
update_metadata_bucket,
+ update_metadata_object,
)
from swift_browser_ui.ui.api import swift_list_buckets, swift_list_objects
from swift_browser_ui.ui.api import swift_download_object
@@ -298,7 +299,49 @@ async def test_get_object_meta_swift(self):
resp = await get_metadata_object(self.request)
resp = json.loads(resp.text)
- expected = [[objkey, {"obj-example": "example"}]]
+ expected = [[objkey, {"obj-example": "example", "usertags": "objects;with;tags"}]]
+ self.assertEqual(resp, expected)
+
+ async def test_set_object_meta_swift(self):
+ """Test metadata API endpoint with container metadata."""
+ req_sessions = self.request.app["Sessions"]
+ req_sessions[self.cookie]["ST_conn"].init_with_data(
+ containers=1,
+ object_range=(1, 1),
+ size_range=(252144, 252144),
+ )
+ container = "test-container-0"
+ req_sessions[self.cookie]["ST_conn"].set_swift_meta_container(container)
+ for obj in req_sessions[self.cookie]["ST_conn"].obj_meta.keys():
+ req_sessions[self.cookie]["ST_conn"].set_swift_meta_object(container, obj)
+
+ obj = list(req_sessions[self.cookie]["ST_conn"].obj_meta[container])[0]
+ self.request.query["container"] = container
+ self.request.set_post(
+ json.dumps(
+ [
+ [
+ obj,
+ {"usertags": "tags;for;testing"},
+ ]
+ ]
+ )
+ )
+
+ post_resp = await update_metadata_object(self.request)
+ self.assertEqual(post_resp.status, 204)
+
+ self.request.set_post(None)
+ self.request.query["object"] = obj
+ resp = await get_metadata_object(self.request)
+ resp = json.loads(resp.text)
+
+ expected = [
+ [
+ obj,
+ {"obj-example": "example", "usertags": "tags;for;testing"},
+ ]
+ ]
self.assertEqual(resp, expected)
async def test_get_object_meta_s3(self):
@@ -372,7 +415,10 @@ async def test_get_object_meta_swift_whole(self):
resp = await get_metadata_object(self.request)
resp = json.loads(resp.text)
- comp = [[i, {"obj-example": "example"}] for i in [j["name"] for j in objs]]
+ comp = [
+ [i, {"usertags": "objects;with;tags", "obj-example": "example"}]
+ for i in [j["name"] for j in objs]
+ ]
self.assertEqual(resp, comp)