diff --git a/backend/lib/cache/index.js b/backend/lib/cache/index.js index 1c2dd64b77..63dc83d055 100644 --- a/backend/lib/cache/index.js +++ b/backend/lib/cache/index.js @@ -88,6 +88,13 @@ module.exports = { } return _.filter(cache.getSeeds(), predicate) }, + getProject (name) { + const project = cache.get('projects').find(['metadata.name', name]) + if (!project) { + throw new NotFound(`Project with name '${name}' not found`) + } + return project + }, getProjects () { return cache.getProjects() }, diff --git a/backend/lib/routes/index.js b/backend/lib/routes/index.js index be84a5e961..ec727db6cb 100644 --- a/backend/lib/routes/index.js +++ b/backend/lib/routes/index.js @@ -17,7 +17,7 @@ module.exports = { '/cloudprofiles': require('./cloudprofiles'), '/seeds': require('./seeds'), '/gardenerextensions': require('./gardenerExtensions'), - '/namespaces': require('./namespaces'), + '/projects': require('./projects'), '/namespaces/:namespace/shoots': require('./shoots'), '/namespaces/:namespace/tickets': require('./tickets'), '/namespaces/:namespace/cloudprovidersecrets': require('./cloudProviderSecrets'), diff --git a/backend/lib/routes/namespaces.js b/backend/lib/routes/projects.js similarity index 82% rename from backend/lib/routes/namespaces.js rename to backend/lib/routes/projects.js index d7fa27ffdf..6992aaad29 100644 --- a/backend/lib/routes/namespaces.js +++ b/backend/lib/routes/projects.js @@ -1,5 +1,5 @@ // -// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 // @@ -12,7 +12,7 @@ const { metricsRoute } = require('../middleware') const router = module.exports = express.Router() -const metricsMiddleware = metricsRoute('namespaces') +const metricsMiddleware = metricsRoute('project') router.route('/') .all(metricsMiddleware) @@ -34,12 +34,12 @@ router.route('/') } }) -router.route('/:namespace') +router.route('/:project') .all(metricsMiddleware) .get(async (req, res, next) => { try { const user = req.user - const name = req.params.namespace + const name = req.params.project res.send(await projects.read({ user, name })) } catch (err) { next(err) @@ -48,7 +48,7 @@ router.route('/:namespace') .put(async (req, res, next) => { try { const user = req.user - const name = req.params.namespace + const name = req.params.project const body = req.body res.send(await projects.patch({ user, name, body })) } catch (err) { @@ -58,7 +58,7 @@ router.route('/:namespace') .patch(async (req, res, next) => { try { const user = req.user - const name = req.params.namespace + const name = req.params.project const body = req.body res.send(await projects.patch({ user, name, body })) } catch (err) { @@ -68,7 +68,7 @@ router.route('/:namespace') .delete(async (req, res, next) => { try { const user = req.user - const name = req.params.namespace + const name = req.params.project res.send(await projects.remove({ user, name })) } catch (err) { next(err) diff --git a/backend/lib/security/index.js b/backend/lib/security/index.js index e13b688e68..658f97103e 100644 --- a/backend/lib/security/index.js +++ b/backend/lib/security/index.js @@ -18,8 +18,6 @@ const logger = require('../logger') const { sessionSecrets, oidc = {} } = require('../config') const { - encodeState, - decodeState, sign, verify, decode, @@ -499,8 +497,6 @@ exports = module.exports = { COOKIE_HEADER_PAYLOAD, COOKIE_SIGNATURE, COOKIE_TOKEN, - encodeState, - decodeState, sign, decode, verify, diff --git a/backend/lib/security/jose.js b/backend/lib/security/jose.js index 2da499d7fe..189764e01a 100644 --- a/backend/lib/security/jose.js +++ b/backend/lib/security/jose.js @@ -34,18 +34,6 @@ function decodeSecret (input) { return Buffer.from(input) } -function encodeState (data = {}) { - return base64url.encode(JSON.stringify(data)) -} - -function decodeState (state) { - try { - return JSON.parse(base64url.decode(state)) - } catch (err) { - return {} - } -} - module.exports = sessionSecrets => { if (!sessionSecrets?.length) { throw new Error('No session secrets provided') @@ -56,8 +44,6 @@ module.exports = sessionSecrets => { const encoder = new TextEncoder() const decoder = new TextDecoder() return { - encodeState, - decodeState, sign (payload, secretOrPrivateKey, { ...options } = {}) { if (isPlainObject(secretOrPrivateKey)) { options = secretOrPrivateKey diff --git a/backend/lib/services/projects.js b/backend/lib/services/projects.js index 8291209bac..2bc39e138f 100644 --- a/backend/lib/services/projects.js +++ b/backend/lib/services/projects.js @@ -1,5 +1,5 @@ // -// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 // @@ -7,122 +7,32 @@ 'use strict' const _ = require('lodash') -const { - dashboardClient, - Resources -} = require('@gardener-dashboard/kube-client') +const { dashboardClient } = require('@gardener-dashboard/kube-client') const { PreconditionFailed, InternalServerError } = require('http-errors') const shoots = require('./shoots') const authorization = require('./authorization') -const { projectFilter } = require('../utils') +const { projectFilter, trimProject } = require('../utils') const cache = require('../cache') const PROJECT_INITIALIZATION_TIMEOUT = 30 * 1000 -function fromResource ({ metadata, spec = {}, status = {} }) { - const role = 'project' - const { name, resourceVersion, creationTimestamp, annotations } = metadata - const { namespace, createdBy, owner, description, purpose } = spec - const { staleSinceTimestamp, staleAutoDeleteTimestamp, phase } = status +async function validateDeletePreconditions ({ user, name }) { + const project = cache.getProject(name) + const namespace = _.get(project, 'spec.namespace') - return { - metadata: { - name, - namespace, - resourceVersion, - role, - creationTimestamp, - annotations - }, - data: { - createdBy: fromSubject(createdBy), - owner: fromSubject(owner), - description, - purpose, - staleSinceTimestamp, - staleAutoDeleteTimestamp, - phase - } - } -} - -function toResource ({ metadata, data = {} }) { - const { apiVersion, kind } = Resources.Project - const { name, namespace, resourceVersion, annotations } = metadata - const { createdBy, owner, description = null, purpose = null } = data - - return { - apiVersion, - kind, - metadata: { - name, - resourceVersion, - annotations - }, - spec: { - namespace, - createdBy: toSubject(createdBy), - owner: toSubject(owner), - description, - purpose - } - } -} - -function toResourceMergePatchDocument ({ metadata: { annotations } = {}, data = {} }) { - const document = {} - if (!_.isEmpty(annotations)) { - document.metadata = { annotations } - } - if (!_.isEmpty(data)) { - document.spec = {} - for (let [key, value] of Object.entries(data)) { - if (value) { - switch (key) { - case 'owner': - value = toSubject(value) - break - } - document.spec[key] = value - } else if (value === null) { - document.spec[key] = null - } - } - } - return document -} - -function fromSubject ({ name } = {}) { - return name -} - -function toSubject (username) { - if (username) { - return { - apiGroup: 'rbac.authorization.k8s.io', - kind: 'User', - name: username - } - } -} - -async function validateDeletePreconditions ({ user, namespace }) { const shootList = await shoots.list({ user, namespace }) if (!_.isEmpty(shootList.items)) { throw new PreconditionFailed('Only empty projects can be deleted') } } -function getProjectName (namespace) { - return cache.findProjectByNamespace(namespace).metadata.name -} - exports.list = async function ({ user }) { const canListProjects = await authorization.canListProjects(user) return _ .chain(cache.getProjects()) .filter(projectFilter(user, canListProjects)) - .map(fromResource) + .map(_.cloneDeep) + .map(trimProject) .value() } @@ -131,8 +41,7 @@ exports.create = async function ({ user, body }) { const name = _.get(body, 'metadata.name') _.set(body, 'metadata.namespace', `garden-${name}`) - _.set(body, 'data.createdBy', user.id) - let project = await client['core.gardener.cloud'].projects.create(toResource(body)) + let project = await client['core.gardener.cloud'].projects.create(body) const isProjectReady = ({ type, object: project }) => { if (type === 'DELETE') { @@ -147,32 +56,28 @@ exports.create = async function ({ user, body }) { const asyncIterable = await dashboardClient['core.gardener.cloud'].projects.watch(name) project = await asyncIterable.until(isProjectReady, { timeout }) - return fromResource(project) + return project } // needs to be exported for testing exports.projectInitializationTimeout = PROJECT_INITIALIZATION_TIMEOUT -exports.read = async function ({ user, name: namespace }) { +exports.read = async function ({ user, name }) { const client = user.client - const name = getProjectName(namespace) const project = await client['core.gardener.cloud'].projects.get(name) - return fromResource(project) + return project } -exports.patch = async function ({ user, name: namespace, body: { metadata, data } }) { +exports.patch = async function ({ user, name, body }) { const client = user.client - const name = getProjectName(namespace) - const body = toResourceMergePatchDocument({ metadata, data }) const project = await client['core.gardener.cloud'].projects.mergePatch(name, body) - return fromResource(project) + return project } -exports.remove = async function ({ user, name: namespace }) { - await validateDeletePreconditions({ user, namespace }) +exports.remove = async function ({ user, name }) { + await validateDeletePreconditions({ user, name }) const client = user.client - const name = getProjectName(namespace) const body = { metadata: { annotations: { @@ -180,7 +85,20 @@ exports.remove = async function ({ user, name: namespace }) { } } } - const project = await client['core.gardener.cloud'].projects.mergePatch(name, body) - await client['core.gardener.cloud'].projects.delete(name) - return fromResource(project) + await client['core.gardener.cloud'].projects.mergePatch(name, body) + try { + const project = await client['core.gardener.cloud'].projects.delete(name) + return project + } catch (error) { + // Revert the annotation if deletion fails + const revertBody = { + metadata: { + annotations: { + 'confirmation.gardener.cloud/deletion': null + } + } + } + await client['core.gardener.cloud'].projects.mergePatch(name, revertBody) + throw error // Re-throw the error after reverting the annotation + } } diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index 71eb329f42..e96b9aa947 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -107,6 +107,12 @@ function trimObjectMetadata (object) { return object } +function trimProject (project) { + project = trimObjectMetadata(project) + _.set(project, 'spec.members', undefined) + return project +} + function parseSelectors (selectors) { const items = [] for (const selector of selectors) { @@ -198,6 +204,7 @@ module.exports = { projectFilter, parseRooms, trimObjectMetadata, + trimProject, parseSelectors, filterBySelectors, getConfigValue, diff --git a/backend/test/__snapshots__/services.projects.spec.js.snap b/backend/test/__snapshots__/services.projects.spec.js.snap new file mode 100644 index 0000000000..ede06e1038 --- /dev/null +++ b/backend/test/__snapshots__/services.projects.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`services/projects #create should create a project and return it when ready 1`] = ` +{ + "metadata": { + "name": "foo", + "namespace": "garden-foo", + }, + "status": { + "phase": "Ready", + }, +} +`; diff --git a/backend/test/acceptance/__snapshots__/api.projects.spec.js.snap b/backend/test/acceptance/__snapshots__/api.projects.spec.js.snap deleted file mode 100644 index ccde9fb292..0000000000 --- a/backend/test/acceptance/__snapshots__/api.projects.spec.js.snap +++ /dev/null @@ -1,561 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`api projects should create a project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "post", - ":path": "/apis/core.gardener.cloud/v1beta1/projects", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - { - "apiVersion": "core.gardener.cloud/v1beta1", - "kind": "Project", - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "8888888888", - }, - "name": "xyz", - "resourceVersion": undefined, - }, - "spec": { - "createdBy": { - "apiGroup": "rbac.authorization.k8s.io", - "kind": "User", - "name": "foo@example.org", - }, - "description": "description", - "namespace": "garden-xyz", - "owner": undefined, - "purpose": "purpose", - }, - }, - ], - [ - { - ":authority": "kubernetes:6443", - ":method": "get", - ":path": "/apis/core.gardener.cloud/v1beta1/projects?watch=true&fieldSelector=metadata.name%3Dxyz", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmdhcmRlbjpkZWZhdWx0In0.-4rSuvvj5BStN6DwnmLAaRVbgpl5iCn2hG0pcqx0NPw", - }, - ], -] -`; - -exports[`api projects should create a project 2`] = ` -{ - "data": { - "createdBy": "foo@example.org", - "description": "description", - "owner": "foo@example.org", - "phase": "Ready", - "purpose": "purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "8888888888", - }, - "name": "xyz", - "namespace": "garden-xyz", - "resourceVersion": "43", - "role": "project", - }, -} -`; - -exports[`api projects should delete a project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "post", - ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - { - "apiVersion": "authorization.k8s.io/v1", - "kind": "SelfSubjectAccessReview", - "spec": { - "nonResourceAttributes": undefined, - "resourceAttributes": { - "group": "core.gardener.cloud", - "namespace": "garden-bar", - "resource": "shoots", - "verb": "list", - }, - }, - }, - ], - [ - { - ":authority": "kubernetes:6443", - ":method": "patch", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/bar", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - "content-type": "application/merge-patch+json", - }, - { - "metadata": { - "annotations": { - "confirmation.gardener.cloud/deletion": "true", - }, - }, - }, - ], - [ - { - ":authority": "kubernetes:6443", - ":method": "delete", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/bar", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - ], -] -`; - -exports[`api projects should delete a project 2`] = ` -{ - "data": { - "createdBy": "foo@example.org", - "description": "bar-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "bar-purpose", - }, - "metadata": { - "annotations": { - "confirmation.gardener.cloud/deletion": "true", - }, - "name": "bar", - "namespace": "garden-bar", - "resourceVersion": "43", - "role": "project", - }, -} -`; - -exports[`api projects should patch a project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "patch", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/foo", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - "content-type": "application/merge-patch+json", - }, - { - "spec": { - "description": "foobar", - }, - }, - ], -] -`; - -exports[`api projects should patch a project 2`] = ` -{ - "data": { - "createdBy": "foo@example.org", - "description": "foobar", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "foo-purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "9999999999", - }, - "name": "foo", - "namespace": "garden-foo", - "resourceVersion": "43", - "role": "project", - }, -} -`; - -exports[`api projects should reject request with authorization error 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "get", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/foo", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImJhekBleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImV4cCI6MzE1NTcxNjgwMCwianRpIjoianRpIn0.FjCv0aM0u5SpfHVemW_p-HnVVyr7Vg53ul5ukPSbNbA", - }, - ], -] -`; - -exports[`api projects should reject request with authorization error 2`] = ` -{ - "code": 403, - "details": Any, - "message": "Forbidden", - "reason": "Forbidden", - "status": "Failure", -} -`; - -exports[`api projects should return all projects 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "post", - ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InByb2plY3RzLXZpZXdlckBleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImV4cCI6MzE1NTcxNjgwMCwianRpIjoianRpIn0.mdL_IwTCaUnb2Yzua4Z54bS85BXKeAU3O1ioUfs7MeI", - }, - { - "apiVersion": "authorization.k8s.io/v1", - "kind": "SelfSubjectAccessReview", - "spec": { - "nonResourceAttributes": undefined, - "resourceAttributes": { - "group": "core.gardener.cloud", - "resource": "projects", - "verb": "list", - }, - }, - }, - ], -] -`; - -exports[`api projects should return all projects 2`] = ` -[ - { - "data": { - "createdBy": "admin@example.org", - "owner": "admin@example.org", - "phase": "Ready", - }, - "metadata": { - "name": "garden", - "namespace": "garden", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "foo@example.org", - "description": "foo-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "foo-purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "9999999999", - }, - "name": "foo", - "namespace": "garden-foo", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "foo@example.org", - "description": "bar-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "bar-purpose", - }, - "metadata": { - "name": "bar", - "namespace": "garden-bar", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "new@example.org", - "owner": "new@example.org", - "phase": "Ready", - }, - "metadata": { - "name": "GroupMember1", - "namespace": "garden-GroupMember1", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "new@example.org", - "owner": "new@example.org", - "phase": "Ready", - }, - "metadata": { - "name": "GroupMember2", - "namespace": "garden-GroupMember2", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "admin@example.org", - "description": "secret-description", - "owner": "admin@example.org", - "phase": "Ready", - "purpose": "secret-purpose", - }, - "metadata": { - "name": "secret", - "namespace": "garden-secret", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "admin@example.org", - "description": "trial-description", - "owner": "admin@example.org", - "phase": "Failed", - "purpose": "trial-purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "1234567890", - }, - "name": "trial", - "namespace": "garden-trial", - "resourceVersion": "42", - "role": "project", - }, - }, -] -`; - -exports[`api projects should return the foo project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "get", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/foo", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - ], -] -`; - -exports[`api projects should return the foo project 2`] = ` -{ - "data": { - "createdBy": "foo@example.org", - "description": "foo-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "foo-purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "9999999999", - }, - "name": "foo", - "namespace": "garden-foo", - "resourceVersion": "42", - "role": "project", - }, -} -`; - -exports[`api projects should return three projects 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "post", - ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - { - "apiVersion": "authorization.k8s.io/v1", - "kind": "SelfSubjectAccessReview", - "spec": { - "nonResourceAttributes": undefined, - "resourceAttributes": { - "group": "core.gardener.cloud", - "resource": "projects", - "verb": "list", - }, - }, - }, - ], -] -`; - -exports[`api projects should return three projects 2`] = ` -[ - { - "data": { - "createdBy": "foo@example.org", - "description": "foo-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "foo-purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "9999999999", - }, - "name": "foo", - "namespace": "garden-foo", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "foo@example.org", - "description": "bar-description", - "owner": "bar@example.org", - "phase": "Ready", - "purpose": "bar-purpose", - }, - "metadata": { - "name": "bar", - "namespace": "garden-bar", - "resourceVersion": "42", - "role": "project", - }, - }, - { - "data": { - "createdBy": "new@example.org", - "owner": "new@example.org", - "phase": "Ready", - }, - "metadata": { - "name": "GroupMember1", - "namespace": "garden-GroupMember1", - "resourceVersion": "42", - "role": "project", - }, - }, -] -`; - -exports[`api projects should timeout when creating a project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "post", - ":path": "/apis/core.gardener.cloud/v1beta1/projects", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - }, - { - "apiVersion": "core.gardener.cloud/v1beta1", - "kind": "Project", - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "8888888888", - }, - "name": "my-project", - "resourceVersion": undefined, - }, - "spec": { - "createdBy": { - "apiGroup": "rbac.authorization.k8s.io", - "kind": "User", - "name": "foo@example.org", - }, - "description": "description", - "namespace": "garden-my-project", - "owner": undefined, - "purpose": "purpose", - }, - }, - ], - [ - { - ":authority": "kubernetes:6443", - ":method": "get", - ":path": "/apis/core.gardener.cloud/v1beta1/projects?watch=true&fieldSelector=metadata.name%3Dmy-project", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmdhcmRlbjpkZWZhdWx0In0.-4rSuvvj5BStN6DwnmLAaRVbgpl5iCn2hG0pcqx0NPw", - }, - ], -] -`; - -exports[`api projects should timeout when creating a project 2`] = ` -{ - "code": 504, - "details": Any, - "message": "The condition for "projects" was not met within 10 ms", - "reason": "Gateway Timeout", - "status": "Failure", -} -`; - -exports[`api projects should update a project 1`] = ` -[ - [ - { - ":authority": "kubernetes:6443", - ":method": "patch", - ":path": "/apis/core.gardener.cloud/v1beta1/projects/foo", - ":scheme": "https", - "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImdyb3VwcyI6WyJncm91cDEiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.iLqu05bZNRweB_7pr3cM6ZGO5gl2wYNf4d-hCazuo7o", - "content-type": "application/merge-patch+json", - }, - { - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "8888888888", - }, - }, - "spec": { - "description": "description", - "owner": { - "apiGroup": "rbac.authorization.k8s.io", - "kind": "User", - "name": "baz@example.org", - }, - "purpose": "purpose", - }, - }, - ], -] -`; - -exports[`api projects should update a project 2`] = ` -{ - "data": { - "createdBy": "foo@example.org", - "description": "description", - "owner": "baz@example.org", - "phase": "Ready", - "purpose": "purpose", - }, - "metadata": { - "annotations": { - "billing.gardener.cloud/costObject": "8888888888", - }, - "name": "foo", - "namespace": "garden-foo", - "resourceVersion": "43", - "role": "project", - }, -} -`; diff --git a/backend/test/acceptance/api.projects.spec.js b/backend/test/acceptance/api.projects.spec.js index b95bb15869..4906bb4829 100644 --- a/backend/test/acceptance/api.projects.spec.js +++ b/backend/test/acceptance/api.projects.spec.js @@ -1,246 +1,122 @@ // -// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 // 'use strict' -const { mockRequest } = require('@gardener-dashboard/request') -const { Store } = require('@gardener-dashboard/kube-client') -const cache = require('../../lib/cache') +const express = require('express') +const supertest = require('supertest') +const projects = require('../../lib/services/projects') +const routes = require('../../lib/routes/projects') -function createStore (items) { - const store = new Store() - store.replace(items) - return store -} +jest.mock('../../lib/services/projects') -describe('api', function () { - let agent +const app = express() +app.use(express.json()) +app.use('/api/projects', routes) - beforeAll(() => { - agent = createAgent() - - cache.initialize({ - shoots: { - store: createStore(fixtures.shoots.list()) - } - }) - }) - - afterAll(() => { - cache.cache.resetTicketCache() - return agent.close() - }) +describe('API Projects', () => { + const user = { id: 'test-user', client: { 'core.gardener.cloud': { projects: {} } } } beforeEach(() => { - fixtures.resetAll() - mockRequest.mockReset() + jest.clearAllMocks() }) - describe('projects', function () { - const user = fixtures.auth.createUser({ - id: 'foo@example.org', - groups: ['group1'] - }) - const namespace = 'garden-foo' - const annotations = { - 'billing.gardener.cloud/costObject': '8888888888' - } - const description = 'description' - const purpose = 'purpose' - - beforeAll(() => { - require('../../lib/services/projects').projectInitializationTimeout = 10 - }) + describe('GET /api/projects', () => { + it('should return a list of projects', async () => { + const projectList = [{ metadata: { name: 'foo' } }, { metadata: { name: 'bar' } }] + projects.list.mockResolvedValue(projectList) - it('should return three projects', async function () { - mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) - - const res = await agent - .get('/api/namespaces') - .set('cookie', await user.cookie) - .expect('content-type', /json/) + const res = await supertest(app) + .get('/api/projects') + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() + expect(res.body).toEqual(projectList) }) + }) - it('should return all projects', async function () { - const user = fixtures.auth.createUser({ id: 'projects-viewer@example.org' }) - - mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) - - const res = await agent - .get('/api/namespaces') - .set('cookie', await user.cookie) - .expect('content-type', /json/) + describe('POST /api/projects', () => { + it('should create a new project', async () => { + const body = { metadata: { name: 'foo' } } + const project = { metadata: { name: 'foo' }, status: { phase: 'Ready' } } + projects.create.mockResolvedValue(project) + + const res = await supertest(app) + .post('/api/projects') + .send(body) + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() + expect(res.body).toEqual(project) }) + }) - it('should return the foo project', async function () { - mockRequest.mockImplementationOnce(fixtures.projects.mocks.get()) - - const res = await agent - .get(`/api/namespaces/${namespace}`) - .set('cookie', await user.cookie) - .expect('content-type', /json/) - .expect(200) - - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() - }) - - it('should reject request with authorization error', async function () { - const user = fixtures.auth.createUser({ id: 'baz@example.org' }) - - mockRequest.mockImplementationOnce(fixtures.projects.mocks.get()) - - const res = await agent - .get(`/api/namespaces/${namespace}`) - .set('cookie', await user.cookie) - .expect('content-type', /json/) - .expect(403) - - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot({ - details: expect.any(Object) - }) - }) + describe('GET /api/projects/:project', () => { + it('should return a project by name', async () => { + const project = { metadata: { name: 'foo' } } + projects.read.mockResolvedValue(project) - it('should create a project', async function () { - mockRequest.mockImplementationOnce(fixtures.projects.mocks.create()) - mockRequest.mockImplementationOnce(fixtures.projects.mocks.watch({ - phase: 'Ready' - })) - - const res = await agent - .post('/api/namespaces') - .set('cookie', await user.cookie) - .send({ - metadata: { - name: 'xyz', - annotations - }, - data: { - purpose, - description - } - }) - .expect('content-type', /json/) + const res = await supertest(app) + .get('/api/projects/foo') + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(2) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() - }) - - it('should timeout when creating a project', async function () { - mockRequest.mockImplementationOnce(fixtures.projects.mocks.create()) - mockRequest.mockImplementationOnce(fixtures.projects.mocks.watch({ - phase: 'Pending' - })) - - const res = await agent - .post('/api/namespaces') - .set('cookie', await user.cookie) - .send({ - metadata: { - name: 'my-project', - annotations - }, - data: { - purpose, - description - } - }) - .expect('content-type', /json/) - .expect(504) - - expect(mockRequest).toBeCalledTimes(2) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot({ - details: expect.any(Object) - }) + expect(res.body).toEqual(project) }) + }) - it('should update a project', async function () { - mockRequest.mockImplementationOnce(fixtures.projects.mocks.patch()) - - const res = await agent - .put(`/api/namespaces/${namespace}`) - .set('cookie', await user.cookie) - .send({ - metadata: { - annotations - }, - data: { - owner: 'baz@example.org', - purpose, - description - } - }) - .expect('content-type', /json/) + describe('PUT /api/projects/:project', () => { + it('should update a project', async () => { + const body = { metadata: { name: 'foo' } } + const project = { metadata: { name: 'foo' } } + projects.patch.mockResolvedValue(project) + + const res = await supertest(app) + .put('/api/projects/foo') + .send(body) + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() + expect(res.body).toEqual(project) }) + }) - it('should patch a project', async function () { - mockRequest.mockImplementationOnce(fixtures.projects.mocks.patch()) - - const res = await agent - .patch(`/api/namespaces/${namespace}`) - .set('cookie', await user.cookie) - .send({ - data: { - description: 'foobar' - } - }) - .expect('content-type', /json/) + describe('PATCH /api/projects/:project', () => { + it('should patch a project', async () => { + const body = { metadata: { name: 'foo' } } + const project = { metadata: { name: 'foo' } } + projects.patch.mockResolvedValue(project) + + const res = await supertest(app) + .patch('/api/projects/foo') + .send(body) + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(1) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() + expect(res.body).toEqual(project) }) + }) - it('should delete a project', async function () { - const namespace = 'garden-bar' - - mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) - mockRequest.mockImplementationOnce(fixtures.projects.mocks.patch()) - mockRequest.mockImplementationOnce(fixtures.projects.mocks.delete()) + describe('DELETE /api/projects/:project', () => { + it('should delete a project', async () => { + const project = { metadata: { name: 'foo' } } + projects.remove.mockResolvedValue(project) - const res = await agent - .delete(`/api/namespaces/${namespace}`) - .set('cookie', await user.cookie) - .expect('content-type', /json/) + const res = await supertest(app) + .delete('/api/projects/foo') + .set('user', JSON.stringify(user)) + .expect('Content-Type', /json/) .expect(200) - expect(mockRequest).toBeCalledTimes(3) - expect(mockRequest.mock.calls).toMatchSnapshot() - - expect(res.body).toMatchSnapshot() + expect(res.body).toEqual(project) }) }) }) diff --git a/backend/test/services.projects.spec.js b/backend/test/services.projects.spec.js index 54fa4452ff..ceb25e2262 100644 --- a/backend/test/services.projects.spec.js +++ b/backend/test/services.projects.spec.js @@ -1,19 +1,56 @@ // -// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 // 'use strict' +const { PreconditionFailed, InternalServerError } = require('http-errors') const projects = require('../lib/services/projects') -const cache = require('../lib/cache') +const shoots = require('../lib/services/shoots') const authorization = require('../lib/services/authorization') +const cache = require('../lib/cache') +const { dashboardClient } = require('@gardener-dashboard/kube-client') + +jest.mock('../lib/services/shoots') +jest.mock('../lib/services/authorization') +jest.mock('../lib/cache') +jest.mock('@gardener-dashboard/kube-client', () => ({ + dashboardClient: { + 'core.gardener.cloud': { + projects: { + watch: jest.fn() + } + } + } +})) + +const createUser = (username) => { + return { + id: username, + client: { + 'core.gardener.cloud': { + projects: { + create: jest.fn(), + get: jest.fn(), + mergePatch: jest.fn(), + delete: jest.fn() + } + } + } + } +} -describe('services', function () { - describe('projects', function () { - const projectList = [ +describe('services/projects', () => { + let projectList + + beforeEach(() => { + projectList = [ { + metadata: { + name: 'foo' + }, spec: { members: [ { @@ -31,14 +68,14 @@ describe('services', function () { } ] }, - metadata: { - name: 'foo' - }, status: { phase: 'Ready' } }, { + metadata: { + name: 'bar' + }, spec: { members: [ { @@ -47,14 +84,14 @@ describe('services', function () { } ] }, - metadata: { - name: 'bar' - }, status: { phase: 'Ready' } }, { + metadata: { + name: 'pending' + }, spec: { members: [ { @@ -63,51 +100,252 @@ describe('services', function () { name: 'bar@bar.com' } ] - }, + } + }, + { metadata: { - name: 'notReady' + name: 'terminating' + }, + status: { + phase: 'Terminating' } } ] + jest.clearAllMocks() + cache.getProjects = jest.fn().mockImplementation(() => projectList) + cache.getProject = jest.fn(name => projectList.find(project => project.metadata.name === name)) + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn().mockResolvedValue(projectList[0]) + }) + }) - const createUser = (username) => { - return { - id: username - } - } + describe('#list', () => { + it('should return all projects if user can list all projects', async () => { + authorization.canListProjects.mockImplementation(user => Promise.resolve(user.id === 'projects-viewer@bar.com')) + const user = createUser('projects-viewer@bar.com') + + const result = await projects.list({ user }) + expect(result).toHaveLength(3) + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ metadata: { name: 'foo' } }), + expect.objectContaining({ metadata: { name: 'bar' } }), + expect.objectContaining({ metadata: { name: 'terminating' } }) + ])) + }) + + it('should return project for user member', async () => { + authorization.canListProjects.mockResolvedValue(false) + const user = createUser('foo@bar.com') + + const result = await projects.list({ user }) + expect(result).toHaveLength(1) + expect(result[0].metadata.name).toBe('foo') + }) + + it('should return project for serviceaccount user member', async () => { + authorization.canListProjects.mockResolvedValue(false) + const user = createUser('system:serviceaccount:garden-foo:robot-user') + + const result = await projects.list({ user }) + expect(result).toHaveLength(1) + expect(result[0].metadata.name).toBe('foo') + }) + + it('should return project for serviceaccount member', async function () { + const userProjects = await projects.list({ user: createUser('system:serviceaccount:garden-foo:robot-sa') }) + expect(userProjects).toHaveLength(1) + expect(userProjects[0].metadata.name).toBe('foo') + }) + + it('should not return project if not in member list', async () => { + authorization.canListProjects.mockResolvedValue(false) + const user = createUser('other@bar.com') + + const result = await projects.list({ user }) + expect(result).toHaveLength(0) + }) + }) + + describe('#create', () => { + it('should create a project and return it when ready', async () => { + const body = { metadata: { name: 'foo' } } + const user = createUser('creator@bar.com') + + // Mock the project creation to return a pending project + user.client['core.gardener.cloud'].projects.create.mockResolvedValue({ + ...body, + status: { phase: 'Pending' } + }) + + // Mock the watch functionality to eventually return a ready project + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + ...body, + status: { phase: 'Ready' } + }) + }, 10) // Simulate delay for the project to become ready + }) + }) + }) - beforeEach(function () { - jest.spyOn(cache, 'getProjects').mockReturnValue(projectList) - jest.spyOn(authorization, 'canListProjects').mockImplementation(user => Promise.resolve(user.id === 'projects-viewer@bar.com')) + const result = await projects.create({ user, body }) + expect(result).toMatchSnapshot() }) - describe('#list', function () { - it('should return all projects if user can list all projects, including not ready projects', async function () { - const userProjects = await projects.list({ user: createUser('projects-viewer@bar.com') }) - expect(userProjects).toHaveLength(2) + it('should throw an error if project creation times out', async () => { + const body = { metadata: { name: 'foo' } } + const user = createUser('creator@bar.com') + user.client['core.gardener.cloud'].projects.create.mockResolvedValue({ + ...body, + status: { phase: 'Pending' } + }) + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn().mockRejectedValue(new Error('Timeout')) }) - it('should return project for user member', async function () { - const userProjects = await projects.list({ user: createUser('system:serviceaccount:garden-foo:robot-user') }) - expect(userProjects).toHaveLength(1) - expect(userProjects[0].metadata.name).toBe('foo') + await expect(projects.create({ user, body })).rejects.toThrow('Timeout') + }) + + it('should throw an InternalServerError if project is deleted', async () => { + const body = { metadata: { name: 'foo' } } + const user = createUser('creator@bar.com') + user.client['core.gardener.cloud'].projects.create.mockResolvedValue({ + ...body, + status: { phase: 'Pending' } + }) + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn((isProjectReady) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + try { + isProjectReady({ type: 'DELETE', object: { metadata: { name: 'foo' } } }) + } catch (error) { + reject(error) + } + }, 10) // Simulate delay before project is deleted + }) + }) }) - it('should return project for serviceaccount user member', async function () { - const userProjects = await projects.list({ user: createUser('system:serviceaccount:garden-foo:robot-user') }) - expect(userProjects).toHaveLength(1) - expect(userProjects[0].metadata.name).toBe('foo') + await expect(projects.create({ user, body })).rejects.toThrow(InternalServerError) + }) + + it('should return ok: true when project status is Ready', async () => { + const body = { metadata: { name: 'foo' } } + const user = createUser('creator@bar.com') + user.client['core.gardener.cloud'].projects.create.mockResolvedValue({ + ...body, + status: { phase: 'Pending' } + }) + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn((isProjectReady) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(isProjectReady({ type: 'MODIFIED', object: { ...body, status: { phase: 'Ready' } } })) + }, 10) // Simulate delay before project becomes ready + }) + }) + }) + + const result = await projects.create({ user, body }) + expect(result).toMatchObject({ ok: true }) + }) + + it('should return ok: false when project status is not Ready', async () => { + const body = { metadata: { name: 'foo' } } + const user = createUser('creator@bar.com') + user.client['core.gardener.cloud'].projects.create.mockResolvedValue({ + ...body, + status: { phase: 'Pending' } }) + dashboardClient['core.gardener.cloud'].projects.watch.mockResolvedValue({ + until: jest.fn((isProjectReady) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(isProjectReady({ type: 'MODIFIED', object: { ...body, status: { phase: 'Pending' } } })) + }, 10) // Simulate delay before project remains pending + }) + }) + }) + + const result = await projects.create({ user, body }) + expect(result).toMatchObject({ ok: false }) + }) + }) + + describe('#read', () => { + it('should return a project by name', async () => { + const project = { metadata: { name: 'foo' } } + const user = createUser('reader@bar.com') + user.client['core.gardener.cloud'].projects.get.mockResolvedValue(project) - it('should return project for serviceaccount member', async function () { - const userProjects = await projects.list({ user: createUser('system:serviceaccount:garden-foo:robot-sa') }) - expect(userProjects).toHaveLength(1) - expect(userProjects[0].metadata.name).toBe('foo') + const result = await projects.read({ user, name: 'foo' }) + expect(result).toEqual(project) + }) + }) + + describe('#patch', () => { + it('should patch a project and return the updated project', async () => { + const project = { metadata: { name: 'foo' } } + const user = createUser('patcher@bar.com') + user.client['core.gardener.cloud'].projects.mergePatch.mockResolvedValue(project) + + const result = await projects.patch({ user, name: 'foo', body: {} }) + expect(result).toEqual(project) + }) + }) + + describe('#remove', () => { + it('should remove a project if preconditions are met', async () => { + const project = { metadata: { name: 'foo' } } + const user = createUser('remover@bar.com') + shoots.list.mockResolvedValue({ items: [] }) + user.client['core.gardener.cloud'].projects.mergePatch.mockResolvedValue(project) + user.client['core.gardener.cloud'].projects.delete.mockResolvedValue(project) + + const result = await projects.remove({ user, name: 'foo' }) + expect(result).toEqual(project) + }) + + it('should throw an error if project is not empty', async () => { + const user = createUser('remover@bar.com') + shoots.list.mockResolvedValue({ items: [{}] }) + + await expect(projects.remove({ user, name: 'foo' })).rejects.toThrow(PreconditionFailed) + }) + + it('should revert the annotation if deletion fails', async () => { + const user = createUser('remover@bar.com') + const projectName = 'foo' + const error = new Error('Deletion failed') + + shoots.list.mockResolvedValue({ items: [] }) + + user.client['core.gardener.cloud'].projects.mergePatch.mockResolvedValue({}) + + user.client['core.gardener.cloud'].projects.delete.mockRejectedValue(error) + + await expect(projects.remove({ user, name: projectName })).rejects.toThrow('Deletion failed') + + expect(user.client['core.gardener.cloud'].projects.mergePatch).toHaveBeenCalledTimes(2) + + expect(user.client['core.gardener.cloud'].projects.mergePatch).toHaveBeenCalledWith(projectName, { + metadata: { + annotations: { + 'confirmation.gardener.cloud/deletion': 'true' + } + } }) - it('should not return project if not in member list', async function () { - const userProjects = await projects.list({ user: createUser('other@bar.com') }) - expect(userProjects).toHaveLength(0) + expect(user.client['core.gardener.cloud'].projects.mergePatch).toHaveBeenCalledWith(projectName, { + metadata: { + annotations: { + 'confirmation.gardener.cloud/deletion': null + } + } }) }) }) diff --git a/backend/test/utils.spec.js b/backend/test/utils.spec.js index b7050b9428..66be30afce 100644 --- a/backend/test/utils.spec.js +++ b/backend/test/utils.spec.js @@ -7,7 +7,7 @@ 'use strict' const { AssertionError } = require('assert').strict -const { merge } = require('lodash') +const { merge, cloneDeep } = require('lodash') const { encodeBase64, decodeBase64, @@ -18,6 +18,7 @@ const { filterBySelectors, constants, trimObjectMetadata, + trimProject, parseRooms } = require('../lib/utils') @@ -88,6 +89,42 @@ describe('utils', function () { expect(trimObjectMetadata({ metadata: extendedMetadata })).toEqual({ metadata }) }) + it('should trim project metadata and remove spec.members', () => { + const name = 'test' + const managedFields = 'managedFields' + const lastAppliedConfiguration = 'last-applied-configuration' + const metadata = { + name, + annotations: { + foo: 'bar' + } + } + const spec = { + members: ['member1', 'member2'] + } + const project = { metadata, spec } + + // Test case where metadata does not have managedFields or last-applied-configuration + expect(trimProject(cloneDeep(project))).toEqual({ + metadata, + spec: {} + }) + + // Test case where metadata has managedFields and last-applied-configuration + const extendedMetadata = merge(cloneDeep(metadata), { + managedFields, + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': lastAppliedConfiguration + } + }) + const extendedProject = { metadata: extendedMetadata, spec } + + expect(trimProject(cloneDeep(extendedProject))).toEqual({ + metadata, + spec: {} + }) + }) + it('should parse labelSelectors', () => { expect(parseSelectors([ 'shoot.gardener.cloud/status!=healthy' diff --git a/frontend/__tests__/stores/shoot.spec.js b/frontend/__tests__/stores/shoot.spec.js index 8e4f9722fc..2d69b063b0 100644 --- a/frontend/__tests__/stores/shoot.spec.js +++ b/frontend/__tests__/stores/shoot.spec.js @@ -175,10 +175,11 @@ describe('stores', () => { } projectStore.list = [{ metadata: { - namespace: 'foo', annotations, }, - data, + spec: { + namespace: 'foo', + }, }] } diff --git a/frontend/src/components/GGardenctlConfigExample.vue b/frontend/src/components/GGardenctlConfigExample.vue index 9d88c45c07..d3167f4019 100644 --- a/frontend/src/components/GGardenctlConfigExample.vue +++ b/frontend/src/components/GGardenctlConfigExample.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: Apache-2.0 /> Note that the kubeconfig - refers to the path of the garden cluster + refers to the path of the Garden cluster kubeconfig which you can download from the { }) const routes = computed(() => { - const hasProjectScope = get(selectedProject.value, 'metadata.namespace') !== allProjectsItem.metadata.namespace + const hasProjectScope = get(selectedProject.value, 'spec.namespace') !== allProjectsItem.spec.namespace return getRoutes(router, hasProjectScope) }) @@ -345,7 +347,8 @@ const sortedAndFilteredProjectList = computed(() => { } const filter = toLower(projectFilter.value) const name = toLower(item.metadata.name) - const owner = toLower(replace(item.data.owner, /@.*$/, '')) + let owner = get(item, 'spec.owner.name') + owner = toLower(replace(owner, /@.*$/, '')) return includes(name, filter) || includes(owner, filter) } const filteredList = filter([ @@ -357,7 +360,7 @@ const sortedAndFilteredProjectList = computed(() => { return toLower(item.metadata.name) === toLower(projectFilter.value) ? 0 : 1 } const allProjectsMatch = item => { - return item?.metadata.namespace === allProjectsItem.metadata.namespace ? 0 : 1 + return item?.spec.namespace === allProjectsItem.spec.namespace ? 0 : 1 } const sortedList = sortBy(filteredList, [allProjectsMatch, exactMatch, 'metadata.name']) return sortedList @@ -381,7 +384,7 @@ const projectNameThatMatchesFilter = computed(() => { }) function getProjectOwner (project) { - return emailToDisplayName(get(project, 'data.owner')) + return emailToDisplayName(get(project, 'spec.owner.name')) } function namespacedRoute (route) { diff --git a/frontend/src/components/GNotReadyProjectWarning.vue b/frontend/src/components/GNotReadyProjectWarning.vue index 51adbfa41e..ec14655ac4 100644 --- a/frontend/src/components/GNotReadyProjectWarning.vue +++ b/frontend/src/components/GNotReadyProjectWarning.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: Apache-2.0