diff --git a/package-lock.json b/package-lock.json index c788b6b58..223167719 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@adobe/spacecat-shared-ahrefs-client": "1.10.7", "@adobe/spacecat-shared-athena-client": "1.9.6", "@adobe/spacecat-shared-brand-client": "1.1.38", - "@adobe/spacecat-shared-data-access": "3.14.0", + "@adobe/spacecat-shared-data-access": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/0a024f16c40e74dc4ca80ff7b221ff6c8e4befbb/adobe-spacecat-shared-data-access-3.11.0.tgz", "@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0", "@adobe/spacecat-shared-drs-client": "1.2.0", "@adobe/spacecat-shared-gpt-client": "1.6.19", @@ -792,6 +792,7 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.0.tgz", "integrity": "sha512-3ZfFdjYtpv7RCgul9yyOBsRVsxLNapwt0YjASBhyzJGNjnPxrWDlqDtbpBdwAgA1Nuh9nmjzFDFu8CJWv6BMKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -2350,13 +2351,12 @@ } }, "node_modules/@adobe/spacecat-shared-data-access": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-data-access/-/spacecat-shared-data-access-3.14.0.tgz", - "integrity": "sha512-94bF4cA55BYfBAMCmHFVPbCCCmqDIMB27Ha21AHe8dz9Yyb3ZxvcTRotxbhupzYkZuhNLZMUPIqkNfeiV+guXw==", + "version": "3.11.0", + "resolved": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/0a024f16c40e74dc4ca80ff7b221ff6c8e4befbb/adobe-spacecat-shared-data-access-3.11.0.tgz", + "integrity": "sha512-+/6FA5vaHzDW56YJBIgPiu60PFYFCBHw0D8YmgXclpSoDoNnof9WlTxMrHaELVahHjwyitnOc7Fgj9iAK+5meA==", "license": "Apache-2.0", "dependencies": { - "@adobe/fetch": "^4.2.3", - "@adobe/spacecat-shared-utils": "1.101.0", + "@adobe/spacecat-shared-utils": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/e4a5f2fb260b1466fadbbd9f09631da21b582f46/adobe-spacecat-shared-utils-1.100.1.tgz", "@aws-sdk/client-s3": "^3.940.0", "@supabase/postgrest-js": "^1.21.4", "@types/joi": "17.2.3", @@ -2814,6 +2814,74 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@adobe/spacecat-shared-data-access/node_modules/@adobe/spacecat-shared-utils": { + "version": "1.100.1", + "resolved": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/e4a5f2fb260b1466fadbbd9f09631da21b582f46/adobe-spacecat-shared-utils-1.100.1.tgz", + "integrity": "sha512-+ABZ/tqaZd92lrU2Xpdjtt/VFxBbRAt71tophsqBfTA1Vt6eyKxgxRa7MZdT2xLx3Pyw1wUIoJwjW+wXcXkWdg==", + "license": "Apache-2.0", + "dependencies": { + "@adobe/fetch": "4.2.3", + "@aws-sdk/client-s3": "3.1004.0", + "@aws-sdk/client-sqs": "3.1004.0", + "@json2csv/plainjs": "7.0.6", + "aws-xray-sdk": "3.12.0", + "cheerio": "1.2.0", + "date-fns": "4.1.0", + "franc-min": "6.2.0", + "iso-639-3": "3.0.1", + "urijs": "1.19.11", + "validator": "^13.15.15", + "world-countries": "5.1.0", + "zod": "^4.1.11" + }, + "engines": { + "node": ">=22.0.0 <25.0.0", + "npm": ">=10.9.0 <12.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-data-access/node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/@adobe/spacecat-shared-data-access/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@adobe/spacecat-shared-data-access/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@adobe/spacecat-shared-drs-client": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-drs-client/-/spacecat-shared-drs-client-1.2.0.tgz", @@ -7676,6 +7744,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -11939,6 +12008,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -12170,6 +12240,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -12376,6 +12447,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -12539,6 +12611,7 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -14521,6 +14594,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -14827,6 +14901,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -14873,6 +14948,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15348,6 +15424,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -15998,6 +16075,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -18187,6 +18265,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -22181,6 +22260,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -23236,6 +23316,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -26408,6 +26489,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -27077,6 +27159,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -28169,6 +28252,7 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -28179,6 +28263,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -28888,6 +28973,7 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -30349,6 +30435,7 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -31431,6 +31518,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -32155,6 +32243,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -32419,6 +32508,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -32428,6 +32518,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/package.json b/package.json index 442c5b141..615f38172 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "hedy -v --test-bundle", "deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest", "deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest", - "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", + "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l sandsinh --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env", "docs": "npm run docs:lint && npm run docs:build", "docs:build": "npx @redocly/cli build-docs -o ./docs/index.html --config docs/openapi/redocly-config.yaml", @@ -76,7 +76,7 @@ "@adobe/spacecat-shared-ahrefs-client": "1.10.7", "@adobe/spacecat-shared-athena-client": "1.9.6", "@adobe/spacecat-shared-brand-client": "1.1.38", - "@adobe/spacecat-shared-data-access": "3.14.0", + "@adobe/spacecat-shared-data-access": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/0a024f16c40e74dc4ca80ff7b221ff6c8e4befbb/adobe-spacecat-shared-data-access-3.11.0.tgz", "@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0", "@adobe/spacecat-shared-drs-client": "1.2.0", "@adobe/spacecat-shared-gpt-client": "1.6.19", diff --git a/src/controllers/tokens.js b/src/controllers/tokens.js new file mode 100644 index 000000000..81ebe6a5a --- /dev/null +++ b/src/controllers/tokens.js @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + badRequest, + notFound, + ok, + forbidden, + internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { + isNonEmptyObject, + isValidUUID, + hasText, +} from '@adobe/spacecat-shared-utils'; + +import { TokenDto } from '../dto/token.js'; +import AccessControlUtil from '../support/access-control-util.js'; + +/** + * Tokens controller. Provides methods to read token allocations by site. + * @param {object} ctx - Context of the request. + * @returns {object} Tokens controller. + * @constructor + */ +function TokensController(ctx) { + if (!isNonEmptyObject(ctx)) { + throw new Error('Context required'); + } + + const { dataAccess } = ctx; + if (!isNonEmptyObject(dataAccess)) { + throw new Error('Data access required'); + } + + const { Token, Site } = dataAccess; + + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + /** + * Gets the current-cycle token for a site by token type. + * Uses createIfNotFound=false so no token is auto-created on reads. + * @param {object} context - Context of the request. + * @returns {Promise} Token response. + */ + const getByTokenType = async (context) => { + const { siteId, tokenType } = context.params; + + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + if (!hasText(tokenType)) { + return badRequest('Token type required'); + } + + try { + const site = await Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('Access denied to this site'); + } + + const token = await Token.findBySiteIdAndTokenType(siteId, tokenType, true); + if (!token) { + return notFound('Token not found for the current cycle'); + } + + return ok(TokenDto.toJSON(token)); + } catch (e) { + context.log.error(`Error getting token by type ${tokenType} for site ${siteId}: ${e.message}`); + return internalServerError(e.message); + } + }; + + return { + getByTokenType, + }; +} + +export default TokensController; diff --git a/src/dto/token.js b/src/dto/token.js new file mode 100644 index 000000000..a358e73bf --- /dev/null +++ b/src/dto/token.js @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Data transfer object for Token. + */ +export const TokenDto = { + /** + * Converts a Token object into a JSON object. + * @param {Readonly} token - Token object. + * @returns {{ + * id: string, + * siteId: string, + * tokenType: string, + * cycle: string, + * total: number, + * used: number, + * remaining: number, + * createdAt: string, + * updatedAt: string, + * }} + */ + toJSON: (token) => ({ + id: token.getId(), + siteId: token.getSiteId(), + tokenType: token.getTokenType(), + cycle: token.getCycle(), + total: token.getTotal(), + used: token.getUsed(), + remaining: token.getRemaining(), + createdAt: token.getCreatedAt(), + updatedAt: token.getUpdatedAt(), + }), +}; diff --git a/src/index.js b/src/index.js index 0a2c08e8f..f7fe87f83 100644 --- a/src/index.js +++ b/src/index.js @@ -84,6 +84,7 @@ import TrafficToolsController from './controllers/paid/traffic-tools.js'; import BotBlockerController from './controllers/bot-blocker.js'; import SentimentController from './controllers/sentiment.js'; import ConsumersController from './controllers/consumers.js'; +import TokensController from './controllers/tokens.js'; import routeRequiredCapabilities from './routes/required-capabilities.js'; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -211,6 +212,7 @@ async function run(request, context) { const botBlockerController = BotBlockerController(context, log); const sentimentController = SentimentController(context, log); const consumersController = ConsumersController(context); + const tokensController = TokensController(context); const routeHandlers = getRouteHandlers( auditsController, @@ -252,6 +254,7 @@ async function run(request, context) { botBlockerController, sentimentController, consumersController, + tokensController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/routes/index.js b/src/routes/index.js index 1b3cb9125..5fda46651 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -86,6 +86,7 @@ function isStaticRoute(routePattern) { * @param {Object} botBlockerController - The bot blocker controller. * @param {Object} sentimentController - The sentiment controller. * @param {Object} consumersController - The consumers controller. + * @param {Object} tokensController - The tokens controller. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ export default function getRouteHandlers( @@ -128,6 +129,7 @@ export default function getRouteHandlers( botBlockerController, sentimentController, consumersController, + tokensController, ) { const staticRoutes = {}; const dynamicRoutes = {}; @@ -433,6 +435,9 @@ export default function getRouteHandlers( 'POST /consumers/register': consumersController.register, 'PATCH /consumers/:consumerId': consumersController.update, 'POST /consumers/:consumerId/revoke': consumersController.revoke, + + // Tokens + 'GET /sites/:siteId/tokens/by-type/:tokenType': tokensController.getByTokenType, }; // Initialization of static and dynamic routes diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index bfb0cae68..127552bb2 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -383,6 +383,9 @@ const routeRequiredCapabilities = { // Sentiment - Config 'GET /sites/:siteId/sentiment/config': 'sentimentTopic:read', + + // Tokens + 'GET /sites/:siteId/tokens/by-type/:tokenType': 'token:read', }; export default routeRequiredCapabilities; diff --git a/test/controllers/tokens.test.js b/test/controllers/tokens.test.js new file mode 100644 index 000000000..69c4d914f --- /dev/null +++ b/test/controllers/tokens.test.js @@ -0,0 +1,228 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js'; + +import TokensController from '../../src/controllers/tokens.js'; +import AccessControlUtil from '../../src/support/access-control-util.js'; + +use(chaiAsPromised); +use(sinonChai); + +describe('Tokens Controller', () => { + const sandbox = sinon.createSandbox(); + const siteId = '123e4567-e89b-12d3-a456-426614174000'; + const tokenId = '223e4567-e89b-12d3-a456-426614174001'; + + const mockSite = { + getId: () => siteId, + getOrganizationId: () => 'org-123', + }; + + const mockToken = { + getId: () => tokenId, + getSiteId: () => siteId, + getTokenType: () => 'monthly_suggestion_cwv', + getCycle: () => '2025-03', + getTotal: () => 100, + getUsed: () => 25, + getRemaining: () => 75, + getCreatedAt: () => '2025-03-01T00:00:00Z', + getUpdatedAt: () => '2025-03-10T00:00:00Z', + }; + + let mockDataAccess; + let mockAccessControlUtil; + let tokensController; + + beforeEach(() => { + sandbox.restore(); + + mockDataAccess = { + Site: { + findById: sandbox.stub().resolves(mockSite), + }, + Token: { + findBySiteIdAndTokenType: sandbox.stub().resolves(mockToken), + }, + }; + + mockAccessControlUtil = { + hasAccess: sandbox.stub().resolves(true), + hasAdminAccess: sandbox.stub().returns(true), + }; + + sandbox.stub(AccessControlUtil, 'fromContext').returns(mockAccessControlUtil); + + tokensController = TokensController({ + dataAccess: mockDataAccess, + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withProfile({ is_admin: true }) + .withAuthenticated(true), + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('TokensController constructor', () => { + it('should throw error when context is not provided', () => { + expect(() => TokensController()).to.throw('Context required'); + }); + + it('should throw error when context is null', () => { + expect(() => TokensController(null)).to.throw('Context required'); + }); + + it('should throw error when context is empty object', () => { + expect(() => TokensController({})).to.throw('Context required'); + }); + + it('should throw error when dataAccess is not provided', () => { + expect(() => TokensController({ someOtherProp: 'value' })).to.throw('Data access required'); + }); + + it('should throw error when dataAccess is null', () => { + expect(() => TokensController({ dataAccess: null })).to.throw('Data access required'); + }); + + it('should throw error when dataAccess is empty object', () => { + expect(() => TokensController({ dataAccess: {} })).to.throw('Data access required'); + }); + }); + + describe('getByTokenType', () => { + it('should return a token by type for the current cycle', async () => { + const context = { + params: { siteId, tokenType: 'monthly_suggestion_cwv' }, + log: { error: sinon.stub() }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ + id: tokenId, + siteId, + tokenType: 'monthly_suggestion_cwv', + cycle: '2025-03', + total: 100, + used: 25, + remaining: 75, + createdAt: '2025-03-01T00:00:00Z', + updatedAt: '2025-03-10T00:00:00Z', + }); + + expect(mockDataAccess.Token.findBySiteIdAndTokenType) + .to.have.been.calledWith(siteId, 'monthly_suggestion_cwv', true); + }); + + it('should return bad request for invalid site ID', async () => { + const context = { + params: { siteId: 'invalid', tokenType: 'monthly_suggestion_cwv' }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('Site ID required'); + }); + + it('should return bad request for missing token type', async () => { + const context = { + params: { siteId, tokenType: '' }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('Token type required'); + }); + + it('should return not found when site does not exist', async () => { + mockDataAccess.Site.findById.resolves(null); + + const context = { + params: { siteId, tokenType: 'monthly_suggestion_cwv' }, + log: { error: sinon.stub() }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(404); + const body = await result.json(); + expect(body.message).to.equal('Site not found'); + }); + + it('should return forbidden when user lacks access', async () => { + mockAccessControlUtil.hasAccess.resolves(false); + + const context = { + params: { siteId, tokenType: 'monthly_suggestion_cwv' }, + log: { error: sinon.stub() }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(403); + const body = await result.json(); + expect(body.message).to.equal('Access denied to this site'); + }); + + it('should return not found when no token exists for the current cycle', async () => { + mockDataAccess.Token.findBySiteIdAndTokenType.resolves(null); + + const context = { + params: { siteId, tokenType: 'monthly_suggestion_cwv' }, + log: { error: sinon.stub() }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(404); + const body = await result.json(); + expect(body.message).to.equal('Token not found for the current cycle'); + }); + + it('should return internal server error on database failure', async () => { + const dbError = new Error('RPC failed'); + mockDataAccess.Token.findBySiteIdAndTokenType.rejects(dbError); + + const context = { + params: { siteId, tokenType: 'monthly_suggestion_cwv' }, + log: { error: sinon.stub() }, + }; + + const result = await tokensController.getByTokenType(context); + + expect(result.status).to.equal(500); + const body = await result.json(); + expect(body.message).to.equal('RPC failed'); + expect(context.log.error).to.have.been.calledWith( + `Error getting token by type monthly_suggestion_cwv for site ${siteId}: RPC failed`, + ); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 0c3cba945..b033961a3 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -350,6 +350,10 @@ describe('getRouteHandlers', () => { revoke: sinon.stub(), }; + const mockTokensController = { + getByTokenType: sinon.stub(), + }; + it('segregates static and dynamic routes', () => { const { staticRoutes, dynamicRoutes } = getRouteHandlers( mockAuditsController, @@ -391,6 +395,7 @@ describe('getRouteHandlers', () => { mockBotBlockerController, mockSentimentController, mockConsumersController, + mockTokensController, ); expect(staticRoutes).to.have.all.keys( @@ -693,6 +698,7 @@ describe('getRouteHandlers', () => { 'GET /consumers/by-client-id/:clientId', 'PATCH /consumers/:consumerId', 'POST /consumers/:consumerId/revoke', + 'GET /sites/:siteId/tokens/by-type/:tokenType', ); expect(dynamicRoutes['GET /audits/latest/:auditType'].handler).to.equal(mockAuditsController.getAllLatest);