From 6e9c8a1517c3297fb4a640989dfb55febf9b0480 Mon Sep 17 00:00:00 2001 From: James Norton Date: Fri, 23 Feb 2024 15:39:08 -0500 Subject: [PATCH] Harmony 1681 - Service Image Tag Endpoint (#535) * HARMONY-1681: Add endpoint for service-images * HARMONY-1681: Change service tag routes to only use token auth * HARMONY-1681: Rename routes to /service-image-tag * HARMONY-1681: Add documentation for service-image-tags endpoint. * HARMONY-1681: Add line in docs about requirement of bearer token * HARMONY-1681: Fix linter issues and add JSDocs * HARMONY-1681: Add info about tag requirements to docs * HARMONY-1599: Update dependencies. * HARMONY-1681: Make /service-image-tag GET routes available via OAUTH * HARMONY-1681: Add link to docker tag reqs in comment * HARMONY-1681: Add Batchee and Stitchee to expected test output for tag listings * HARMONY-1681: Prevent invalid env vars from causing crashes when handling service tag requests --------- Co-authored-by: Chris Durbin --- package.json | 2 +- .../app/frontends/service-image-tags.ts | 160 +++++++ services/harmony/app/markdown/docs.md | 4 +- services/harmony/app/markdown/endpoints.md | 1 + .../app/markdown/service-image-tags.md | 91 ++++ .../earthdata-login-oauth-authorizer.ts | 3 +- .../harmony/app/middleware/error-handler.ts | 2 +- services/harmony/app/routers/router.ts | 8 +- services/harmony/env-defaults | 3 +- services/harmony/package.json | 2 +- services/harmony/test/service-image-tags.ts | 393 ++++++++++++++++++ 11 files changed, 662 insertions(+), 7 deletions(-) create mode 100644 services/harmony/app/frontends/service-image-tags.ts create mode 100644 services/harmony/app/markdown/service-image-tags.md create mode 100644 services/harmony/test/service-image-tags.ts diff --git a/package.json b/package.json index e55d3ba26..bbe3576ec 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "better-npm-audit audit && lerna run test --load-env-files=false", "test-fast": "lerna run test-fast --load-env-files=false", "test-bail": "lerna run test-bail --load-env-files=false", - "coverage": "lerna run coverage", + "coverage": "lerna run coverage --load-env-files=false", "lint": "lerna run lint", "start": "cd services/harmony && NODE_OPTIONS=--max-old-space-size=3096 ts-node -r tsconfig-paths/register app/server.ts", "start-dev": "cd services/harmony && strict-npm-engines && ts-node-dev --no-notify -r tsconfig-paths/register --watch app/views,public/js --respawn app/server", diff --git a/services/harmony/app/frontends/service-image-tags.ts b/services/harmony/app/frontends/service-image-tags.ts new file mode 100644 index 000000000..09bcfcb8e --- /dev/null +++ b/services/harmony/app/frontends/service-image-tags.ts @@ -0,0 +1,160 @@ + +import { Response, NextFunction } from 'express'; +import HarmonyRequest from '../models/harmony-request'; +import { getEdlGroupInformation } from '../util/edl-api'; + +const harmonyTaskServices = [ + 'work-item-scheduler', + 'work-item-updater', + 'work-reaper', + 'work-failer', +]; + +/** + * Compute the map of services to tags. Harmony core services are excluded. + * @returns The map of canonical service names to image tags. + */ +function getImageTagMap(): {} { + const imageMap = {}; + for (const v of Object.keys(process.env)) { + if (v.endsWith('_IMAGE')) { + const serviceName = v.slice(0, -6).toLowerCase().replaceAll('_', '-'); + // add in any services that are not Harmony core task services + if (!harmonyTaskServices.includes(serviceName)) { + const image = process.env[v]; + const match = image.match(/.*:(.*)/); + if (match) { + const tag = match[1] || ''; + imageMap[serviceName] = tag; + } + } + } + } + + return imageMap; +} + +/** + * Validate that the service exists + * @param req - The request object + * @param res - The response object - will be used to send an error if the validation fails + * @returns A Promise containing `true` if the service exists, `false` otherwise + */ +async function validateServiceExists( + res: Response, service: string, +): Promise { + const imageMap = getImageTagMap(); + if (!imageMap[service]) { + res.statusCode = 404; + const message = `Service ${service} does not exist.\nThe existing services and their images are\n${JSON.stringify(imageMap, null, 2)}`; + res.send(message); + return false; + } + return true; +} + +/** + * Validate that the user is in the deployers or the admin group + * @param req - The request object + * @param res - The response object - will be used to send an error if the validation fails + * @returns A Promise containing `true` if the user in in either group, `false` otherwise + */ +async function validateUserIsInDeployerOrAdminGroup( + req: HarmonyRequest, res: Response, +): Promise { + const { isAdmin, isServiceDeployer } = await getEdlGroupInformation( + req.user, req.context.logger, + ); + + if (!isServiceDeployer && !isAdmin) { + res.statusCode = 403; + res.send(`User ${req.user} is not in the service deployers or admin EDL groups`); + return false; + } + return true; +} + +/** + * Verify that the given tag is valid. Send an error if it is not. + * @param req - The request object + * @param res - The response object - will be used to send an error if the validation fails + * @returns a Promise containing `true` if the tag is valid, false if not + */ +async function validateTag( + req: HarmonyRequest, res: Response, +): Promise { + const { tag } = req.body; + // See https://docs.docker.com/engine/reference/commandline/image_tag/ + const tagRegex = /^[a-zA-Z\d_][a-zA-Z\d\-_.]{0,127}$/; + if (!tagRegex.test(tag)) { + res.statusCode = 400; + res.send('A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters.'); + return false; + } + return true; +} + +/** + * Get a map of the canonical service names to their current tags + * @param req - The request object + * @param res - The response object + * @param _next - The next middleware in the chain + */ +export async function getServiceImageTags( + req: HarmonyRequest, res: Response, _next: NextFunction, +): Promise { + if (! await validateUserIsInDeployerOrAdminGroup(req, res)) return; + + const imageMap = getImageTagMap(); + res.statusCode = 200; + res.send(imageMap); +} + +/** + * Get the current image tag for the given service + * @param req - The request object + * @param res - The response object + * @param _next - The next middleware in the chain + */ +export async function getServiceImageTag( + req: HarmonyRequest, res: Response, _next: NextFunction, +): Promise { + if (! await validateUserIsInDeployerOrAdminGroup(req, res)) return; + const { service } = req.params; + if (! await validateServiceExists(res, service)) return; + + const imageTagMap = getImageTagMap(); + const tag = imageTagMap[service]; + res.statusCode = 200; + res.send({ 'tag': tag }); +} + +/** + * Update the tag for the given service + * + * @param req - The request object + * @param res - The response object + * @param _next - The next middleware in the chain + */ +export async function updateServiceImageTag( + req: HarmonyRequest, res: Response, _next: NextFunction, +): Promise { + if (! await validateUserIsInDeployerOrAdminGroup(req, res)) return; + + const { service } = req.params; + if (! await validateServiceExists(res, service)) return; + if (!req.body || !req.body.tag) { + res.statusCode = 400; + res.send('\'tag\' is a required body parameter'); + return; + } + + if (! await validateTag(req, res)) return; + + const { tag } = req.body; + + // TODO HARMONY-1701 run deployment script here + + res.statusCode = 201; + res.send({ 'tag': tag }); +} diff --git a/services/harmony/app/markdown/docs.md b/services/harmony/app/markdown/docs.md index a3d24b763..c08637db6 100644 --- a/services/harmony/app/markdown/docs.md +++ b/services/harmony/app/markdown/docs.md @@ -18,4 +18,6 @@ !!!include(cloud-access.md)!!! -!!!include(versions.md)!!! \ No newline at end of file +!!!include(versions.md)!!! + +!!!include(service-image-tags.md)!!! \ No newline at end of file diff --git a/services/harmony/app/markdown/endpoints.md b/services/harmony/app/markdown/endpoints.md index bf2e6b157..0680e1b68 100644 --- a/services/harmony/app/markdown/endpoints.md +++ b/services/harmony/app/markdown/endpoints.md @@ -13,6 +13,7 @@ All of the public endpoints for Harmony users other than the OGC Coverages and W | /stac | [The API for retrieving STAC catalog and catalog items for processed data](#stac-details) | | /staging-bucket-policy | [The policy generator for external (user) bucket storage](#user-owned-buckets-for-harmony-output) | | /versions | [Returns JSON indicating the image and tag each deployed service is running](#versions-details) | +| /service-image-tag | [The API for managing service image tags/versions](#service-image-tags-details) | | /workflow-ui | The Workflow UI for monitoring and interacting with running jobs | --- **Table {{tableCounter}}** - Harmony routes other than OGC Coverages and WMS diff --git a/services/harmony/app/markdown/service-image-tags.md b/services/harmony/app/markdown/service-image-tags.md new file mode 100644 index 000000000..755405003 --- /dev/null +++ b/services/harmony/app/markdown/service-image-tags.md @@ -0,0 +1,91 @@ +### Managing Service Image Tags (Versions) + +Using the `service-image-tag` endpoint, service providers can manage the versions of their services deployed to an environment. Note that a user must be a member of either the EDL `Harmony Service Deployers` +group or the EDL `Harmony Admin` group to access this endpoint, and requests to this endpoint _must_ include +an EDL bearer token header, .e.g., `Authorization: Bearer `. + +#### Get backend service tag (version) information for all services + +``` + +GET {{root}}/service-image-tag + +``` +**Example {{exampleCounter}}** - Getting backend service image tags using the `service-image-tag` API + +The returned JSON response is a map of canonical service names to tags: + +```JSON +{ + "service-runner": "latest", + "harmony-gdal-adapter": "latest", + "hybig": "latest", + "harmony-service-example": "latest", + "harmony-netcdf-to-zarr": "latest", + "harmony-regridder": "latest", + "swath-projector": "latest", + "hoss": "latest", + "sds-maskfill": "latest", + "trajectory-subsetter": "latest", + "podaac-concise": "sit", + "podaac-l2-subsetter": "sit", + "podaac-ps3": "latest", + "podaac-netcdf-converter": "latest", + "query-cmr": "latest", + "giovanni-adapter": "latest", + "geoloco": "latest" +} +``` +--- +**Example {{exampleCounter}}** - Harmony `service-image-tags` response + +#### Get backend service tag (version) information for a specific service + +``` + +GET {{root}}/service-image-tag/#canonical-service-name + +``` +**Example {{exampleCounter}}** - Getting a specific backend service image tag using the `service-image-tags` API + +The returned JSON response is a map with a single `tag` field: + +```JSON +{ + "tag": "1.2.3" +} +``` +--- +**Example {{exampleCounter}}** - Harmony `service-image-tags` response for a single service + +#### Update backend service tag (version) for a specific service + +``` + +PUT {{root}}/service-image-tag/#canonical-service-name + +``` +**Example {{exampleCounter}}** - Updating a specific backend service image tag using the `service-image-tags` API + +The body of the `PUT` request should be a JSON object of the same form as the single service `GET` response in the +example above: + +```JSON +{ + "tag": "new-version" +} +``` + +The returned JSON response is the same as the single service request above, indicating the new tag value + +```JSON +{ + "tag": "new-version" +} +``` +--- +**Example {{exampleCounter}}** - Harmony `service-image-tags` response for a updating a single service + + +**Important** from the [Docker documentation](https://docs.docker.com/engine/reference/commandline/image_tag/): +>A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters. diff --git a/services/harmony/app/middleware/earthdata-login-oauth-authorizer.ts b/services/harmony/app/middleware/earthdata-login-oauth-authorizer.ts index 19ce3d244..4f032046f 100644 --- a/services/harmony/app/middleware/earthdata-login-oauth-authorizer.ts +++ b/services/harmony/app/middleware/earthdata-login-oauth-authorizer.ts @@ -179,7 +179,8 @@ export default function buildEdlAuthorizer(paths: Array = []): return async function earthdataLoginAuthorizer(req: HarmonyRequest, res, next): Promise { const oauth2 = simpleOAuth2.create(oauthOptions); const { token } = req.signedCookies; - const requiresAuth = paths.some((p) => req.path.match(p)) && !req.authorized; + const requiresAuth = paths.some((p) => req.path.match(p)) && !req.authorized + && req.method.toUpperCase() != 'PUT'; // we don't support PUT requests with the redirect let handler; try { diff --git a/services/harmony/app/middleware/error-handler.ts b/services/harmony/app/middleware/error-handler.ts index edebbfda2..f5c24a161 100644 --- a/services/harmony/app/middleware/error-handler.ts +++ b/services/harmony/app/middleware/error-handler.ts @@ -9,7 +9,7 @@ import { import HarmonyRequest from '../models/harmony-request'; const errorTemplate = fs.readFileSync(path.join(__dirname, '../views/server-error.mustache.html'), { encoding: 'utf8' }); -const jsonErrorRoutesRegex = /jobs|capabilities|ogc-api-coverages|stac|metrics|health|configuration|workflow-ui\/.*\/(?:links|logs|retry)/; +const jsonErrorRoutesRegex = /jobs|capabilities|ogc-api-coverages|stac|metrics|health|configuration|workflow-ui|service-image\/.*\/(?:links|logs|retry)/; /** * Returns true if the provided error should be returned as JSON. diff --git a/services/harmony/app/routers/router.ts b/services/harmony/app/routers/router.ts index a316a1ab9..fca7e84f2 100644 --- a/services/harmony/app/routers/router.ts +++ b/services/harmony/app/routers/router.ts @@ -28,7 +28,7 @@ import { setLogLevel } from '../frontends/configuration'; import getVersions from '../frontends/versions'; import serviceInvoker from '../backends/service-invoker'; import HarmonyRequest, { addRequestContextToOperation } from '../models/harmony-request'; - +import { getServiceImageTag, getServiceImageTags, updateServiceImageTag } from '../frontends/service-image-tags'; import cmrCollectionReader = require('../middleware/cmr-collection-reader'); import cmrUmmCollectionReader = require('../middleware/cmr-umm-collection-reader'); import env from '../util/env'; @@ -136,6 +136,7 @@ const authorizedRoutes = [ '/logs*', '/service-results/*', '/workflow-ui*', + '/service-image*', ]; /** @@ -283,6 +284,11 @@ export default function router({ skipEarthdataLogin = 'false' }: RouterConfig): res.send('OK'); }); + // service images + result.get('/service-image-tag', asyncHandler(getServiceImageTags)); + result.get('/service-image-tag/:service', asyncHandler(getServiceImageTag)); + result.put('/service-image-tag/:service', jsonParser, asyncHandler(updateServiceImageTag)); + result.get('/*', () => { throw new NotFoundError('The requested page was not found.'); }); result.post('/*', () => { throw new NotFoundError('The requested POST page was not found.'); }); return result; diff --git a/services/harmony/env-defaults b/services/harmony/env-defaults index afe824d50..70196e2ac 100644 --- a/services/harmony/env-defaults +++ b/services/harmony/env-defaults @@ -390,7 +390,8 @@ HARMONY_NETCDF_TO_ZARR_LIMITS_CPU=128m HARMONY_NETCDF_TO_ZARR_LIMITS_MEMORY=512Mi HARMONY_NETCDF_TO_ZARR_INVOCATION_ARGS='python -m harmony_netcdf_to_zarr' -HARMONY_REGRIDDER_IMAGE=sds/harmony-regridder:latestHARMONY_REGRIDDER_REQUESTS_CPU=128m +HARMONY_REGRIDDER_IMAGE=sds/harmony-regridder:latest +HARMONY_REGRIDDER_REQUESTS_CPU=128m HARMONY_REGRIDDER_REQUESTS_MEMORY=128Mi HARMONY_REGRIDDER_LIMITS_CPU=128m HARMONY_REGRIDDER_LIMITS_MEMORY=512Mi diff --git a/services/harmony/package.json b/services/harmony/package.json index df08f3596..f6e387a2f 100644 --- a/services/harmony/package.json +++ b/services/harmony/package.json @@ -7,7 +7,7 @@ "test": "strict-npm-engines && eslint --ext .ts --ignore-pattern built . && nyc mocha && better-npm-audit audit", "test-fast": "TS_NODE_TRANSPILE_ONLY=true mocha", "test-bail": "TS_NODE_TRANSPILE_ONLY=true mocha --bail", - "coverage": "nyc mocha", + "coverage": "nyc mocha --load-env-files=false --bail 2>&1 | tee coverage-test.log", "lint": "eslint --ext .ts --ignore-pattern built .", "start": "NODE_OPTIONS=--max-old-space-size=3096 ts-node -r tsconfig-paths/register app/server.ts", "start-dev": "strict-npm-engines && ts-node-dev --no-notify -r tsconfig-paths/register --watch app/views,public/js --respawn app/server", diff --git a/services/harmony/test/service-image-tags.ts b/services/harmony/test/service-image-tags.ts new file mode 100644 index 000000000..8cd198be7 --- /dev/null +++ b/services/harmony/test/service-image-tags.ts @@ -0,0 +1,393 @@ +import { expect } from 'chai'; +import request from 'supertest'; + +import hookServersStartStop from './helpers/servers'; +import { hookRedirect } from './helpers/hooks'; +import { auth } from './helpers/auth'; + +// +// Tests for the service-image endpoint +// +// Note: this test relies on the EDL fixture that includes users `eve` and `buzz` in the +// deployers group and `adam` in the admin group, and `joe` in neither +// + +const serviceImages = { + 'service-runner': 'latest', + 'harmony-gdal-adapter': 'latest', + 'hybig': 'latest', + 'harmony-service-example': 'latest', + 'harmony-netcdf-to-zarr': 'latest', + 'harmony-regridder': 'latest', + 'swath-projector': 'latest', + 'hoss': 'latest', + 'sds-maskfill': 'latest', + 'trajectory-subsetter': 'latest', + 'podaac-concise': 'sit', + 'podaac-l2-subsetter': 'sit', + 'podaac-ps3': 'latest', + 'podaac-netcdf-converter': 'latest', + 'query-cmr': 'latest', + 'giovanni-adapter': 'latest', + 'geoloco': 'latest', + 'batchee': 'latest', + 'stitchee': 'latest', +}; + +const errorMsg404 = 'Service foo does not exist.\nThe existing services and their images are\n' + + JSON.stringify(serviceImages, null, 2); + +const userErrorMsg = 'User joe is not in the service deployers or admin EDL groups'; + +const tagContentErrorMsg = 'A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters.'; + +describe('Service image endpoint', async function () { + hookServersStartStop({ skipEarthdataLogin: false }); + + describe('List service images', async function () { + describe('when a user is not in the EDL service deployers or admin groups', async function () { + before(async function () { + hookRedirect('joe'); + this.res = await request(this.frontend).get('/service-image-tag').use(auth({ username: 'joe' })); + }); + + after(function () { + delete this.res; + }); + + it('rejects the user', async function () { + expect(this.res.status).to.equal(403); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(userErrorMsg); + }); + }); + + describe('when a user is in the EDL service deployers group', async function () { + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).get('/service-image-tag').use(auth({ username: 'buzz' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a map of images', async function () { + expect(this.res.status).to.equal(200); + expect(this.res.body).to.eql(serviceImages); + }); + }); + + describe('when a user is in the EDL admin group', async function () { + before(async function () { + hookRedirect('adam'); + this.res = await request(this.frontend).get('/service-image-tag').use(auth({ username: 'adam' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a map of images', async function () { + expect(this.res.status).to.equal(200); + expect(this.res.body).to.eql(serviceImages); + }); + }); + }); + + describe('Get service image', async function () { + describe('when a user is not in the EDL service deployers or admin groups', async function () { + + describe('when the service does not exist', async function () { + before(async function () { + hookRedirect('joe'); + this.res = await request(this.frontend).get('/service-image-tag/foo').use(auth({ username: 'joe' })); + }); + + after(function () { + delete this.res; + }); + + it('rejects the user', async function () { + expect(this.res.status).to.equal(403); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(userErrorMsg); + }); + + }); + + describe('when the service does exist', async function () { + before(async function () { + hookRedirect('joe'); + this.res = await request(this.frontend).get('/service-image-tag/hoss').use(auth({ username: 'joe' })); + }); + + after(function () { + delete this.res; + }); + + it('rejects the user', async function () { + expect(this.res.status).to.equal(403); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(userErrorMsg); + }); + + }); + }); + + describe('when a user is in the EDL service deployers group', async function () { + + describe('when the service does not exist', async function () { + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).get('/service-image-tag/foo').use(auth({ username: 'buzz' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 404', async function () { + expect(this.res.status).to.equal(404); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(errorMsg404); + }); + + }); + + describe('when the service does exist', async function () { + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).get('/service-image-tag/hoss').use(auth({ username: 'buzz' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 200', async function () { + expect(this.res.status).to.equal(200); + }); + + it('returns the service image information', async function () { + expect(this.res.body).to.eql({ + 'tag': 'latest', + }); + }); + + }); + }); + + describe('when a user is in the EDL admin group', async function () { + + describe('when the service does not exist', async function () { + before(async function () { + hookRedirect('adam'); + this.res = await request(this.frontend).get('/service-image-tag/foo').use(auth({ username: 'adam' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 404', async function () { + expect(this.res.status).to.equal(404); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(errorMsg404); + }); + + }); + + describe('when the service does exist', async function () { + before(async function () { + hookRedirect('adam'); + this.res = await request(this.frontend).get('/service-image-tag/hoss').use(auth({ username: 'adam' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 200', async function () { + expect(this.res.status).to.equal(200); + }); + + it('returns the service image information', async function () { + expect(this.res.body).to.eql({ + 'tag': 'latest', + }); + }); + + }); + }); + + }); + + describe('Update service image', function () { + describe('when a user is not in the EDL service deployers or admin groups', async function () { + + before(async function () { + hookRedirect('joe'); + this.res = await request(this.frontend).put('/service-image-tag/hoss').use(auth({ username: 'joe' })); + }); + + after(function () { + delete this.res; + }); + + it('rejects the user', async function () { + expect(this.res.status).to.equal(403); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(userErrorMsg); + }); + }); + + describe('when the service does not exist', async function () { + + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).put('/service-image-tag/foo').use(auth({ username: 'buzz' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 404', async function () { + expect(this.res.status).to.equal(404); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(errorMsg404); + }); + }); + + describe('when the tag is not sent in the request', async function () { + + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).put('/service-image-tag/harmony-service-example').use(auth({ username: 'buzz' })); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 400', async function () { + expect(this.res.status).to.equal(400); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal('\'tag\' is a required body parameter'); + }); + }); + + describe('when the user is in the deployers group, but the tag has invalid characters', async function () { + + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).put('/service-image-tag/harmony-service-example').use(auth({ username: 'buzz' })).send({ tag: 'foo:bar' }); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 400', async function () { + expect(this.res.status).to.equal(400); + }); + + it('returns a meaningful error message', async function () { + expect(this.res.text).to.equal(tagContentErrorMsg); + }); + }); + + describe('when the user is in the deployers group and a valid tag is sent in the request', async function () { + + before(async function () { + hookRedirect('buzz'); + this.res = await request(this.frontend).put('/service-image-tag/harmony-service-example').use(auth({ username: 'buzz' })).send({ tag: 'foo' }); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 201', async function () { + expect(this.res.status).to.equal(201); + }); + + it('returns the tag we sent', async function () { + expect(this.res.body).to.eql({ 'tag': 'foo' }); + }); + + // TODO HARMONY-1701 enable this test or remove it as you see fit + // describe('when the user checks the tag', async function () { + // before(async function () { + // hookRedirect('buzz'); + // this.res = await request(this.frontend).get('/service-image-tag/harmony-service-example').use(auth({ username: 'buzz' })); + // }); + + // after(function () { + // delete this.res; + // }); + + // it('returns the updated tag', async function () { + // expect(this.res.body).to.eql({ + // 'tag': 'foo', + // }); + // }); + // }); + }); + + describe('when the user is in the admin group and a valid tag is sent in the request', async function () { + + before(async function () { + hookRedirect('adam'); + this.res = await request(this.frontend).put('/service-image-tag/harmony-service-example').use(auth({ username: 'adam' })).send({ tag: 'foo' }); + }); + + after(function () { + delete this.res; + }); + + it('returns a status 201', async function () { + expect(this.res.status).to.equal(201); + }); + + it('returns the tag we sent', async function () { + expect(this.res.body).to.eql({ 'tag': 'foo' }); + }); + + // TODO HARMONY-1701 enable this test or remove it as you see fit + // describe('when the user checks the tag', async function () { + // before(async function () { + // hookRedirect('adam'); + // this.res = await request(this.frontend).get('/service-image-tag/harmony-service-example').use(auth({ username: 'adam' })); + // }); + + // after(function () { + // delete this.res; + // }); + + // it('returns the updated tag', async function () { + // expect(this.res.body).to.eql({ + // 'tag': 'foo', + // }); + // }); + // }); + }); + }); +});