diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..7f3ee0a --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,8 @@ +FROM node:lts-alpine +WORKDIR /app +COPY api ./api +COPY pm2 ./pm2 +WORKDIR /app/api +RUN npm install -g pm2 +RUN npm install +EXPOSE 3010 \ No newline at end of file diff --git a/api/ENVIRONMENT.md b/api/ENVIRONMENT.md index c7d0e03..0f7a05e 100644 --- a/api/ENVIRONMENT.md +++ b/api/ENVIRONMENT.md @@ -32,8 +32,20 @@ To emulate Azure Blob Storage locally. Azurite needs to be installed and running - `MICROSOFT_CLIENT_SECRET` - `MICROSOFT_TENANT_ID`: Required if application registration is single tenant. +#### Google OAuth + +- `GOOGLE_CLIENT_ID`: The client ID from Google Developer Console + ## Optional parameters +### Authorization + +- `LOGIN_AUTHORIZED_DOMAINS`: Comma-separated list of additional email domains authorized to access the service via Google OAuth. + +### Terms and Conditions + +- `CURRENT_TERMS_VERSION`: Version string for the current terms and conditions that users must accept. Example: `v1.0`, `v2.1`. Defaults to `v1.0` if not set. + ### HTTPS - `HTTPS`: Set to 'true' to enable HTTPS for local deployment diff --git a/api/docker-compose.yml b/api/docker-compose.yml index 4475fc4..dfbe225 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: db: image: mysql:5.7 @@ -6,8 +5,6 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: codepushdb - MYSQL_USER: root - MYSQL_PASSWORD: root ports: - "3306:3306" # MySQL running on localhost:3306 volumes: @@ -18,6 +15,11 @@ services: ports: - "6379:6379" # Redis running on localhost:6379 + memcached: + image: memcached:alpine + ports: + - "11211:11211" # Memcached running on localhost:11211 + localstack: image: localstack/localstack environment: @@ -25,5 +27,37 @@ services: ports: - "4566:4566" # LocalStack running on localhost:4566 + app: + build: + context: .. + dockerfile: api/Dockerfile + ports: + - "3010:3010" + env_file: + - .env + volumes: + - ../api:/app/api + - ../pm2:/app/pm2 + depends_on: + - redis + - db + - memcached + - localstack + working_dir: /app/api + command: > + /bin/sh -c " + sleep 10 && + npm run build && + if [ \"$$NODE_ENV\" = \"production\" ]; then + echo 'Starting in PRODUCTION mode...' && + pm2-runtime start /app/pm2/pm2-prod.json; + else + echo 'Starting in DEVELOPMENT mode...' && + echo 'Running database seeding...' && + npx ts-node script/storage/seedData.ts && + echo 'Starting PM2 with dev configuration...' && + pm2-runtime start /app/pm2/pm2-dev.json; + fi + " volumes: db_data: diff --git a/api/package.json b/api/package.json index 03633a2..c91460f 100644 --- a/api/package.json +++ b/api/package.json @@ -44,6 +44,7 @@ "google-auth-library": "9.9.0", "ioredis": "5.4.0", "lusca": "^1.7.0", + "memcached": "2.2.2", "moment": "^2.30.1", "multer": "^1.4.5-lts.1", "mysql2": "3.11.3", diff --git a/api/script/default-server.ts b/api/script/default-server.ts index 597ebec..681904d 100644 --- a/api/script/default-server.ts +++ b/api/script/default-server.ts @@ -9,9 +9,9 @@ import { AzureStorage } from "./storage/azure-storage"; import { fileUploadMiddleware } from "./file-upload-manager"; import { JsonStorage } from "./storage/json-storage"; import { RedisManager } from "./redis-manager"; +import { MemcachedManager } from "./memcached-manager"; import { Storage } from "./storage/storage"; import { Response } from "express"; -import rateLimit from "express-rate-limit"; const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME || ""; const RDS_DB_INSTANCE_IDENTIFIER = process.env.RDS_DB_INSTANCE_IDENTIFIER || ""; const SECRETS_MANAGER_SECRET_ID = process.env.SECRETS_MANAGER_SECRET_ID || ""; @@ -59,8 +59,14 @@ export function start(done: (err?: any, server?: express.Express, storage?: Stor }) .then(() => { const app = express(); + // Trust a specific number of proxy hops (safer than boolean true). + // Configure via TRUST_PROXY_HOPS; default to 1 when sitting behind a single proxy/ELB. + const trustProxyHops = parseInt(process.env.TRUST_PROXY_HOPS || "1", 10); + app.set("trust proxy", trustProxyHops); + console.log(`Trust proxy hops: ${trustProxyHops}`); const auth = api.auth({ storage: storage }); const redisManager = new RedisManager(); + const memcachedManager = new MemcachedManager(); // First, to wrap all requests and catch all exceptions. app.use(domain); @@ -135,16 +141,12 @@ export function start(done: (err?: any, server?: express.Express, storage?: Stor app.set("view engine", "ejs"); app.use("/auth/images/", express.static(__dirname + "/views/images")); app.use(api.headers({ origin: process.env.CORS_ORIGIN || "http://localhost:4000" })); - app.use(api.health({ storage: storage, redisManager: redisManager })); + app.use(api.health({ storage: storage, redisManager: redisManager, memcachedManager: memcachedManager })); - const limiter = rateLimit({ - windowMs: 1000, // 1 minute - max: 2000, // limit each IP to 100 requests per windowMs - validate: { xForwardedForHeader: false } - }); + // Rate limiting removed: relying on CloudFront + WAF for request throttling if (process.env.DISABLE_ACQUISITION !== "true") { - app.use(api.acquisition({ storage: storage, redisManager: redisManager })); + app.use(api.acquisition({ storage: storage, redisManager: redisManager, memcachedManager: memcachedManager })); } if (process.env.DISABLE_MANAGEMENT !== "true") { @@ -166,11 +168,15 @@ export function start(done: (err?: any, server?: express.Express, storage?: Stor } else { app.use(auth.router()); } - app.use(auth.authenticate, fileUploadMiddleware, limiter, api.management({ storage: storage, redisManager: redisManager })); + app.use(auth.authenticate, fileUploadMiddleware, api.management({ storage: storage, redisManager: redisManager })); } else { app.use(auth.router()); } done(null, app, storage); }) + .catch((error) => { + console.error("Error starting server:", error); + done(error); + }); } diff --git a/api/script/memcached-manager.ts b/api/script/memcached-manager.ts new file mode 100644 index 0000000..c960e20 --- /dev/null +++ b/api/script/memcached-manager.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as crypto from "crypto"; + +// Using memcached library for Node.js +const Memcached = require('memcached'); + +export interface CacheableResponse { + statusCode: number; + body: any; +} + +/** + * Minimal Memcached Manager for updateCheck API caching only + * Replaces Redis for updateCheck API responses + */ +export class MemcachedManager { + private client: any; + private setupPromise: Promise; + private keyPrefix: string; + + constructor() { + const memcachedServers = process.env.MEMCACHED_SERVERS || 'localhost:11211'; + this.keyPrefix = process.env.MEMCACHED_KEY_PREFIX || 'codepush:'; + + // Initialize Memcached client + this.client = new Memcached(memcachedServers, { + timeout: parseInt(process.env.MEMCACHED_TIMEOUT || '5000'), + retries: parseInt(process.env.MEMCACHED_RETRIES || '3'), + retry: parseInt(process.env.MEMCACHED_RETRY_DELAY || '1000'), + failures: parseInt(process.env.MEMCACHED_MAX_FAILURES || '5'), + keyCompression: false, + maxKeySize: 250, // Memcached limit + maxValue: 1048576 // 1MB limit + }); + + this.setupPromise = this.setup(); + } + + private async setup(): Promise { + return new Promise((resolve, reject) => { + // Test connection + this.client.version((err: any, version: any) => { + if (err) { + console.warn('Memcached connection failed, will operate without cache:', err.message); + resolve(); // Don't fail, just operate without cache + } else { + console.log('✅ Memcached connected successfully, version:', version); + resolve(); + } + }); + }); + } + + /** + * Health check for Memcached connectivity + */ + public async checkHealth(): Promise { + return this.setupPromise; + } + + /** + * Get cached response for updateCheck API + */ + public async getCachedResponse(deploymentKey: string, urlKey: string): Promise { + await this.setupPromise; + + return new Promise((resolve) => { + const key = this.createKey('cache', this.getDeploymentKeyHash(deploymentKey), this.hashString(urlKey, 16)); + + this.client.get(key, (err: any, data: any) => { + if (err || !data) { + resolve(null); + return; + } + + try { + resolve(JSON.parse(data)); + } catch (parseErr) { + console.error('Failed to parse cached response:', parseErr); + resolve(null); + } + }); + }); + } + + /** + * Set cached response for updateCheck API + */ + public async setCachedResponse(deploymentKey: string, urlKey: string, response: CacheableResponse, ttlSeconds?: number): Promise { + await this.setupPromise; + + const ttl = ttlSeconds || parseInt(process.env.CACHE_TTL_SECONDS || '3600'); // 1 hour default + const key = this.createKey('cache', this.getDeploymentKeyHash(deploymentKey), this.hashString(urlKey, 16)); + const data = JSON.stringify(response); + + return new Promise((resolve) => { + this.client.set(key, data, ttl, (err: any) => { + if (err) { + console.error('Failed to set cache:', err); + } + resolve(); // Don't fail the request if cache set fails + }); + }); + } + + // ===== UTILITY METHODS ===== + + private createKey(...parts: string[]): string { + return this.keyPrefix + parts.filter(p => p).join(':'); + } + + /** + * Get deployment key hash for consistent key generation + */ + private getDeploymentKeyHash(deploymentKey: string): string { + return crypto.createHash('sha256').update(deploymentKey).digest('hex').substring(0, 16); + } + + /** + * Hash any string to specified length + */ + private hashString(input: string, length: number = 8): string { + return crypto.createHash('sha256').update(input).digest('hex').substring(0, length); + } +} \ No newline at end of file diff --git a/api/script/routes/acquisition.ts b/api/script/routes/acquisition.ts index 7bb81a6..0d633a7 100644 --- a/api/script/routes/acquisition.ts +++ b/api/script/routes/acquisition.ts @@ -8,6 +8,7 @@ import * as utils from "../utils/common"; import * as acquisitionUtils from "../utils/acquisition"; import * as errorUtils from "../utils/rest-error-handling"; import * as redis from "../redis-manager"; +import { MemcachedManager, CacheableResponse } from "../memcached-manager"; import * as restHeaders from "../utils/rest-headers"; import * as rolloutSelector from "../utils/rollout-selector"; import * as storageTypes from "../storage/storage"; @@ -23,19 +24,27 @@ const METRICS_BREAKING_VERSION = "1.5.2-beta"; export interface AcquisitionConfig { storage: storageTypes.Storage; redisManager: redis.RedisManager; + memcachedManager?: MemcachedManager; } function getUrlKey(originalUrl: string): string { const obj: any = URL.parse(originalUrl, /*parseQueryString*/ true); delete obj.query.client_unique_id; - return obj.pathname + "?" + queryString.stringify(obj.query); + + // Sort query parameters to ensure consistent hashing + const sortedQuery: any = {}; + Object.keys(obj.query).sort().forEach(key => { + sortedQuery[key] = obj.query[key]; + }); + + return obj.pathname + "?" + queryString.stringify(sortedQuery); } function createResponseUsingStorage( req: express.Request, res: express.Response, storage: storageTypes.Storage -): Promise { +): Promise { const deploymentKey: string = String(req.query.deploymentKey || req.query.deployment_key); const appVersion: string = String(req.query.appVersion || req.query.app_version); const packageHash: string = String(req.query.packageHash || req.query.package_hash); @@ -81,7 +90,7 @@ function createResponseUsingStorage( } } - const cacheableResponse: redis.CacheableResponse = { + const cacheableResponse: CacheableResponse = { statusCode: 200, body: updateObject, }; @@ -108,25 +117,44 @@ function createResponseUsingStorage( ); } - return Promise.resolve((null)); + return Promise.resolve((null)); } } export function getHealthRouter(config: AcquisitionConfig): express.Router { const storage: storageTypes.Storage = config.storage; const redisManager: redis.RedisManager = config.redisManager; + const memcachedManager: MemcachedManager = config.memcachedManager; const router: express.Router = express.Router(); router.get("/healthcheck", (req: express.Request, res: express.Response, next: (err?: any) => void): any => { - Promise.any([ + // Either Storage OR Redis must be healthy (at least one) + const storageOrRedis = Promise.any([ storage.checkHealth(), Promise.race([ redisManager.checkHealth(), new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout after 30ms")), 30) + setTimeout(() => reject(new Error("Redis timeout after 30ms")), 30) ) ]) - ]) + ]); + + const healthChecks: Promise[] = [storageOrRedis]; + + // Memcached health check with timeout (REQUIRED if memcachedManager exists) + if (memcachedManager) { + healthChecks.push( + Promise.race([ + memcachedManager.checkHealth(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Memcached timeout after 30ms")), 30) + ) + ]) + ); + } + + // Storage/Redis (at least one) AND Memcached (if configured) must be healthy + Promise.all(healthChecks) .then(() => res.status(200).send("Healthy")) .catch((error: Error) => { errorUtils.sendUnknownError(res, error, next); @@ -141,9 +169,12 @@ export function getHealthRouter(config: AcquisitionConfig): express.Router { export function getAcquisitionRouter(config: AcquisitionConfig): express.Router { const storage: storageTypes.Storage = config.storage; const redisManager: redis.RedisManager = config.redisManager; + const memcachedManager: MemcachedManager = config.memcachedManager; const router: express.Router = express.Router(); const REDIS_TIMEOUT = 100; - const REDIS_TIMEOUT_MS = parseInt(process.env.REDIS_TIMEOUT) || REDIS_TIMEOUT; + const REDIS_TIMEOUT_MS = parseInt(process.env.REDIS_TIMEOUT) || REDIS_TIMEOUT; + const MEMCACHED_TIMEOUT_MS = parseInt(process.env.MEMCACHED_TIMEOUT) || 100; + const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS) || 60; // 1 minute default function redisWithTimeout(redisPromise: Promise): Promise { return Promise.race([ @@ -154,30 +185,43 @@ export function getAcquisitionRouter(config: AcquisitionConfig): express.Router ]); } + function memcachedWithTimeout(memcachedPromise: Promise): Promise { + return Promise.race([ + memcachedPromise, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("Memcached request timed out. Memcached might be down")), MEMCACHED_TIMEOUT_MS); + }), + ]); + } + const updateCheck = function (newApi: boolean) { return function (req: express.Request, res: express.Response, next: (err?: any) => void) { const deploymentKey: string = String(req.query.deploymentKey || req.query.deployment_key); - const key: string = redis.Utilities.getDeploymentKeyHash(deploymentKey); const clientUniqueId: string = String(req.query.clientUniqueId || req.query.client_unique_id); const url: string = getUrlKey(req.originalUrl); let fromCache = true; - let redisError: Error | null = null; + let cacheError: Error | null = null; + + // Use Memcached if available, otherwise skip cache entirely + const cachePromise = memcachedManager + ? memcachedWithTimeout(memcachedManager.getCachedResponse(deploymentKey, url)) + : Promise.resolve(null); // No cache if Memcached not available - redisWithTimeout(redisManager.getCachedResponse(key, url)) + cachePromise .catch((error: Error) => { - // If Redis is down/slow, we store the error for logging but return null + // If cache is down/slow, we store the error for logging but return null // so we can continue with DB lookups. - redisError = error; + cacheError = error; return null; // triggers fallback to DB }) - .then((cachedResponse: redis.CacheableResponse | null) => { + .then((cachedResponse: CacheableResponse | null) => { fromCache = !!cachedResponse; - // If we got nothing from Redis, we use the DB storage approach. + // If we got nothing from cache, we use the DB storage approach. return cachedResponse || createResponseUsingStorage(req, res, storage); }) - .then((response: redis.CacheableResponse) => { + .then((response: CacheableResponse) => { if (!response) { // If we still have no response, something else has gone wrong. // Possibly return next() with an error or handle differently. @@ -215,20 +259,21 @@ export function getAcquisitionRouter(config: AcquisitionConfig): express.Router .status(response.statusCode) .send(newApi ? utils.convertObjectToSnakeCase(updateCheckBody) : updateCheckBody); - // Update Redis cache AFTER sending response, if we didn't have a cache hit - if (!fromCache) { - redisManager.setCachedResponse(key, url, response).catch((err) => { - // Log the error, but don’t block the request (which is already done). - console.error("Failed while setting cached response in Redis:", err); + // Update cache AFTER sending response, if we didn't have a cache hit + if (!fromCache && memcachedManager) { + // Only cache in Memcached for updateCheck API + memcachedManager.setCachedResponse(deploymentKey, url, response, CACHE_TTL_SECONDS).catch((err) => { + // Log the error, but don't block the request (which is already done). + console.error("Failed while setting cached response in Memcached:", err); sendErrorToDatadog(err); }); } }) .then(() => { - // If there was a Redis error, log it (e.g., to Datadog) and optionally throw - if (redisError) { - sendErrorToDatadog(redisError); - console.error("Redis error:", redisError); + // If there was a cache error, log it (e.g., to Datadog) and optionally throw + if (cacheError) { + sendErrorToDatadog(cacheError); + console.error("Memcached cache error:", cacheError); } }) .catch((error: storageTypes.StorageError) => { diff --git a/api/script/routes/authentication.ts b/api/script/routes/authentication.ts index bd4116f..1c55da5 100644 --- a/api/script/routes/authentication.ts +++ b/api/script/routes/authentication.ts @@ -2,7 +2,6 @@ import { OAuth2Client, TokenPayload } from "google-auth-library"; import * as cookieSession from "cookie-session"; import { Request, Response, Router, RequestHandler } from "express"; import * as storage from "../storage/storage"; -import rateLimit from "express-rate-limit"; import { sendErrorToDatadog } from "../utils/tracer"; // Replace with your actual Google Client ID (from Google Developer Console) @@ -72,6 +71,41 @@ export class Authentication { } } + // Check if the user's email domain is authorized to access the service + private isEmailDomainAuthorized(email: string): boolean { + const authorizedDomains = process.env.LOGIN_AUTHORIZED_DOMAINS; + + // Always include dream11.com as an allowed domain + let allowedDomains: string[] = ['dream11.com']; + + if (authorizedDomains && authorizedDomains.trim() !== '') { + // Parse comma-separated domains and normalize + const configuredDomains = authorizedDomains + .split(',') + .map(domain => domain.trim().toLowerCase()) + .filter(domain => domain.length > 0); + + // Add configured domains to the allowed list (avoiding duplicates) + configuredDomains.forEach(domain => { + if (!allowedDomains.includes(domain)) { + allowedDomains.push(domain); + } + }); + } + + // Extract and check email domain + if (!email) { + return false; + } + + const emailDomain = email.split('@')[1]?.toLowerCase(); + if (!emailDomain) { + return false; + } + + return allowedDomains.includes(emailDomain); + } + // Middleware to authenticate requests using Google ID token public async authenticate(req: Request, res: Response, next: (err?: Error) => void) { // Bypass authentication in development mode @@ -130,6 +164,15 @@ export class Authentication { // Check user exists in the storage const userEmail = payload.email; + // Authorize email domain BEFORE creating user in database + if (!this.isEmailDomainAuthorized(userEmail)) { + sendErrorToDatadog(new Error(`403: Unauthorized domain access attempt - ${userEmail}`)); + return res.status(403).send( + "Access denied: Your email domain is not authorized to access this service. " + + "Please contact your administrator if you believe this is an error." + ); + } + const user = await this.getOrCreateUser(payload); if (!user) { @@ -165,7 +208,6 @@ export class Authentication { // Example protected route router.get( "/authenticated", - rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), this.authenticate.bind(this), (req: Request, res: Response) => { res.send({ authenticated: true, user: req.user }); diff --git a/api/script/routes/management.ts b/api/script/routes/management.ts index da15aea..30aec3b 100644 --- a/api/script/routes/management.ts +++ b/api/script/routes/management.ts @@ -176,6 +176,107 @@ export function getManagementRouter(config: ManagementConfig): Router { .catch((error: error.CodePushError) => errorUtils.restErrorHandler(res, error, next)) }); + router.get("/account/ownerTermsStatus", (req: Request, res: Response, next: (err?: any) => void): any => { + const accountId: string = req.user.id; + const termsVersion = process.env.CURRENT_TERMS_VERSION || "v1.0"; + + // First check if user is an owner of at least one app + storage + .getAppOwnershipCount(accountId) + .then((ownershipCount: number) => { + if (ownershipCount === 0) { + // User is not an owner of any app + res.send({ + accountId: accountId, + isOwner: false, + ownerAppCount: 0, + termsAccepted: false, + termsVersion: null, + message: "Logged in account is not an owner of any app" + }); + return; + } + + // User is an owner, now check terms acceptance + return storage.getTermsAcceptance(accountId) + .then((termsRecord: storageTypes.TermsAcceptance) => { + const isCurrentVersion = termsRecord.termsVersion === termsVersion; + res.send({ + accountId: accountId, + isOwner: true, + ownerAppCount: ownershipCount, + termsAccepted: true, + termsVersion: termsRecord.termsVersion, + acceptedTime: termsRecord.acceptedTime, + isCurrentVersion: isCurrentVersion, + currentRequiredVersion: termsVersion + }); + }) + .catch((termsError: storageTypes.StorageError) => { + if (termsError.code === storageTypes.ErrorCode.NotFound) { + // User is owner but hasn't accepted terms yet + res.send({ + accountId: accountId, + isOwner: true, + ownerAppCount: ownershipCount, + termsAccepted: false, + termsVersion: null, + acceptedTime: null, + isCurrentVersion: false, + currentRequiredVersion: termsVersion + }); + } else { + // Other error occurred + errorUtils.restErrorHandler(res, termsError, next); + } + }); + }) + .catch((error: storageTypes.StorageError) => { + errorUtils.restErrorHandler(res, error, next); + }); + }); + + router.post("/account/acceptTerms", (req: Request, res: Response, next: (err?: any) => void): any => { + const accountId: string = req.user.id; + const termsVersion = req.body.termsVersion; + console.log("termsVersion", termsVersion); + + // Validate required fields + if (!termsVersion || typeof termsVersion !== 'string') { + res.status(400).send({ error: "termsVersion is required and must be a string" }); + return; + } + + // Get user account to retrieve email + storage + .getAccount(accountId) + .then((account: storageTypes.Account) => { + const termsData: storageTypes.TermsAcceptance = { + accountId: accountId, + email: account.email, + termsVersion: termsVersion, + acceptedTime: new Date().getTime() + }; + + return storage.addOrUpdateTermsAcceptance(termsData); + }) + .then((savedTerms: storageTypes.TermsAcceptance) => { + res.status(201).send({ + message: "Terms acceptance recorded successfully", + termsAcceptance: { + id: savedTerms.id, + accountId: savedTerms.accountId, + email: savedTerms.email, + termsVersion: savedTerms.termsVersion, + acceptedTime: savedTerms.acceptedTime + } + }); + }) + .catch((error: storageTypes.StorageError) => { + errorUtils.restErrorHandler(res, error, next); + }); + }); + router.patch("/accessKeys/:accessKeyName", (req: Request, res: Response, next: (err?: any) => void): any => { const accountId: string = req.user.id; const accessKeyName: string = req.params.accessKeyName; @@ -564,14 +665,22 @@ export function getManagementRouter(config: ManagementConfig): Router { nameResolver .resolveApp(accountId, appName, tenantId) .then((app: storageTypes.App) => { - const isAdmin: boolean = - app.collaborators && email && app.collaborators[email] && app.collaborators[email].isCurrentAccount; - let permission = role === "Owner" ? storageTypes.Permissions.Owner : storageTypes.Permissions.Collaborator; - throwIfInvalidPermissions(app, permission); + throwIfInvalidPermissions(app, storageTypes.Permissions.Owner); + + // Prevent ONLY the app creator from changing their permission from Owner to Collaborator + const collaboratorBeingModified = app.collaborators[email]; + if (collaboratorBeingModified) { + const collaboratorAccountId = collaboratorBeingModified.accountId; + const appCreatorAccountId = (app as any).accountId; + if (collaboratorAccountId === appCreatorAccountId && role === "Collaborator") { + throw errorUtils.restError(errorUtils.ErrorCode.Conflict,"The app creator cannot change their permission from Owner to Collaborator."); + } + } + return storage.updateCollaborators(accountId, app.id, email, role); }) .then(() => { - res.sendStatus(204); + res.sendStatus(200); }) .catch((error: error.CodePushError) => errorUtils.restErrorHandler(res, error, next)) }); @@ -1191,6 +1300,7 @@ export function getManagementRouter(config: ManagementConfig): Router { rollout: info.rollout || null, size: sourcePackage.size, uploadTime: new Date().getTime(), + isBundlePatchingEnabled: sourcePackage.isBundlePatchingEnabled, releaseMethod: storageTypes.ReleaseMethod.Promote, originalLabel: sourcePackage.label, originalDeployment: sourceDeploymentName, @@ -1292,6 +1402,7 @@ export function getManagementRouter(config: ManagementConfig): Router { packageHash: destinationPackage.packageHash, size: destinationPackage.size, uploadTime: new Date().getTime(), + isBundlePatchingEnabled: destinationPackage.isBundlePatchingEnabled, releaseMethod: storageTypes.ReleaseMethod.Rollback, originalLabel: destinationPackage.label, }; diff --git a/api/script/storage/aws-storage.ts b/api/script/storage/aws-storage.ts index 5070735..22e69f2 100644 --- a/api/script/storage/aws-storage.ts +++ b/api/script/storage/aws-storage.ts @@ -1,14 +1,13 @@ -import * as storage from "./storage"; -import { S3, CloudFront} from "aws-sdk"; -import {HeadBucketRequest, CreateBucketRequest} from "aws-sdk/clients/s3" -import { getSignedUrl } from "aws-cloudfront-sign"; +import { S3 } from "aws-sdk"; +import { CreateBucketRequest, HeadBucketRequest } from "aws-sdk/clients/s3"; +import { DataTypes, Sequelize } from "sequelize"; import * as stream from "stream"; -import { Sequelize, DataTypes } from "sequelize"; +import * as storage from "./storage"; //import * from nanoid; +import * as mysql from "mysql2/promise"; import * as shortid from "shortid"; import * as utils from "../utils/common"; -import * as mysql from "mysql2/promise"; -import * as fs from "fs"; +import * as security from "../utils/security"; //Creating Access Key export function createAccessKey(sequelize: Sequelize) { @@ -116,6 +115,25 @@ export function createCollaborators(sequelize: Sequelize) { }) } +//Create TermsAcceptance +export function createTermsAcceptance(sequelize: Sequelize) { + return sequelize.define("termsAcceptance", { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + accountId: { + type: DataTypes.STRING, + allowNull: false, + unique: true, // Prevents duplicate entries per account + references: { + model: 'accounts', + key: 'id', + } + }, + email: { type: DataTypes.STRING, allowNull: false }, + termsVersion: { type: DataTypes.STRING, allowNull: false }, + acceptedTime: { type: DataTypes.BIGINT, allowNull: false }, // Epoch timestamp + }) +} + //Create Deployment export function createDeployment(sequelize: Sequelize) { @@ -168,6 +186,7 @@ export function createPackage(sequelize: Sequelize) { rollout: { type: DataTypes.FLOAT, allowNull: true }, size: { type: DataTypes.FLOAT, allowNull: false }, uploadTime: { type: DataTypes.BIGINT, allowNull: false }, + isBundlePatchingEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, deploymentId: { // Foreign key to associate this package with a deployment history type: DataTypes.STRING, allowNull: true, @@ -227,6 +246,7 @@ export function createModelss(sequelize: Sequelize) { const AppPointer = createAppPointer(sequelize); const Collaborator = createCollaborators(sequelize); const App = createApp(sequelize); + const TermsAcceptance = createTermsAcceptance(sequelize); // Define associations @@ -267,6 +287,7 @@ export function createModelss(sequelize: Sequelize) { AppPointer, Collaborator, App, + TermsAcceptance, }; } @@ -290,11 +311,12 @@ export const MODELS = { ACCESSKEY : "accessKey", ACCOUNT : "account", APPPOINTER: "AppPointer", - TENANT : "tenant" + TENANT : "tenant", + TERMS_ACCEPTANCE : "termsAcceptance" } const DB_NAME = "codepushdb" -const DB_USER = "codepush" +const DB_USER = "root" const DB_PASS = "root" const DB_HOST = "localhost" @@ -304,19 +326,31 @@ export class S3Storage implements storage.Storage { private sequelize:Sequelize; private setupPromise: Promise; public constructor() { - this.s3 = new S3({ - endpoint: process.env.S3_ENDPOINT, // LocalStack S3 endpoint - s3ForcePathStyle: true, - accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', - region: process.env.S3_REGION - }); + const s3Config = { + region: process.env.S3_REGION, + } + + if (process.env.NODE_ENV === "local" || process.env.NODE_ENV === "dev") { + // These additional configurations are passed due to AWS SDK Version issue on local + this.s3 = new S3({ + ...s3Config, + endpoint: process.env.S3_ENDPOINT, // LocalStack S3 endpoint + s3ForcePathStyle: true, + accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', + }); + } else { + this.s3 = new S3(s3Config); + } shortid.characters("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"); // Ensure the database exists, then initialize Sequelize this.setupPromise = this.createDatabaseIfNotExists().then(() => { this.sequelize = new Sequelize({ database: process.env.DB_NAME || DB_NAME, + username: process.env.DB_USER || DB_USER, + password: process.env.DB_PASS || DB_PASS, + host: process.env.DB_HOST || DB_HOST, dialect: 'mysql', replication: { write: { @@ -456,8 +490,10 @@ export class S3Storage implements storage.Storage { return this.setupPromise .then(async () => { const account = await this.sequelize.models[MODELS.ACCOUNT].findOne({where: {email : email}}) - //Fix this error code - return account !== null ? Promise.resolve(account.dataValues) : Promise.reject({code: 1}) + if (account === null) { + throw storage.storageError(storage.ErrorCode.NotFound, `Account with email ${email} not found`); + } + return account.dataValues; }) } @@ -474,6 +510,81 @@ export class S3Storage implements storage.Storage { }) .catch(S3Storage.storageErrorHandler); } + + public getAppOwnershipCount(accountId: string): Promise { + return this.setupPromise + .then(() => { + // Direct query to collaborators table + return this.sequelize.models[MODELS.COLLABORATOR].count({ + where: { + accountId: accountId, + permission: 'Owner' + } + }); + }) + .catch(S3Storage.storageErrorHandler); + } + + // Terms acceptance methods + public getTermsAcceptance(accountId: string): Promise { + return this.setupPromise + .then(() => { + return this.sequelize.models[MODELS.TERMS_ACCEPTANCE].findOne({ + where: { accountId: accountId } + }); + }) + .then((result: any) => { + if (!result) { + throw storage.storageError( + storage.ErrorCode.NotFound, + `Terms acceptance record not found for account ${accountId}` + ); + } + return { + id: result.id, + accountId: result.accountId, + email: result.email, + termsVersion: result.termsVersion, + acceptedTime: result.acceptedTime + }; + }) + .catch(S3Storage.storageErrorHandler); + } + + public addOrUpdateTermsAcceptance(termsAcceptance: storage.TermsAcceptance): Promise { + return this.setupPromise + .then(() => { + const termsData = { + id: termsAcceptance.id || security.generateSecureKey(termsAcceptance.accountId), + accountId: termsAcceptance.accountId, + email: termsAcceptance.email, + termsVersion: termsAcceptance.termsVersion, + acceptedTime: termsAcceptance.acceptedTime + // createdAt and updatedAt handled by Sequelize defaults + }; + + // Use upsert (INSERT ... ON DUPLICATE KEY UPDATE) + return this.sequelize.models[MODELS.TERMS_ACCEPTANCE].upsert(termsData, { + returning: true + }); + }) + .then(() => { + // After upsert, fetch the record to return it + return this.sequelize.models[MODELS.TERMS_ACCEPTANCE].findOne({ + where: { accountId: termsAcceptance.accountId } + }); + }) + .then((result: any) => { + return { + id: result.id, + accountId: result.accountId, + email: result.email, + termsVersion: result.termsVersion, + acceptedTime: result.acceptedTime + }; + }) + .catch(S3Storage.storageErrorHandler); + } public getAccountIdFromAccessKey(accessKey: string): Promise { @@ -541,7 +652,8 @@ export class S3Storage implements storage.Storage { }); if(tenant) { - throw new Error("An organization or user of this name already exists. Please select a different name.") + throw storage.storageError(storage.ErrorCode.AlreadyExists, "An organization or user of this name already exists. Please select a different name."); + //throw new Error("An organization or user of this name already exists. Please select a different name.") } else { // If no tenantId is provided, set tenantId to NULL (app is standalone/personal) const idTogenerate = shortid.generate(); @@ -1209,9 +1321,13 @@ export class S3Storage implements storage.Storage { return this.setupPromise .then(async () => { for (const appPackage of history) { - // Find the existing package in the table + // Find the existing package in the table using unique label and packageHash for data integrity const existingPackage = await this.sequelize.models[MODELS.PACKAGE].findOne({ - where: { deploymentId: deploymentId, packageHash: appPackage.packageHash }, + where: { + deploymentId: deploymentId, + label: appPackage.label, + packageHash: appPackage.packageHash + }, }); if (existingPackage) { @@ -1644,6 +1760,7 @@ export class S3Storage implements storage.Storage { rollout: pkgData.rollout, size: pkgData.size, uploadTime: pkgData.uploadTime, + isBundlePatchingEnabled: pkgData.isBundlePatchingEnabled, }; } diff --git a/api/script/storage/azure-storage.ts b/api/script/storage/azure-storage.ts index 35c60de..7f5f878 100644 --- a/api/script/storage/azure-storage.ts +++ b/api/script/storage/azure-storage.ts @@ -315,6 +315,34 @@ export class AzureStorage implements storage.Storage { }) .catch(AzureStorage.azureErrorHandler); } +// NOTE: This method is not implemented for azure storage + public getAppOwnershipCount(accountId: string): Promise { + return Promise.reject( + storage.storageError( + storage.ErrorCode.Other, + "AzureStorage is not configured. Please use S3Storage or JsonStorage." + ) + ); + } + + // NOTE: Terms acceptance methods - stubs for Azure (not implemented) + public getTermsAcceptance(accountId: string): Promise { + return Promise.reject( + storage.storageError( + storage.ErrorCode.Other, + "AzureStorage is not configured. Please use S3Storage or JsonStorage." + ) + ); + } + + public addOrUpdateTermsAcceptance(termsAcceptance: storage.TermsAcceptance): Promise { + return Promise.reject( + storage.storageError( + storage.ErrorCode.Other, + "AzureStorage is not configured. Please use S3Storage or JsonStorage." + ) + ); + } public getAccountIdFromAccessKey(accessKey: string): Promise { const partitionKey: string = Keys.getShortcutAccessKeyPartitionKey(accessKey); diff --git a/api/script/storage/json-storage.ts b/api/script/storage/json-storage.ts index ed81e91..560cce0 100644 --- a/api/script/storage/json-storage.ts +++ b/api/script/storage/json-storage.ts @@ -44,6 +44,7 @@ export class JsonStorage implements storage.Storage { public packages: { [id: string]: storage.Package } = {}; public blobs: { [id: string]: string } = {}; public accessKeys: { [id: string]: storage.AccessKey } = {}; + public termsAcceptance: { [accountId: string]: storage.TermsAcceptance } = {}; public accountToAppsMap: { [id: string]: string[] } = {}; public appToAccountMap: { [id: string]: string } = {}; @@ -215,6 +216,66 @@ export class JsonStorage implements storage.Storage { }); } + public getAppOwnershipCount(accountId: string): Promise { + return this.getAccount(accountId).then((account: storage.Account) => { + const appIds = this.accountToAppsMap[accountId]; + + if (!appIds) { + return 0; + } + + let ownerCount = 0; + const userEmail = account.email.toLowerCase(); + + // Count apps where user is owner + appIds.forEach((appId: string) => { + const app = this.apps[appId]; + if (app && app.collaborators && app.collaborators[userEmail]) { + const permission = app.collaborators[userEmail].permission; + if (permission === storage.Permissions.Owner) { + ownerCount++; + } + } + }); + + return ownerCount; + }); + } + + // Terms acceptance methods + public getTermsAcceptance(accountId: string): Promise { + const termsRecord = this.termsAcceptance[accountId]; + if (!termsRecord) { + return JsonStorage.getRejectedPromise(storage.ErrorCode.NotFound); + } + return Promise.resolve(clone(termsRecord)); + } + + public addOrUpdateTermsAcceptance(termsAcceptance: storage.TermsAcceptance): Promise { + const existingRecord = this.termsAcceptance[termsAcceptance.accountId]; + + if (existingRecord) { + // Update existing record + existingRecord.termsVersion = termsAcceptance.termsVersion; + existingRecord.acceptedTime = termsAcceptance.acceptedTime; + this.saveStateAsync(); + return Promise.resolve(clone(existingRecord)); + } else { + // Create new record + const newRecord: storage.TermsAcceptance = { + id: termsAcceptance.id || this.newId(), + accountId: termsAcceptance.accountId, + email: termsAcceptance.email, + termsVersion: termsAcceptance.termsVersion, + acceptedTime: termsAcceptance.acceptedTime + }; + + this.termsAcceptance[termsAcceptance.accountId] = newRecord; + this.saveStateAsync(); + return Promise.resolve(clone(newRecord)); + } + } + public getAccountIdFromAccessKey(accessKey: string): Promise { if (!this.accessKeyNameToAccountIdMap[accessKey]) { return JsonStorage.getRejectedPromise(storage.ErrorCode.NotFound); diff --git a/api/script/storage/seedData.ts b/api/script/storage/seedData.ts index e7bfbab..53d890e 100644 --- a/api/script/storage/seedData.ts +++ b/api/script/storage/seedData.ts @@ -3,7 +3,7 @@ import { createModelss } from "./aws-storage"; // Define the Sequelize connection const sequelize = new Sequelize("codepushdb", "root", "root", { - host: "localhost", + host: process.env.DB_HOST || "db", dialect: "mysql", }); @@ -167,5 +167,8 @@ async function seed() { } } -// Run the seed function -seed(); +if (process.env.NODE_ENV !== "production") { + seed(); +} else { + // Do nothing +} diff --git a/api/script/storage/storage.ts b/api/script/storage/storage.ts index effa485..c7bb937 100644 --- a/api/script/storage/storage.ts +++ b/api/script/storage/storage.ts @@ -73,6 +73,14 @@ export interface Organization { role: string; } +export interface TermsAcceptance { + /*generated*/ id?: string; + accountId: string; + email: string; + termsVersion: string; + acceptedTime: number; // Epoch timestamp +} + export interface Deployment { /*generated*/ createdTime?: number; /*generated*/ id?: string; @@ -113,6 +121,7 @@ export interface Package { rollout?: number; size: number; uploadTime: number; + isBundlePatchingEnabled: boolean; active?: number; downloaded?: number; failed?: number; @@ -150,6 +159,11 @@ export interface Storage { getAccountByEmail(email: string): Promise; getAccountIdFromAccessKey(accessKey: string): Promise; updateAccount(email: string, updates: Account): Promise; + getAppOwnershipCount(accountId: string): Promise; + + // Terms acceptance methods + getTermsAcceptance(accountId: string): Promise; + addOrUpdateTermsAcceptance(termsAcceptance: TermsAcceptance): Promise; getTenants(accountId: string): Promise; removeTenant(accountId: string, tenantId: string): Promise; diff --git a/api/script/types/rest-definitions.ts b/api/script/types/rest-definitions.ts index 9eff201..5bbb911 100644 --- a/api/script/types/rest-definitions.ts +++ b/api/script/types/rest-definitions.ts @@ -54,6 +54,7 @@ export interface PackageInfo { /*generated*/ label?: string; /*generated*/ packageHash?: string; rollout?: number; + isBundlePatchingEnabled?: boolean; } /*out*/ @@ -64,6 +65,7 @@ export interface UpdateCheckResponse extends PackageInfo { packageSize?: number; shouldRunBinaryVersion?: boolean; updateAppVersion?: boolean; + isBundlePatchingEnabled?: boolean; } /*out*/ diff --git a/api/script/utils/acquisition.ts b/api/script/utils/acquisition.ts index bf8a7bc..417b6a8 100644 --- a/api/script/utils/acquisition.ts +++ b/api/script/utils/acquisition.ts @@ -40,6 +40,7 @@ function getUpdatePackage(packageHistory: Package[], request: UpdateCheckRequest label: "", packageSize: 0, updateAppVersion: false, + isBundlePatchingEnabled: false, }; if (!packageHistory || packageHistory.length === 0) { @@ -116,9 +117,11 @@ function getUpdatePackage(packageHistory: Package[], request: UpdateCheckRequest ) { updateDetails.downloadURL = latestSatisfyingEnabledPackage.diffPackageMap[request.packageHash].url; updateDetails.packageSize = latestSatisfyingEnabledPackage.diffPackageMap[request.packageHash].size; + updateDetails.isBundlePatchingEnabled = latestSatisfyingEnabledPackage.isBundlePatchingEnabled; } else { updateDetails.downloadURL = latestSatisfyingEnabledPackage.blobUrl; updateDetails.packageSize = latestSatisfyingEnabledPackage.size; + updateDetails.isBundlePatchingEnabled = latestSatisfyingEnabledPackage.isBundlePatchingEnabled; } updateDetails.description = latestSatisfyingEnabledPackage.description; diff --git a/api/test/utils.test.ts b/api/test/utils.test.ts index 777e7fc..6b631a3 100644 --- a/api/test/utils.test.ts +++ b/api/test/utils.test.ts @@ -106,6 +106,7 @@ export function makePackage(version?: string, isMandatory?: boolean, packageHash size: 1, manifestBlobUrl: "test manifest blob URL", uploadTime: new Date().getTime(), + isBundlePatchingEnabled: false, }; return storagePackage; diff --git a/e2e-mocks/docker-compose.yml b/e2e-mocks/docker-compose.yml new file mode 100644 index 0000000..72d2212 --- /dev/null +++ b/e2e-mocks/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + mockserver: + image: mockserver/mockserver:5.12.0 + restart: always + ports: + - "1080:1080" + environment: + MOCKSERVER_LOG_LEVEL: INFO + MOCKSERVER_PERSIST_EXPECTATIONS: "true" + + mock-callback: + build: ./mock-callback + container_name: mock-callback + ports: + - "3001:3001" + environment: + PORT: 3001 + volumes: + - ./packages:/tmp/codepush-packages + restart: always diff --git a/e2e-mocks/expectations/.gitkeep b/e2e-mocks/expectations/.gitkeep new file mode 100644 index 0000000..4f1fdac --- /dev/null +++ b/e2e-mocks/expectations/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the expectations directory is tracked by git + diff --git a/e2e-mocks/expectations/accesskeys.json b/e2e-mocks/expectations/accesskeys.json new file mode 100644 index 0000000..18afaea --- /dev/null +++ b/e2e-mocks/expectations/accesskeys.json @@ -0,0 +1,80 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/accessKeys" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "POST", + "path": "/accessKeys" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/accessKeys/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "PATCH", + "path": "/accessKeys/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/accessKeys/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/sessions/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/accountByaccessKeyName" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/account.json b/e2e-mocks/expectations/account.json new file mode 100644 index 0000000..d132271 --- /dev/null +++ b/e2e-mocks/expectations/account.json @@ -0,0 +1,16 @@ +[ + { + "priority": 1, + "httpRequest": { + "method": "GET", + "path": "/account" + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } + } +] diff --git a/e2e-mocks/expectations/acquisition.json b/e2e-mocks/expectations/acquisition.json new file mode 100644 index 0000000..3afe8aa --- /dev/null +++ b/e2e-mocks/expectations/acquisition.json @@ -0,0 +1,45 @@ +[ + { + "priority": 10, + "httpRequest": { + "method": "GET", + "path": "/v0.1/public/codepush/update_check" + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } + }, + { + "priority": 10, + "httpRequest": { + "method": "POST", + "path": "/v0.1/public/codepush/report_status/deploy" + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } + }, + { + "priority": 10, + "httpRequest": { + "method": "POST", + "path": "/v0.1/public/codepush/report_status/download" + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } + } +] + diff --git a/e2e-mocks/expectations/apps-name.json b/e2e-mocks/expectations/apps-name.json new file mode 100644 index 0000000..01d95af --- /dev/null +++ b/e2e-mocks/expectations/apps-name.json @@ -0,0 +1,36 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/apps/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/apps/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "PATCH", + "path": "/apps/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/apps.json b/e2e-mocks/expectations/apps.json new file mode 100644 index 0000000..e2be7ff --- /dev/null +++ b/e2e-mocks/expectations/apps.json @@ -0,0 +1,11 @@ +{ + "httpRequest": { + "path": "/apps" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } +} + diff --git a/e2e-mocks/expectations/authentication.json b/e2e-mocks/expectations/authentication.json new file mode 100644 index 0000000..9d2cb74 --- /dev/null +++ b/e2e-mocks/expectations/authentication.json @@ -0,0 +1,14 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/authenticated" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/collaborators.json b/e2e-mocks/expectations/collaborators.json new file mode 100644 index 0000000..662a555 --- /dev/null +++ b/e2e-mocks/expectations/collaborators.json @@ -0,0 +1,47 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/apps/.*/collaborators" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "POST", + "path": "/apps/.*/collaborators/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/apps/.*/collaborators/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "PATCH", + "path": "/apps/.*/collaborators/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/deployments.json b/e2e-mocks/expectations/deployments.json new file mode 100644 index 0000000..59dcaab --- /dev/null +++ b/e2e-mocks/expectations/deployments.json @@ -0,0 +1,58 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/apps/.*/deployments" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "POST", + "path": "/apps/.*/deployments" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/apps/.*/deployments/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/apps/.*/deployments/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "PATCH", + "path": "/apps/.*/deployments/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/packages.json b/e2e-mocks/expectations/packages.json new file mode 100644 index 0000000..08efa81 --- /dev/null +++ b/e2e-mocks/expectations/packages.json @@ -0,0 +1,14 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/packages/.*" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/ping.json b/e2e-mocks/expectations/ping.json new file mode 100644 index 0000000..eb7571d --- /dev/null +++ b/e2e-mocks/expectations/ping.json @@ -0,0 +1,12 @@ +{ + "httpRequest": { + "method": "GET", + "path": "/ping" + }, + "httpResponse": { + "statusCode": 200, + "body": "pong" + } + } + + \ No newline at end of file diff --git a/e2e-mocks/expectations/releases.json b/e2e-mocks/expectations/releases.json new file mode 100644 index 0000000..9603814 --- /dev/null +++ b/e2e-mocks/expectations/releases.json @@ -0,0 +1,47 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/apps/.*/deployments/.*/history" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "POST", + "path": "/apps/.*/deployments/.*/release" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/apps/.*/deployments/.*/history" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + }, + { + "httpRequest": { + "method": "PATCH", + "path": "/apps/.*/deployments/.*/release" + }, + "httpForward": { + "host": "mock-callback", + "port": 3001, + "scheme": "HTTP" + } + } +] + diff --git a/e2e-mocks/expectations/tenants-id.json b/e2e-mocks/expectations/tenants-id.json new file mode 100644 index 0000000..62342d1 --- /dev/null +++ b/e2e-mocks/expectations/tenants-id.json @@ -0,0 +1,18 @@ +[ + { + "priority": 1, + "httpRequest": { + "method": "DELETE", + "path": { + "regex": "/tenants/[^/]+" + } + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } + } +] diff --git a/e2e-mocks/expectations/tenants.json b/e2e-mocks/expectations/tenants.json new file mode 100644 index 0000000..d89c0bf --- /dev/null +++ b/e2e-mocks/expectations/tenants.json @@ -0,0 +1,14 @@ +{ + "priority": 1, + "httpRequest": { + "method": "GET", + "path": "/tenants" + }, + "httpForward": { + "scheme": "HTTP", + "host": "mock-callback", + "port": 3001 + }, + "times": { "unlimited": true }, + "timeToLive": { "unlimited": true } +} diff --git a/e2e-mocks/mock-callback/Dockerfile b/e2e-mocks/mock-callback/Dockerfile new file mode 100644 index 0000000..0e152eb --- /dev/null +++ b/e2e-mocks/mock-callback/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine +WORKDIR /app +COPY package.json ./ +COPY server.js ./ +COPY mock_data.js ./ +COPY routes/ ./routes/ +COPY utils/ ./utils/ +RUN npm install +# Create storage directory for uploaded packages +RUN mkdir -p /tmp/codepush-packages +EXPOSE 3001 +CMD ["npm", "start"] + diff --git a/e2e-mocks/mock-callback/mock_data.js b/e2e-mocks/mock-callback/mock_data.js new file mode 100644 index 0000000..c274fa1 --- /dev/null +++ b/e2e-mocks/mock-callback/mock_data.js @@ -0,0 +1,720 @@ +/** + * MOCK_DATA.JS - In-Memory Database for Mock APIs + * + * PURPOSE: + * This file implements a stateful, in-memory database that simulates backend storage + * for the CodePush mock API service. Unlike static JSON mocks, this provides: + * - Stateful persistence across requests (data created via POST persists for GET) + * - Entity relationships (apps belong to accounts, deployments belong to apps, etc.) + * - Business logic validation (duplicate prevention, permission checks, etc.) + * - Cascading operations (deleting app also deletes deployments/collaborators) + * + * ARCHITECTURE: + * - Uses simple JavaScript arrays to store entities (no external database) + * - All operations are synchronous (no async/await needed) + * - Data persists only while the mock-callback container is running + * - Relationships maintained via ID references (foreign keys) + * + * DATA STRUCTURE: + * ┌─────────────┐ + * │ accounts │ → User accounts (id, name, email, linkedProviders) + * └──────┬──────┘ + * │ + * ├─→ apps[] → Applications (id, name, accountId, tenantId, createdTime) + * │ │ + * │ ├─→ deployments[] → Deployment environments (id, name, key, appId) + * │ │ │ + * │ │ └─→ packageHistory[] → Release packages (label, appVersion, etc.) + * │ │ + * │ └─→ collaborators[] → Permission mappings (email, accountId, appId, permission) + * │ + * ├─→ accessKeys[] → API keys (id, name, friendlyName, accountId, expires) + * │ + * └─→ tenants[] → Organizations (id, displayName, createdBy) + * │ + * └─→ apps[] → Apps belonging to tenant + * + * KEY FUNCTIONS: + * - CRUD operations for each entity type (add, get, update, delete) + * - Permission checking (isOwner, hasAccess) + * - Relationship management (cascading deletes, linking entities) + * - Business logic (duplicate checking, validation, label generation) + * + * USAGE: + * All route handlers (routes/*.js) import this module: + * const db = require('../mock_data'); + * + * Example: + * const account = db.getAccount(userId); + * const newApp = db.addApp({ name: 'MyApp', accountId: userId }); + * + * LIFETIME: + * - Data exists in memory only + * - Persists across requests within same container lifecycle + * - Lost when container stops/restarts + * - Can be reset via reset() function for testing + */ + +// ============================================================================ +// DATA STORAGE - In-memory arrays for each entity type +// ============================================================================ + +let accounts = []; // User accounts +let apps = []; // Applications +let deployments = []; // Deployment environments +let accessKeys = []; // API access keys +let tenants = []; // Organizations/tenants +let collaborators = []; // App collaborator permissions + +// Counters (not used in current implementation, kept for potential future use) +let accountIdCounter = 1; +let appIdCounter = 1; +let deploymentIdCounter = 1; +let deploymentKeyCounter = 1; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Generates a unique ID for entities + * Format: {prefix}-{timestamp}-{random} + * Example: "app-1761906891983-a6ibczhvn" + */ +function generateId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// ============================================================================ +// MODULE EXPORTS - Public API for route handlers +// ============================================================================ + +module.exports = { + // ======================================================================== + // ACCOUNT OPERATIONS + // ======================================================================== + // Accounts represent user accounts in the system + // Fields: id, name, email, linkedProviders[] + accounts, + addAccount: (account) => { + const id = generateId('account'); + const newAccount = { ...account, id }; + accounts.push(newAccount); + return id; + }, + getAccount: (accountId) => accounts.find(a => a.id === accountId), + getAccountByEmail: (email) => accounts.find(a => a.email && a.email.toLowerCase() === email.toLowerCase()), + + // ======================================================================== + // APP OPERATIONS + // ======================================================================== + // Apps represent applications that belong to accounts + // Fields: id, name, accountId, tenantId, createdTime + // Relationships: belongs to account, may belong to tenant, has deployments & collaborators + apps, + addApp: (app) => { + const id = generateId('app'); + const newApp = { ...app, id, createdTime: Date.now() }; + apps.push(newApp); + return newApp; + }, + getApps: (accountId, tenantId = null) => { + let userApps = apps.filter(app => { + // Check if user is collaborator or owner + const collab = collaborators.find(c => + c.accountId === accountId && c.appId === app.id + ); + return collab !== undefined; + }); + + // Filter by tenant if provided + if (tenantId) { + userApps = userApps.filter(app => app.tenantId === tenantId); + } + + return userApps; + }, + getAppByName: (accountId, appName, tenantId = null) => { + const userApps = module.exports.getApps(accountId, tenantId); + return userApps.find(app => app.name === appName); + }, + appExists: (appName, tenantId = null) => { + // Check if app exists regardless of user access + let searchApps = apps; + if (tenantId) { + searchApps = apps.filter(app => app.tenantId === tenantId); + } + const found = searchApps.find(app => app.name === appName); + return found; + }, + deleteApp: (accountId, appId) => { + const index = apps.findIndex(app => app.id === appId); + if (index !== -1) { + // Delete app + apps.splice(index, 1); + // Delete associated deployments + deployments = deployments.filter(d => d.appId !== appId); + // Delete associated collaborators + collaborators = collaborators.filter(c => c.appId !== appId); + return true; + } + return false; + }, + isDuplicateApp: (accountId, appRequest) => { + const userApps = module.exports.getApps(accountId); + const existingApp = userApps.find(app => { + // Check by name + if (app.name === appRequest.name) { + // If tenantId is provided, also check tenant + if (appRequest.tenantId && app.tenantId !== appRequest.tenantId) { + return false; + } + return true; + } + return false; + }); + return existingApp !== undefined; + }, + updateApp: (accountId, appId, updates) => { + const app = apps.find(a => a.id === appId); + if (!app) { + throw new Error('App not found'); + } + // Update app fields + if (updates.name !== undefined) { + app.name = updates.name; + } + return app; + }, + + // ======================================================================== + // DEPLOYMENT OPERATIONS + // ======================================================================== + // Deployments represent deployment environments (Production, Staging, etc.) + // Fields: id, name, key, appId, createdTime, package (current), packageHistory[] + // Relationships: belongs to app, contains package history + deployments, + addDeployment: (deployment) => { + const id = generateId('deployment'); + const key = deployment.key || `deployment-key-${deploymentKeyCounter++}`; + const newDeployment = { ...deployment, id, key, createdTime: Date.now() }; + deployments.push(newDeployment); + return newDeployment; + }, + getDeployments: (appId) => deployments.filter(d => d.appId === appId), + getDeploymentById: (deploymentId) => deployments.find(d => d.id === deploymentId), + getDeploymentByKey: (deploymentKey) => deployments.find(d => d.key === deploymentKey), + deleteDeployment: (appId, deploymentId) => { + const index = deployments.findIndex(d => d.appId === appId && d.id === deploymentId); + if (index !== -1) { + deployments.splice(index, 1); + return true; + } + return false; + }, + deleteDeploymentsByAppId: (appId) => { + deployments = deployments.filter(d => d.appId !== appId); + }, + updateDeployment: (appId, deploymentId, updates) => { + const deployment = deployments.find(d => d.appId === appId && d.id === deploymentId); + if (!deployment) { + throw new Error('Deployment not found'); + } + // Update deployment fields + if (updates.name !== undefined) { + deployment.name = updates.name; + } + return deployment; + }, + + // ======================================================================== + // PACKAGE/RELEASE MANAGEMENT + // ======================================================================== + // Packages represent code releases deployed to a deployment environment + // Stored in deployment.packageHistory[] array + // Fields: label (v1, v2, v3...), appVersion, blobUrl, packageHash, rollout, etc. + getPackageHistory: (deploymentId) => { + const deployment = deployments.find(d => d.id === deploymentId); + if (!deployment) return []; + return deployment.packageHistory || []; + }, + commitPackage: (deploymentId, appPackage) => { + const deployment = deployments.find(d => d.id === deploymentId); + if (!deployment) { + throw new Error('Deployment not found'); + } + + // Initialize packageHistory if not exists + if (!deployment.packageHistory) { + deployment.packageHistory = []; + } + + // Get next label + const history = deployment.packageHistory; + let label; + if (history.length === 0) { + label = 'v1'; + } else { + const lastLabel = history[history.length - 1].label; + const lastVersion = parseInt(lastLabel.substring(1)); + label = 'v' + (lastVersion + 1); + } + + // Create package with label + const packageData = { + ...appPackage, + label: label, + uploadTime: appPackage.uploadTime || Date.now(), + releaseMethod: appPackage.releaseMethod || 'Upload' + }; + + // Add to history + deployment.packageHistory.push(packageData); + + // Update deployment's current package + deployment.package = packageData; + + // Limit history to 100 packages + if (deployment.packageHistory.length > 100) { + deployment.packageHistory = deployment.packageHistory.slice(-100); + } + + return packageData; + }, + updatePackageInHistory: (deploymentId, label, updates) => { + const deployment = deployments.find(d => d.id === deploymentId); + if (!deployment || !deployment.packageHistory) { + throw new Error('Deployment not found or has no history'); + } + + // Find package by label (search from end) + for (let i = deployment.packageHistory.length - 1; i >= 0; i--) { + if (deployment.packageHistory[i].label === label) { + // Update package + Object.assign(deployment.packageHistory[i], updates); + + // Update current package if it's the same + if (deployment.package && deployment.package.label === label) { + Object.assign(deployment.package, updates); + } + + return deployment.packageHistory[i]; + } + } + + throw new Error('Package with label not found'); + }, + updatePackageHistory: (deploymentId, packageHistory) => { + const deployment = deployments.find(d => d.id === deploymentId); + if (!deployment) { + throw new Error('Deployment not found'); + } + + // Replace the entire package history + deployment.packageHistory = packageHistory; + + // Update current package to be the last one in history + if (packageHistory && packageHistory.length > 0) { + deployment.package = packageHistory[packageHistory.length - 1]; + } else { + deployment.package = null; + } + + return true; + }, + clearPackageHistory: (deploymentId) => { + const deployment = deployments.find(d => d.id === deploymentId); + if (!deployment) { + throw new Error('Deployment not found'); + } + + deployment.packageHistory = []; + deployment.package = null; + + return true; + }, + + // ======================================================================== + // COLLABORATOR OPERATIONS + // ======================================================================== + // Collaborators represent permission mappings between accounts and apps + // Fields: email, accountId, appId, permission (Owner/Collaborator) + // Purpose: Controls who can access/modify apps + collaborators, + addCollaborator: (collab) => { + const exists = collaborators.find(c => + c.appId === collab.appId && c.email === collab.email + ); + if (!exists) { + collaborators.push(collab); + } + return !exists; + }, + getCollaborators: (appId) => collaborators.filter(c => c.appId === appId), + getCollaboratorForApp: (accountId, appId) => { + return collaborators.find(c => c.accountId === accountId && c.appId === appId); + }, + getCollaboratorsMap: (accountId, appId) => { + // Get all collaborators for the app and format as CollaboratorMap + const collabs = collaborators.filter(c => c.appId === appId); + const collabMap = {}; + + collabs.forEach(collab => { + collabMap[collab.email] = { + accountId: collab.accountId, + permission: collab.permission, + isCurrentAccount: collab.accountId === accountId + }; + }); + + return collabMap; + }, + addCollaboratorToApp: (accountId, appId, email) => { + // Check if collaborator already exists (case-insensitive) + const exists = collaborators.find(c => + c.appId === appId && + (c.email === email || c.email.toLowerCase() === email.toLowerCase()) + ); + + if (exists) { + throw new Error('The given account is already a collaborator for this app.'); + } + + // Get account by email to ensure it exists and get the correct email casing + const targetAccount = accounts.find(a => a.email && a.email.toLowerCase() === email.toLowerCase()); + if (!targetAccount) { + throw new Error('The specified e-mail address doesn\'t represent a registered user'); + } + + // Use the email from the account (preserve casing) + const actualEmail = targetAccount.email; + + // Add collaborator + collaborators.push({ + email: actualEmail, + accountId: targetAccount.id, + appId: appId, + permission: 'Collaborator' + }); + + return true; + }, + removeCollaboratorFromApp: (accountId, appId, email) => { + // Find the collaborator + const collab = collaborators.find(c => + c.appId === appId && + (c.email === email || c.email.toLowerCase() === email.toLowerCase()) + ); + + if (!collab) { + throw new Error('The given email is not a collaborator for this app.'); + } + + // Cannot remove the owner + if (collab.permission === 'Owner') { + throw new Error('Cannot remove the owner of the app from collaborator list.'); + } + + // Remove the collaborator + const index = collaborators.findIndex(c => + c.appId === appId && + (c.email === email || c.email.toLowerCase() === email.toLowerCase()) + ); + if (index !== -1) { + collaborators.splice(index, 1); + } + + return true; + }, + updateCollaboratorRole: (accountId, appId, email, newPermission) => { + // Find the collaborator (case-insensitive) + const collab = collaborators.find(c => + c.appId === appId && + (c.email === email || c.email.toLowerCase() === email.toLowerCase()) + ); + + if (!collab) { + throw new Error('The given email is not a collaborator for this app.'); + } + + // Update permission + collab.permission = newPermission; + return collab; + }, + + // ======================================================================== + // TENANT/ORGANIZATION OPERATIONS + // ======================================================================== + // Tenants represent organizations that can contain multiple apps + // Fields: id, displayName, createdBy, createdAt + // Relationships: apps can belong to tenants, tenant created by account + tenants, + addTenant: (tenant) => { + const id = generateId('tenant'); + const newTenant = { ...tenant, id, createdAt: Date.now() }; + tenants.push(newTenant); + return newTenant; + }, + getTenant: (tenantId) => tenants.find(t => t.id === tenantId), + getTenants: (accountId) => { + // Get all tenants where user has access via apps (as collaborator/owner) + // Find all apps where user is a collaborator + const userApps = apps.filter(app => { + const collab = collaborators.find(c => c.accountId === accountId && c.appId === app.id); + return collab !== undefined && app.tenantId !== null; + }); + + // Get unique tenant IDs + const tenantIds = [...new Set(userApps.map(app => app.tenantId).filter(Boolean))]; + + // Get tenant details and determine role + const tenantOrgs = tenantIds.map(tenantId => { + const tenant = tenants.find(t => t.id === tenantId); + if (!tenant) return null; + + // Role is "Owner" if user created the tenant, otherwise "Collaborator" + const role = tenant.createdBy === accountId ? 'Owner' : 'Collaborator'; + + return { + id: tenant.id, + displayName: tenant.displayName, + role: role + }; + }).filter(Boolean); + + return tenantOrgs; + }, + removeTenant: (accountId, tenantId) => { + const tenant = tenants.find(t => t.id === tenantId); + if (!tenant) { + throw new Error('Specified Organisation does not exist.'); + } + + // Check if user is the owner (createdBy) + if (tenant.createdBy !== accountId) { + throw new Error('User does not have admin permissions for the specified tenant.'); + } + + // Get all apps under this tenant + const tenantApps = apps.filter(app => app.tenantId === tenantId); + + // For each app: + // - If app owner is the requesting user, delete the app + // - If app owner is someone else, set tenantId to null + tenantApps.forEach(app => { + if (app.accountId === accountId) { + // Delete app (and associated data) + module.exports.deleteApp(accountId, app.id); + } else { + // Remove tenant association + app.tenantId = null; + } + }); + + // Delete the tenant + const index = tenants.findIndex(t => t.id === tenantId); + if (index !== -1) { + tenants.splice(index, 1); + } + + return true; + }, + + // ======================================================================== + // ACCESS KEY OPERATIONS + // ======================================================================== + // Access keys are API authentication tokens + // Fields: id, name (secret key), friendlyName (human-readable), accountId, expires, scope, isSession + // Purpose: Allow programmatic access to API without user login + accessKeys, + addAccessKey: (accountId, accessKey) => { + const id = generateId('accesskey'); + const newAccessKey = { ...accessKey, id, accountId }; + accessKeys.push(newAccessKey); + return id; + }, + getAccessKeys: (accountId) => { + return accessKeys.filter(ak => ak.accountId === accountId); + }, + getAccessKeyById: (accountId, accessKeyId) => { + return accessKeys.find(ak => ak.id === accessKeyId && ak.accountId === accountId); + }, + findAccessKeyByName: (accountId, name) => { + // Match by either name or friendlyName + return accessKeys.find(ak => + ak.accountId === accountId && + (ak.name === name || ak.friendlyName === name) + ); + }, + updateAccessKey: (accountId, accessKey) => { + const index = accessKeys.findIndex(ak => + ak.id === accessKey.id && ak.accountId === accountId + ); + if (index !== -1) { + accessKeys[index] = { ...accessKeys[index], ...accessKey }; + return true; + } + return false; + }, + removeAccessKey: (accountId, accessKeyId) => { + const index = accessKeys.findIndex(ak => + ak.id === accessKeyId && ak.accountId === accountId + ); + if (index !== -1) { + accessKeys.splice(index, 1); + return true; + } + return false; + }, + getUserFromAccessKey: (friendlyName) => { + // Search by friendlyName + const accessKey = accessKeys.find(ak => ak.friendlyName === friendlyName); + if (!accessKey) { + throw new Error('Access key not found'); + } + + // Check if expired + if (new Date().getTime() >= accessKey.expires) { + throw new Error('The access key has expired.'); + } + + // Get account + const account = accounts.find(a => a.id === accessKey.accountId); + if (!account) { + throw new Error('Account not found'); + } + + return account; + }, + removeAccessKeysByCreatedBy: (accountId, createdBy) => { + // Remove all access keys that are sessions and match createdBy + const toRemove = accessKeys.filter(ak => + ak.accountId === accountId && + ak.isSession === true && + ak.createdBy === createdBy + ); + + toRemove.forEach(ak => { + const index = accessKeys.findIndex(key => key.id === ak.id); + if (index !== -1) { + accessKeys.splice(index, 1); + } + }); + + return toRemove.length; + }, + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Reset all data - clears all arrays + * Useful for testing to start with a clean slate + */ + reset: () => { + accounts = []; + apps = []; + deployments = []; + accessKeys = []; + tenants = []; + collaborators = []; + accountIdCounter = 1; + appIdCounter = 1; + deploymentIdCounter = 1; + deploymentKeyCounter = 1; + }, + + /** + * Initialize pre-configured test data + * Creates default account, tenant, app, and deployment for testing + * This data is automatically created when the server starts + */ + initializePreconfiguredData: () => { + // Reset any existing data first + module.exports.reset(); + + // 1. Create test account + const testAccountId = 'test-user'; + const testAccount = { + id: testAccountId, + name: 'Test User', + email: 'test@example.com', + linkedProviders: [] + }; + accounts.push(testAccount); + + // 2. Create test tenant/organization + // Note: displayName must match what CLI uses (testOrg/testApp format) + const testTenantId = 'testOrg'; + const testTenant = { + id: testTenantId, + displayName: 'testOrg', // Must match CLI app name format + createdBy: testAccountId, + createdAt: Date.now() + }; + tenants.push(testTenant); + + // 3. Create test app + const testAppName = 'testApp'; + const testApp = { + id: generateId('app'), + name: testAppName, + accountId: testAccountId, + tenantId: testTenantId, + createdTime: Date.now() + }; + apps.push(testApp); + + // 4. Create Production deployment with the expected key + const productionDeployment = { + id: generateId('deployment'), + name: 'Production', + key: 'deployment-key-1', + appId: testApp.id, + createdTime: Date.now(), + packageHistory: [], + package: null + }; + deployments.push(productionDeployment); + + // 5. Create Staging deployment (optional, but useful for testing) + const stagingDeployment = { + id: generateId('deployment'), + name: 'Staging', + key: 'deployment-key-2', + appId: testApp.id, + createdTime: Date.now(), + packageHistory: [], + package: null + }; + deployments.push(stagingDeployment); + + // 6. Add test-user as owner collaborator for the app + collaborators.push({ + email: testAccount.email, + accountId: testAccountId, + appId: testApp.id, + permission: 'Owner' + }); + + // 7. Create access key for CLI authentication (named "test-user") + // CLI sends "Bearer cli-test-user", so we need an access key with friendlyName "test-user" + const accessKeyExpiry = Date.now() + (90 * 24 * 60 * 60 * 1000); // 90 days from now + const testAccessKey = { + id: generateId('accesskey'), + name: `cli-${testAccountId}`, // CLI uses this as the actual key + friendlyName: testAccountId, // This is what CLI references + accountId: testAccountId, + expires: accessKeyExpiry, + createdTime: Date.now(), + isSession: false, + createdBy: testAccountId, + scope: null + }; + accessKeys.push(testAccessKey); + + // Pre-configured test data prepared + } +}; + diff --git a/e2e-mocks/mock-callback/package.json b/e2e-mocks/mock-callback/package.json new file mode 100644 index 0000000..2347f78 --- /dev/null +++ b/e2e-mocks/mock-callback/package.json @@ -0,0 +1,15 @@ +{ + "name": "mock-callback", + "version": "1.0.0", + "description": "Mock callback service for MockServer", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.17.1", + "body-parser": "^1.19.0", + "multer": "^1.4.5-lts.1" + } +} + diff --git a/e2e-mocks/mock-callback/routes/accesskeys.js b/e2e-mocks/mock-callback/routes/accesskeys.js new file mode 100644 index 0000000..8e5f643 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/accesskeys.js @@ -0,0 +1,328 @@ +const db = require('../mock_data'); +const { getUserId } = require('../utils/auth'); + +// Helper: Extract IP address from request +function getIpAddress(req) { + return req.ip || req.connection?.remoteAddress || '127.0.0.1'; +} + +// Helper: Generate secure key (simplified) +function generateSecureKey(accountId) { + // Simple mock implementation - in real code this would be more secure + return `key-${accountId}-${Date.now()}-${Math.random().toString(36).substr(2, 16)}`; +} + +// Helper: Validate key field (10-100 chars, alphanumeric and some special chars) +function isValidKeyField(val) { + if (!val || typeof val !== 'string') return false; + if (val.length < 10 || val.length > 100) return false; + // Simplified validation - allow alphanumeric and common safe characters + return /^[a-zA-Z0-9_\-]+$/.test(val); +} + +// Helper: Validate friendly name (1-10000 chars) +function isValidFriendlyNameField(val) { + if (!val || typeof val !== 'string') return false; + if (val.length < 1 || val.length > 10000) return false; + return true; +} + +// Helper: Validate TTL (>= 0, allow 0 for updates) +function isValidTtlField(allowZero, val) { + if (val === null || val === undefined) return true; // Optional + if (typeof val !== 'number' || isNaN(val)) return false; + return val >= 0 && (val !== 0 || allowZero); +} + +// Helper: Validate scope +function isValidScope(val) { + if (val === null || val === undefined) return true; // Optional + return ['All', 'Write', 'Read'].includes(val); +} + +// Helper: Check if name is duplicate (checks both name and friendlyName) +function isDuplicate(accessKeys, name) { + return accessKeys.some(ak => ak.name === name || ak.friendlyName === name); +} + +// Helper: Find access key by name (checks both name and friendlyName) +function findByName(accessKeys, name) { + return accessKeys.find(ak => ak.name === name || ak.friendlyName === name); +} + +// Default TTL: 60 days in milliseconds +const DEFAULT_ACCESS_KEY_EXPIRY = 1000 * 60 * 60 * 24 * 60; +const ACCESS_KEY_MASKING_STRING = '(hidden)'; + +// GET /accessKeys - List all access keys for account +function getAccessKeys(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const accessKeysList = db.getAccessKeys(accountId); + + // Sort by createdTime + accessKeysList.sort((first, second) => { + const firstTime = first.createdTime || 0; + const secondTime = second.createdTime || 0; + return firstTime - secondTime; + }); + + // Mask the actual key string for legacy CLIs + const maskedKeys = accessKeysList.map(ak => ({ + ...ak, + name: ACCESS_KEY_MASKING_STRING + })); + + return res.status(200).json({ accessKeys: maskedKeys }); +} + +// POST /accessKeys - Create a new access key +function postAccessKeys(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const accessKeyRequest = req.body; + + // Generate name if not provided + if (!accessKeyRequest.name) { + accessKeyRequest.name = generateSecureKey(accountId); + } + + // Set createdBy from IP if not provided + if (!accessKeyRequest.createdBy) { + accessKeyRequest.createdBy = getIpAddress(req); + } + + // Validate required fields + const validationErrors = []; + + if (!isValidKeyField(accessKeyRequest.name)) { + validationErrors.push({ field: 'name', message: 'Field is invalid' }); + } + + if (!accessKeyRequest.friendlyName || !isValidFriendlyNameField(accessKeyRequest.friendlyName)) { + validationErrors.push({ field: 'friendlyName', message: 'Field is required' }); + } + + if (accessKeyRequest.ttl !== undefined && !isValidTtlField(false, accessKeyRequest.ttl)) { + validationErrors.push({ field: 'ttl', message: 'Field is invalid' }); + } + + if (!isValidScope(accessKeyRequest.scope)) { + validationErrors.push({ field: 'scope', message: 'Field is invalid' }); + } + + if (validationErrors.length > 0) { + return res.status(400).json(validationErrors); + } + + // Check for duplicates + const existingKeys = db.getAccessKeys(accountId); + if (isDuplicate(existingKeys, accessKeyRequest.name)) { + return res.status(409).json({ error: `The access key "${accessKeyRequest.name}" already exists.` }); + } + + if (isDuplicate(existingKeys, accessKeyRequest.friendlyName)) { + return res.status(409).json({ error: `The access key "${accessKeyRequest.friendlyName}" already exists.` }); + } + + // Create access key + const createdTime = Date.now(); + const ttl = accessKeyRequest.ttl || DEFAULT_ACCESS_KEY_EXPIRY; + const expires = createdTime + ttl; + + const accessKey = { + name: accessKeyRequest.name, + friendlyName: accessKeyRequest.friendlyName, + description: accessKeyRequest.friendlyName, // description = friendlyName + createdTime: createdTime, + expires: expires, + createdBy: accessKeyRequest.createdBy, + scope: accessKeyRequest.scope || 'All', + isSession: accessKeyRequest.isSession || false + }; + + const accessKeyId = db.addAccessKey(accountId, accessKey); + + // Return access key (without internal id) + const restAccessKey = { + ...accessKey, + id: accessKeyId + }; + + res.setHeader('Location', `/accessKeys/${accessKey.friendlyName}`); + return res.status(201).json({ accessKey: restAccessKey }); +} + +// GET /accessKeys/:accessKeyName - Get a specific access key +function getAccessKey(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const accessKeyName = req.params.accessKeyName; + + const existingKeys = db.getAccessKeys(accountId); + const accessKey = findByName(existingKeys, accessKeyName); + + if (!accessKey) { + return res.status(404).json({ error: `Access key "${accessKeyName}" does not exist.` }); + } + + // Delete name from response (security) + const restAccessKey = { ...accessKey }; + delete restAccessKey.name; + + return res.status(200).json({ accessKey: restAccessKey }); +} + +// PATCH /accessKeys/:accessKeyName - Update an access key +function patchAccessKey(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const accessKeyName = req.params.accessKeyName; + const accessKeyRequest = req.body; + + // Validate fields (all optional for update) + const validationErrors = []; + + if (accessKeyRequest.friendlyName !== undefined && !isValidFriendlyNameField(accessKeyRequest.friendlyName)) { + validationErrors.push({ field: 'friendlyName', message: 'Field is invalid' }); + } + + if (accessKeyRequest.ttl !== undefined && !isValidTtlField(true, accessKeyRequest.ttl)) { + validationErrors.push({ field: 'ttl', message: 'Field is invalid' }); + } + + if (accessKeyRequest.scope !== undefined && !isValidScope(accessKeyRequest.scope)) { + validationErrors.push({ field: 'scope', message: 'Field is invalid' }); + } + + if (validationErrors.length > 0) { + return res.status(400).json(validationErrors); + } + + // Find existing access key + const existingKeys = db.getAccessKeys(accountId); + let updatedAccessKey = findByName(existingKeys, accessKeyName); + + if (!updatedAccessKey) { + return res.status(404).json({ error: `Access key "${accessKeyName}" does not exist.` }); + } + + // Update fields + if (accessKeyRequest.description !== undefined) { + updatedAccessKey.description = accessKeyRequest.description; + } + + if (accessKeyRequest.friendlyName !== undefined) { + // Check for duplicate friendlyName + if (isDuplicate(existingKeys, accessKeyRequest.friendlyName) && + updatedAccessKey.friendlyName !== accessKeyRequest.friendlyName) { + return res.status(409).json({ error: `The access key "${accessKeyRequest.friendlyName}" already exists.` }); + } + + updatedAccessKey.friendlyName = accessKeyRequest.friendlyName; + updatedAccessKey.description = accessKeyRequest.friendlyName; + } + + if (accessKeyRequest.scope !== undefined) { + updatedAccessKey.scope = accessKeyRequest.scope; + } + + if (accessKeyRequest.ttl !== undefined) { + updatedAccessKey.expires = Date.now() + accessKeyRequest.ttl; + } + + // Save updates + db.updateAccessKey(accountId, updatedAccessKey); + + // Delete name from response + const restAccessKey = { ...updatedAccessKey }; + delete restAccessKey.name; + + return res.status(200).json({ accessKey: restAccessKey }); +} + +// DELETE /accessKeys/:accessKeyName - Delete an access key +function deleteAccessKey(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const accessKeyName = req.params.accessKeyName; + + const existingKeys = db.getAccessKeys(accountId); + const accessKey = findByName(existingKeys, accessKeyName); + + if (!accessKey) { + return res.status(404).json({ error: `Access key "${accessKeyName}" does not exist.` }); + } + + db.removeAccessKey(accountId, accessKey.id); + + return res.status(201).send('Access key deleted successfully'); +} + +// DELETE /sessions/:createdBy - Delete all sessions with a specific createdBy +function deleteSessions(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const createdBy = req.params.createdBy; + + const removedCount = db.removeAccessKeysByCreatedBy(accountId, createdBy); + + if (removedCount === 0) { + return res.status(404).json({ error: `There are no sessions associated with "${createdBy}."` }); + } + + return res.status(204).send(); +} + +// GET /accountByaccessKeyName - Get account by access key name (from header) +function getAccountByAccessKeyName(req, res) { + const accessKeyName = Array.isArray(req.headers.accesskeyname) + ? req.headers.accesskeyname[0] + : req.headers.accesskeyname; + + if (!accessKeyName) { + return res.status(400).send('Access key name is required'); + } + + try { + const account = db.getUserFromAccessKey(accessKeyName); + return res.status(200).json({ user: account }); + } catch (error) { + if (error.message === 'Access key not found') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'The access key has expired.') { + return res.status(401).json({ error: error.message }); + } + return res.status(500).json({ error: error.message }); + } +} + +module.exports = { + getAccessKeys, + postAccessKeys, + getAccessKey, + patchAccessKey, + deleteAccessKey, + deleteSessions, + getAccountByAccessKeyName +}; + diff --git a/e2e-mocks/mock-callback/routes/account.js b/e2e-mocks/mock-callback/routes/account.js new file mode 100644 index 0000000..344e429 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/account.js @@ -0,0 +1,68 @@ +const db = require('../mock_data'); +const { json, html, fieldErr } = require('../utils/response'); + +const { getUserId } = require('../utils/auth'); + +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +// GET /account +function getAccount(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const account = db.getAccount(accountId); + if (!account) { + return res.status(404).send('Account not found'); + } + + return res.status(200).json({ account }); +} + +// POST /account +function postAccount(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (!req.body || !req.body.account) { + return res.status(400).send(JSON.stringify([{ field: 'account', message: 'Required field missing' }])); + } + + const acc = req.body.account; + + if (!acc.name || !acc.name.trim()) { + return res.status(400).send(JSON.stringify([{ field: 'name', message: 'Required field missing' }])); + } + + if (acc.email && !isValidEmail(acc.email)) { + return res.status(400).send(JSON.stringify([{ field: 'email', message: 'Invalid email format' }])); + } + + const email = acc.email ? acc.email.toLowerCase() : null; + const exists = email && db.accounts.some(a => a.email && a.email.toLowerCase() === email); + + if (exists) { + return res.status(409).send('The provided resource already exists'); + } + + // Use accountId from Bearer token as the account ID + db.accounts.push({ + id: accountId, + name: acc.name, + email: acc.email || null, + linkedProviders: [] + }); + + return res.status(200).json({ account: accountId }); +} + +module.exports = { + getAccount, + postAccount +}; + diff --git a/e2e-mocks/mock-callback/routes/acquisition.js b/e2e-mocks/mock-callback/routes/acquisition.js new file mode 100644 index 0000000..0cc4ce2 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/acquisition.js @@ -0,0 +1,466 @@ +const db = require('../mock_data'); +const fs = require('fs'); +const path = require('path'); + +// File-based logging function +function writeLog(message) { + const logFile = '/tmp/update-check.log'; + const timestamp = new Date().toISOString(); + fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`); +} + +// Simple semver check (basic implementation) +function isValidSemver(version) { + // Basic semver validation: accepts both major.minor.patch and major.minor formats + // Android versionName can be "1.0" (2 parts) or "1.0.0" (3 parts) + const semverRegex = /^\d+\.\d+(\.\d+)?(-.+)?$/; + return semverRegex.test(version); +} + +// Normalize version strings (1.0 -> 1.0.0, 1.0.0 -> 1.0.0) +function normalizeVersion(version) { + const parts = version.split('.'); + while (parts.length < 3) { + parts.push('0'); + } + return parts.join('.'); +} + +// Simple semver satisfies check (basic implementation) +// This should match semver.satisfies() behavior as closely as possible +function semverSatisfies(appVersion, packageAppVersion) { + // Normalize versions for comparison (1.0 == 1.0.0) + const normalizedAppVersion = normalizeVersion(appVersion); + const normalizedPackageVersion = normalizeVersion(packageAppVersion); + + if (normalizedAppVersion === normalizedPackageVersion) return true; + + // Handle semver ranges (e.g., ">=1.0.0", "*", "1.x", etc.) + // For now, handle common cases: + + // If package version is a range starting with >= + if (packageAppVersion.startsWith('>=')) { + const rangeVersion = packageAppVersion.substring(2).trim(); + return semverGreaterThanOrEqual(normalizedAppVersion, normalizeVersion(rangeVersion)); + } + + // If package version is "*" or "x", it matches everything + if (packageAppVersion === '*' || packageAppVersion === 'x') { + return true; + } + + // Handle "1.x" or "1.0.x" patterns + if (packageAppVersion.includes('.x')) { + const pattern = packageAppVersion.replace(/\.x/g, ''); + return normalizedAppVersion.startsWith(normalizeVersion(pattern)); + } + + // For exact versions, they should be equal after normalization + // If package version doesn't match exactly, check if it's a range + // For now, treat exact versions as requiring exact match + return normalizedAppVersion === normalizedPackageVersion; +} + +// Helper: Check if version1 >= version2 +function semverGreaterThanOrEqual(v1, v2) { + const parts1 = v1.split('.').map(n => parseInt(n || '0')); + const parts2 = v2.split('.').map(n => parseInt(n || '0')); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return true; + if (p1 < p2) return false; + } + return true; // Equal +} + +// Helper: Check if version1 > version2 +function semverGreaterThan(v1, v2) { + const parts1 = v1.split('.').map(n => parseInt(n || '0')); + const parts2 = v2.split('.').map(n => parseInt(n || '0')); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return true; + if (p1 < p2) return false; + } + return false; // Equal, so not greater +} + +// Check if rollout is unfinished +function isUnfinishedRollout(rollout) { + return rollout !== null && rollout !== undefined && rollout > 0 && rollout < 100; +} + +// GET /updateCheck - Check for available updates +function updateCheck(req, res) { + const deploymentKey = req.query.deploymentKey || req.query.deployment_key; + const appVersion = req.query.appVersion || req.query.app_version; + const clientUniqueId = req.query.clientUniqueId || req.query.client_unique_id; + const packageHash = req.query.packageHash || req.query.package_hash; + const label = req.query.label; + const isCompanion = req.query.isCompanion === 'true' || req.query.is_companion === 'true'; + const newApi = req.originalUrl.includes('v0.1/public/codepush/update_check') || req.path.includes('v0.1/public/codepush/update_check'); + + // Minimal request log (file), controllable via LOG_LEVEL + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + const timestamp = new Date().toISOString(); + writeLog(`[UPDATE CHECK] ${timestamp} key=${deploymentKey} v=${appVersion}`); + } + + // Validate required fields + if (!deploymentKey) { + return res.status(400).send( + 'An update check must include a valid deployment key - please check that your app has been ' + + 'configured correctly. To view available deployment keys, run \'code-push-standalone deployment ls -k\'.' + ); + } + + if (!appVersion || !isValidSemver(appVersion)) { + return res.status(400).send( + 'An update check must include a binary version that conforms to the semver standard (e.g. \'1.0.0\'). ' + + 'The binary version is normally inferred from the App Store/Play Store version configured with your app.' + ); + } + + // Find deployment by key + const deployment = db.getDeploymentByKey(deploymentKey); + if (!deployment) { + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + // No matching deployment + } + // Return no update if deployment not found + const noUpdate = { + updateInfo: { + isAvailable: false, + shouldRunBinaryVersion: true, + appVersion: appVersion + } + }; + return res.status(200).send(newApi ? convertToSnakeCase(noUpdate) : noUpdate); + } + + + + // Get package history + const packageHistory = db.getPackageHistory(deployment.id); + + // (silenced verbose package history logs) + + if (!packageHistory || packageHistory.length === 0) { + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + // No packages for this app + } + // No packages, return no update + const noUpdate = { + updateInfo: { + isAvailable: false, + shouldRunBinaryVersion: true, + appVersion: appVersion + } + }; + return res.status(200).send(newApi ? convertToSnakeCase(noUpdate) : noUpdate); + } + + // Find the latest enabled package that satisfies appVersion + // This logic matches the original server implementation exactly + let latestEnabledPackage = null; + let latestSatisfyingPackage = null; + let foundRequestPackageInHistory = false; + let rollout = null; + let shouldMakeUpdateMandatory = false; + + // Search from newest to oldest (reverse order) + // (silenced detailed scan logs) + for (let i = packageHistory.length - 1; i >= 0; i--) { + const pkg = packageHistory[i]; + + // (scan details removed) + + // Check if this is the package the client is currently running + // Note: If both label and packageHash are missing from request, + // we can't determine which package the client has, so we'll scan + // through all packages to find the latest satisfying one + const isCurrentPackage = + (label && pkg.label === label) || + (!label && packageHash && pkg.packageHash === packageHash); + + // (verbose match logs removed) + + foundRequestPackageInHistory = foundRequestPackageInHistory || isCurrentPackage || + (!label && !packageHash); // If both missing, we'll treat as "found" after we get latest + + // Skip disabled packages + if (pkg.isDisabled) { + // skip disabled + continue; + } + + // Track latest enabled package (first non-disabled package we encounter) + if (!latestEnabledPackage) { + latestEnabledPackage = pkg; + // mark latest enabled + } + + // Check if package satisfies appVersion (or ignore if isCompanion) + const satisfiesAppVersion = isCompanion || semverSatisfies(appVersion, pkg.appVersion); + + if (!satisfiesAppVersion) { + // doesn't satisfy + continue; // Skip packages that don't satisfy appVersion + } + + // Skip unfinished rollout packages for satisfying check (we'll handle rollout separately) + if (isUnfinishedRollout(pkg.rollout)) { + rollout = pkg.rollout; + continue; + } + + // This package satisfies appVersion - track it + if (!latestSatisfyingPackage) { + latestSatisfyingPackage = pkg; + // mark latest satisfying + } + + // If we found the client's current package, stop scanning + // All packages further down are older than what the client has + if (isCurrentPackage) { + // found current + break; + } + + // If this package is mandatory and is newer than what client has, + // mark the update as mandatory and stop (we have all info needed) + if (pkg.isMandatory) { + shouldMakeUpdateMandatory = true; + break; + } + } + + // (silenced scan summary) + + // Build response - matches original server structure + const updateInfo = { + isAvailable: false, + shouldRunBinaryVersion: false, + appVersion: appVersion, + downloadURL: '', + packageSize: 0, + label: '', + packageHash: '', + description: '', + isMandatory: false, + updateAppVersion: false, + isBundlePatchingEnabled: false + }; + + // If no satisfying package found + updateInfo.shouldRunBinaryVersion = !latestSatisfyingPackage; + + if (!latestEnabledPackage) { + // None of the releases in this deployment are enabled + const response = { updateInfo: updateInfo }; + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + // No enabled packages + } + return res.status(200).send(newApi ? convertToSnakeCase(response) : response); + } + + // If no satisfying package OR client already has latest, return no update + const clientHasPackage = (latestSatisfyingPackage && latestSatisfyingPackage.packageHash === packageHash) || + (latestSatisfyingPackage && label && latestSatisfyingPackage.label === label); + + // (silenced availability details) + + if (updateInfo.shouldRunBinaryVersion || clientHasPackage) { + // Still provide appVersion info + if (semverGreaterThan(appVersion, latestEnabledPackage.appVersion)) { + // Client version is newer than latest package + updateInfo.appVersion = latestEnabledPackage.appVersion; + } else if (!semverSatisfies(appVersion, latestEnabledPackage.appVersion)) { + // Client version doesn't satisfy latest package version + updateInfo.updateAppVersion = true; + updateInfo.appVersion = latestEnabledPackage.appVersion; + } + + const response = { updateInfo: updateInfo }; + // minimal + return res.status(200).send(newApi ? convertToSnakeCase(response) : response); + } + + // Update is available + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + // Update available + } + + // Update is available - set all fields + updateInfo.isAvailable = true; + updateInfo.downloadURL = latestSatisfyingPackage.blobUrl || ''; + updateInfo.packageSize = latestSatisfyingPackage.size || 0; + updateInfo.label = latestSatisfyingPackage.label || ''; + updateInfo.packageHash = latestSatisfyingPackage.packageHash || ''; + updateInfo.description = latestSatisfyingPackage.description || ''; + // Make mandatory if explicitly set OR if there's a mandatory package newer than client + updateInfo.isMandatory = shouldMakeUpdateMandatory || (latestSatisfyingPackage.isMandatory || false); + // Return the same appVersion as requested (for old plugins compatibility) + updateInfo.appVersion = appVersion; + updateInfo.isBundlePatchingEnabled = latestSatisfyingPackage.isBundlePatchingEnabled || false; + rollout = latestSatisfyingPackage.rollout; + + // Handle rollout package selection (simplified) + let finalUpdateInfo = updateInfo; + if (rollout && clientUniqueId) { + // Simplified rollout selection: use clientUniqueId hash + const hash = simpleHash(clientUniqueId); + const shouldUseRollout = (hash % 100) < rollout; + + if (shouldUseRollout) { + // Find rollout package + const rolloutPackage = packageHistory[packageHistory.length - 1]; + if (rolloutPackage && isUnfinishedRollout(rolloutPackage.rollout)) { + finalUpdateInfo = { + ...updateInfo, + downloadURL: rolloutPackage.blobUrl || '', + packageSize: rolloutPackage.size || 0, + label: rolloutPackage.label || '', + packageHash: rolloutPackage.packageHash || '', + description: rolloutPackage.description || '', + isMandatory: rolloutPackage.isMandatory || false, + appVersion: rolloutPackage.appVersion + }; + } + } + } + + finalUpdateInfo.target_binary_range = finalUpdateInfo.appVersion; + + const response = { updateInfo: finalUpdateInfo }; + // Final minimal response log + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + // Response summary + } + return res.status(200).send(newApi ? convertToSnakeCase(response) : response); +} + +// Simple hash function for rollout selection +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +// Convert camelCase to snake_case +// Convert camelCase to snake_case with proper acronym handling +function convertToSnakeCase(obj) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => convertToSnakeCase(item)); + } + + const result = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + let snakeKey = key; + + // ✅ Acronym-safe rewrite: treat URL suffix as a single word + snakeKey = snakeKey.replace(/URL$/, 'Url'); + + // ✅ Standard camelCase → snake_case + snakeKey = snakeKey + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1_$2') + .toLowerCase(); + + // ✅ Defensive cleanup for edge cases + snakeKey = snakeKey.replace(/_u_r_l/g, '_url'); + + result[snakeKey] = convertToSnakeCase(obj[key]); + } + } + return result; +} + + +// POST /reportStatus/deploy - Report deployment status +function reportStatusDeploy(req, res) { + const deploymentKey = req.body.deploymentKey || req.body.deployment_key; + const appVersion = req.body.appVersion || req.body.app_version; + const label = req.body.label; + const status = req.body.status; + const clientUniqueId = req.body.clientUniqueId || req.body.client_unique_id; + + // Validate required fields + if (!deploymentKey || !appVersion) { + return res.status(400).send( + 'A deploy status report must contain a valid appVersion and deploymentKey.' + ); + } + + // If label is provided, status must also be provided and valid + if (label) { + if (!status) { + return res.status(400).send( + 'A deploy status report for a labelled package must contain a valid status.' + ); + } + + const validStatuses = ['DeploymentSucceeded', 'DeploymentFailed', 'Downloaded']; + if (!validStatuses.includes(status)) { + return res.status(400).send('Invalid status: ' + status); + } + } + + // For older SDK versions, clientUniqueId is required + // For newer versions (1.5.2-beta+), it's optional + // For mock, we'll accept requests without clientUniqueId if they have label/status + // This matches the behavior where newer SDK versions don't require clientUniqueId + + // In the real implementation, this updates Redis with metrics + // For mock, we just log and return success +// Deploy status received + + return res.sendStatus(200); +} + +// POST /reportStatus/download - Report download status +function reportStatusDownload(req, res) { + const deploymentKey = req.body.deploymentKey || req.body.deployment_key; + const label = req.body.label; + + // Validate required fields + if (!req.body || !deploymentKey || !label) { + return res.status(400).send( + 'A download status report must contain a valid deploymentKey and package label.' + ); + } + + // In the real implementation, this increments download count in Redis + // For mock, we just log and return success +// Download status received + + return res.sendStatus(200); +} + +// GET /healthcheck - Health check endpoint +function healthcheck(req, res) { + // In the real implementation, this checks Storage, Redis, and Memcached + // For mock, we just return healthy + return res.status(200).send('Healthy'); +} + +module.exports = { + updateCheck, + reportStatusDeploy, + reportStatusDownload, + healthcheck +}; + diff --git a/e2e-mocks/mock-callback/routes/apps.js b/e2e-mocks/mock-callback/routes/apps.js new file mode 100644 index 0000000..f82d195 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/apps.js @@ -0,0 +1,271 @@ +const db = require('../mock_data'); +const { json, html, fieldErr, sendError } = require('../utils/response'); + +const { getUserId } = require('../utils/auth'); + +// Helper: Extract tenant ID from header +function getTenantId(req) { + const tenant = req.headers.tenant; + return Array.isArray(tenant) ? tenant[0] : tenant || null; +} + +// Helper: Check if user is owner +function isOwner(accountId, appId) { + const collab = db.getCollaboratorForApp(accountId, appId); + return collab && collab.permission === 'Owner'; +} + +// Helper: Check if user has collaborator access +function hasAccess(accountId, appId) { + return db.getCollaboratorForApp(accountId, appId) !== undefined; +} + +// Helper: Validate app name +function isValidAppName(name) { + if (!name || typeof name !== 'string') return false; + const trimmed = name.trim(); + return trimmed.length > 0 && trimmed.length <= 100; +} + +// GET /apps - Get all apps for account +function getApps(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const tenantId = getTenantId(req); + const apps = db.getApps(accountId, tenantId); + + // Get deployments for each app + const appsWithDeployments = apps.map(app => { + const appDeployments = db.getDeployments(app.id); + const deploymentNames = appDeployments.map(d => d.name); + return { + id: app.id, + name: app.name, + displayName: app.name, + deployments: deploymentNames, + createdTime: app.createdTime, + tenantId: app.tenantId || null + }; + }); + + // Sort by name + appsWithDeployments.sort((a, b) => a.name.localeCompare(b.name)); + + return res.status(200).json({ apps: appsWithDeployments }); +} + +// POST /apps - Create a new app +function postApps(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const account = db.getAccount(accountId); + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + const appRequest = req.body; + + // Validation: name is required + if (!appRequest || !isValidAppName(appRequest.name)) { + return res.status(400).json([{ field: 'name', message: 'App name is required and must be a valid string' }]); + } + + // Check for duplicate app name + if (db.isDuplicateApp(accountId, appRequest)) { + return res.status(409).json({ error: `An app named '${appRequest.name}' already exists.` }); + } + + // Handle tenant creation if tenantId is provided + let tenantId = appRequest.tenantId || null; + + // If tenantId is provided, ensure tenant exists (create if needed) + if (tenantId) { + let tenant = db.getTenant(tenantId); + if (!tenant) { + // Create new tenant + tenant = db.addTenant({ + id: tenantId, + displayName: appRequest.tenantName || `Organization ${tenantId}`, + createdBy: accountId + }); + } + tenantId = tenant.id; + } + + // Create app + const newApp = db.addApp({ + name: appRequest.name.trim(), + accountId: accountId, + tenantId: tenantId + }); + + // Add owner as collaborator + db.addCollaborator({ + email: account.email, + accountId: accountId, + appId: newApp.id, + permission: 'Owner' + }); + + // Create default deployments if not manually provisioned + let deploymentNames = []; + if (!appRequest.manuallyProvisionDeployments) { + const defaultDeployments = ['Production', 'Staging']; + defaultDeployments.forEach(deploymentName => { + const deployment = db.addDeployment({ + appId: newApp.id, + name: deploymentName + }); + deploymentNames.push(deployment.name); + }); + } + + // Set Location header + res.setHeader('Location', `/apps/${newApp.name}`); + + return res.status(201).json({ + app: { + id: newApp.id, + name: newApp.name, + displayName: newApp.name, + deployments: deploymentNames, + createdTime: newApp.createdTime, + tenantId: newApp.tenantId || null + } + }); +} + +// GET /apps/:appName - Get a specific app +function getApp(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + + const app = db.getAppByName(accountId, appName, tenantId); + if (!app) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Check access + if (!hasAccess(accountId, app.id)) { + return res.status(403).json({ error: 'You do not have access to this app' }); + } + + const appDeployments = db.getDeployments(app.id); + const deploymentNames = appDeployments.map(d => d.name); + + return res.status(200).json({ + app: { + id: app.id, + name: app.name, + displayName: app.name, + deployments: deploymentNames, + createdTime: app.createdTime, + tenantId: app.tenantId || null + } + }); +} + +// DELETE /apps/:appName - Delete an app +function deleteApp(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + + const app = db.getAppByName(accountId, appName, tenantId); + if (!app) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Delete app (and associated deployments/collaborators) + db.deleteApp(accountId, app.id); + + return res.status(201).send('App deleted successfully'); +} + +// PATCH /apps/:appName - Update an app (change name) +function patchApp(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + const appRequest = req.body; + + // Get existing app + const existingApp = db.getAppByName(accountId, appName, tenantId); + if (!existingApp) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Check if user is owner + if (!isOwner(accountId, existingApp.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // If name is being changed + if (appRequest.name !== undefined && appRequest.name !== existingApp.name) { + // Validate new name + if (!isValidAppName(appRequest.name)) { + return res.status(400).json([{ field: 'name', message: 'App name is required and must be a valid string' }]); + } + + // Check for duplicate name + const userApps = db.getApps(accountId); + const duplicateApp = userApps.find(app => app.name === appRequest.name && app.id !== existingApp.id); + if (duplicateApp) { + return res.status(409).json({ error: `An app named '${appRequest.name}' already exists.` }); + } + + // Update app name + db.updateApp(accountId, existingApp.id, { name: appRequest.name.trim() }); + } + + // Get deployments for response + const appDeployments = db.getDeployments(existingApp.id); + const deploymentNames = appDeployments.map(d => d.name); + + // Get updated app + const updatedApp = db.getAppByName(accountId, appRequest.name || appName, tenantId); + + return res.status(200).json({ + app: { + id: updatedApp.id, + name: updatedApp.name, + displayName: updatedApp.name, + deployments: deploymentNames, + createdTime: updatedApp.createdTime, + tenantId: updatedApp.tenantId || null + } + }); +} + +module.exports = { + getApps, + postApps, + getApp, + deleteApp, + patchApp +}; + diff --git a/e2e-mocks/mock-callback/routes/authentication.js b/e2e-mocks/mock-callback/routes/authentication.js new file mode 100644 index 0000000..74342ca --- /dev/null +++ b/e2e-mocks/mock-callback/routes/authentication.js @@ -0,0 +1,68 @@ +const db = require('../mock_data'); + +// Helper: Extract user ID from Authorization header +function getUserId(req) { + // Handle Authorization header first + const auth = req.headers.authorization || ''; + const token = auth.replace('Bearer ', '').trim(); + + if (token) { + // Handle access key authentication (cli- prefix) + if (token.startsWith('cli-')) { + const accessKeyName = token.replace('cli-', ''); + try { + const account = db.getUserFromAccessKey(accessKeyName); + return account ? account.id : null; + } catch (error) { + return null; + } + } + + // Regular Bearer token (treat as userId) + return token || null; + } + + // If no Authorization header, check userId header (alternative auth method) + // Express lowercases headers, but MockServer might forward as-is + const userId = Array.isArray(req.headers.userid) ? req.headers.userid[0] : + Array.isArray(req.headers.userId) ? req.headers.userId[0] : + req.headers.userid || req.headers.userId; + + if (userId) { + const account = db.getAccount(userId); + return account ? userId : null; + } + + return null; +} + +// GET /authenticated - Check if user is authenticated +function getAuthenticated(req, res) { + // Debug: log headers (commented out for production) + // console.log('Headers:', JSON.stringify(req.headers, null, 2)); + + const accountId = getUserId(req); + + if (!accountId) { + return res.status(401).send('Authentication failed'); + } + + const account = db.getAccount(accountId); + if (!account) { + return res.status(401).send('User not found'); + } + + return res.status(200).json({ + authenticated: true, + user: { + id: account.id, + email: account.email, + name: account.name + } + }); +} + +module.exports = { + getAuthenticated +}; + diff --git a/e2e-mocks/mock-callback/routes/collaborators.js b/e2e-mocks/mock-callback/routes/collaborators.js new file mode 100644 index 0000000..f0e3e75 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/collaborators.js @@ -0,0 +1,257 @@ +const db = require('../mock_data'); +const appsRoutes = require('./apps'); +const { getUserId } = require('../utils/auth'); + +// Helper: Extract tenant ID from header +function getTenantId(req) { + const tenant = req.headers.tenant; + return Array.isArray(tenant) ? tenant[0] : tenant || null; +} + +// Helper: Check if user is owner +function isOwner(accountId, appId) { + const collab = db.getCollaboratorForApp(accountId, appId); + return collab && collab.permission === 'Owner'; +} + +// Helper: Check if user has collaborator access +function hasAccess(accountId, appId) { + return db.getCollaboratorForApp(accountId, appId) !== undefined; +} + +// Helper: Validate email parameter (prototype pollution check) +function isPrototypePollutionKey(key) { + if (!key || typeof key !== 'string') return false; + const dangerousKeys = ['__proto__', 'constructor', 'prototype']; + return dangerousKeys.includes(key.toLowerCase()); +} + +// GET /apps/:appName/collaborators - Get all collaborators for an app +function getCollaborators(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + + // Check if app exists (regardless of user access) + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app (reusing logic from apps routes) + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get collaborators map + const collaboratorsMap = db.getCollaboratorsMap(accountId, app.id); + + return res.status(200).json({ collaborators: collaboratorsMap }); +} + +// POST /apps/:appName/collaborators/:email - Add a collaborator to an app +function postCollaborator(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const email = req.params.email; + const tenantId = getTenantId(req); + + // Validate email parameter + if (isPrototypePollutionKey(email)) { + return res.status(400).send('Invalid email parameter'); + } + + // Check if app exists (regardless of user access) + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app (reusing logic from apps routes) + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + try { + db.addCollaboratorToApp(accountId, app.id, email); + return res.status(201).send(); + } catch (error) { + if (error.message === 'The specified e-mail address doesn\'t represent a registered user') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'The given account is already a collaborator for this app.') { + return res.status(409).json({ error: error.message }); + } + return res.status(500).json({ error: error.message }); + } +} + +// DELETE /apps/:appName/collaborators/:email - Remove a collaborator from an app +function deleteCollaborator(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const email = req.params.email; + const tenantId = getTenantId(req); + + // Validate email parameter + if (isPrototypePollutionKey(email)) { + return res.status(400).send('Invalid email parameter'); + } + + // Check if app exists (regardless of user access) + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app (reusing logic from apps routes) + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Check if user is attempting to remove themselves + const currentCollab = db.getCollaboratorForApp(accountId, app.id); + const isRemovingSelf = currentCollab && + (currentCollab.email === email || currentCollab.email.toLowerCase() === email.toLowerCase()); + + // Permission check: Owner can remove anyone, Collaborator can only remove themselves + if (isRemovingSelf) { + // Collaborator can remove themselves + if (!hasAccess(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + } else { + // Owner required to remove others + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + } + + try { + db.removeCollaboratorFromApp(accountId, app.id, email); + return res.status(201).send('Collaborator removed successfully'); + } catch (error) { + if (error.message === 'The given email is not a collaborator for this app.') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Cannot remove the owner of the app from collaborator list.') { + return res.status(409).json({ error: error.message }); + } + return res.status(500).json({ error: error.message }); + } +} + +// PATCH /apps/:appName/collaborators/:email - Change collaborator role +function patchCollaborator(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const email = req.params.email; + const tenantId = getTenantId(req); + const role = req.body.role || 'Collaborator'; // Default to Collaborator if not specified + + // Validate email parameter + if (isPrototypePollutionKey(email)) { + return res.status(400).send('Invalid email parameter'); + } + + // Validate role + if (role !== 'Owner' && role !== 'Collaborator') { + return res.status(400).json({ error: 'Invalid role. Must be "Owner" or "Collaborator"' }); + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if collaborator exists and get their info + const collaboratorsMap = db.getCollaboratorsMap(accountId, app.id); + const collaboratorBeingModified = collaboratorsMap[email] || + Object.values(collaboratorsMap).find(c => c.email && c.email.toLowerCase() === email.toLowerCase()); + + // Find by email in collaborators array + const collabInDb = db.getCollaborators(app.id).find(c => + c.email === email || c.email.toLowerCase() === email.toLowerCase() + ); + + if (!collabInDb) { + return res.status(404).json({ error: 'The given email is not a collaborator for this app.' }); + } + + // Prevent ONLY the app creator from changing their permission from Owner to Collaborator + const appCreatorAccountId = app.accountId; + const collaboratorAccountId = collabInDb.accountId; + + if (collaboratorAccountId === appCreatorAccountId && role === 'Collaborator') { + return res.status(409).json({ error: 'The app creator cannot change their permission from Owner to Collaborator.' }); + } + + try { + // Update collaborator role + db.updateCollaboratorRole(accountId, app.id, email, role); + return res.status(200).send(); + } catch (error) { + if (error.message === 'The given email is not a collaborator for this app.') { + return res.status(404).json({ error: error.message }); + } + return res.status(500).json({ error: error.message }); + } +} + +module.exports = { + getCollaborators, + postCollaborator, + deleteCollaborator, + patchCollaborator +}; + diff --git a/e2e-mocks/mock-callback/routes/deployments.js b/e2e-mocks/mock-callback/routes/deployments.js new file mode 100644 index 0000000..8cde265 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/deployments.js @@ -0,0 +1,341 @@ +const db = require('../mock_data'); +const appsRoutes = require('./apps'); +const { getUserId } = require('../utils/auth'); + +// Helper: Extract tenant ID from header +function getTenantId(req) { + const tenant = req.headers.tenant; + return Array.isArray(tenant) ? tenant[0] : tenant || null; +} + +// Helper: Check if user is owner +function isOwner(accountId, appId) { + const collab = db.getCollaboratorForApp(accountId, appId); + return collab && collab.permission === 'Owner'; +} + +// Helper: Check if user has collaborator access +function hasAccess(accountId, appId) { + return db.getCollaboratorForApp(accountId, appId) !== undefined; +} + +// Helper: Validate deployment name (matches validation.ts logic) +function isValidNameField(name) { + if (typeof name !== 'string') return false; + // Name must be 1-1000 characters, no URL special chars, no control chars, no colon + if (name.length === 0 || name.length > 1000) return false; + if (/[\\\/\?]/.test(name)) return false; // No URL special chars + if (/[\x00-\x1F]/.test(name)) return false; // No control chars + if (/[\x7F-\x9F]/.test(name)) return false; // No extended control chars + if (/:/.test(name)) return false; // No colon (used as delimiter) + return true; +} + +// Helper: Validate deployment key (if provided) +function isValidKeyField(key) { + if (!key) return true; // Key is optional + if (typeof key !== 'string') return false; + return key.length > 0 && key.length <= 1000; +} + +// Helper: Generate secure key (simplified version) +function generateSecureKey(prefix) { + const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-'; + let result = prefix + '-'; + for (let i = 0; i < 43; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +// Helper: Find deployment by name +function findDeploymentByName(deployments, name) { + return deployments.find(d => d.name === name); +} + +// Helper: Check for duplicate deployment name +function isDuplicateDeployment(deployments, name) { + return deployments.some(d => d.name === name); +} + +// GET /apps/:appName/deployments - Get all deployments for an app +function getDeployments(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get deployments for the app + const deployments = db.getDeployments(app.id); + + // Format deployments (remove internal fields, add package/packageHistory) + const restDeployments = deployments.map(deployment => ({ + name: deployment.name, + key: deployment.key, + package: deployment.package || null, + packageHistory: deployment.packageHistory || [] + })); + + // Sort deployments by name + restDeployments.sort((first, second) => { + return first.name.localeCompare(second.name); + }); + + return res.status(200).json({ deployments: restDeployments }); +} + +// POST /apps/:appName/deployments - Create a new deployment +function postDeployment(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const tenantId = getTenantId(req); + const deploymentRequest = req.body; + + // Validation: name is required + if (!deploymentRequest || !deploymentRequest.name) { + return res.status(400).json([{ field: 'name', message: 'Field is required' }]); + } + + // Validate name field + if (!isValidNameField(deploymentRequest.name)) { + return res.status(400).json([{ field: 'name', message: 'Field is invalid' }]); + } + + // Validate key field (if provided) + if (deploymentRequest.key && !isValidKeyField(deploymentRequest.key)) { + return res.status(400).json([{ field: 'key', message: 'Field is invalid' }]); + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check for duplicate deployment name + const existingDeployments = db.getDeployments(app.id); + if (isDuplicateDeployment(existingDeployments, deploymentRequest.name)) { + return res.status(409).json({ error: `A deployment named '${deploymentRequest.name}' already exists.` }); + } + + // Create deployment + const deploymentData = { + name: deploymentRequest.name.trim(), + appId: app.id, + key: deploymentRequest.key || generateSecureKey(accountId) + }; + + const newDeployment = db.addDeployment(deploymentData); + + // Format response (remove internal fields, add package/packageHistory) + const restDeployment = { + name: newDeployment.name, + key: newDeployment.key, + package: null, + packageHistory: [] + }; + + res.setHeader('Location', `/apps/${appName}/deployments/${restDeployment.name}`); + return res.status(201).json({ deployment: restDeployment }); +} + +// GET /apps/:appName/deployments/:deploymentName - Get a specific deployment +function getDeployment(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const deploymentName = req.params.deploymentName; + const tenantId = getTenantId(req); + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get deployments + const deployments = db.getDeployments(app.id); + const deployment = findDeploymentByName(deployments, deploymentName); + + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Format response (add package/packageHistory) + const restDeployment = { + name: deployment.name, + key: deployment.key, + package: deployment.package || null, + packageHistory: deployment.packageHistory || [] + }; + + return res.status(200).json({ deployment: restDeployment }); +} + +// DELETE /apps/:appName/deployments/:deploymentName - Delete a deployment +function deleteDeployment(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const deploymentName = req.params.deploymentName; + const tenantId = getTenantId(req); + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Get deployments + const deployments = db.getDeployments(app.id); + const deployment = findDeploymentByName(deployments, deploymentName); + + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Delete deployment + db.deleteDeployment(app.id, deployment.id); + + return res.status(201).send('Deployment deleted successfully'); +} + +// PATCH /apps/:appName/deployments/:deploymentName - Update a deployment (change name) +function patchDeployment(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const appName = req.params.appName; + const deploymentName = req.params.deploymentName; + const tenantId = getTenantId(req); + const deploymentRequest = req.body; + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Get deployments + const deployments = db.getDeployments(app.id); + const existingDeployment = findDeploymentByName(deployments, deploymentName); + + if (!existingDeployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // If name is being changed + if (deploymentRequest.name !== undefined && deploymentRequest.name !== existingDeployment.name) { + // Validate new name + if (!isValidNameField(deploymentRequest.name)) { + return res.status(400).json([{ field: 'name', message: 'Field is invalid' }]); + } + + // Check for duplicate deployment name + if (isDuplicateDeployment(deployments, deploymentRequest.name)) { + return res.status(409).json({ error: `A deployment named '${deploymentRequest.name}' already exists.` }); + } + + // Update deployment name + db.updateDeployment(app.id, existingDeployment.id, { name: deploymentRequest.name.trim() }); + } + + // Get updated deployment + const updatedDeployments = db.getDeployments(app.id); + const updatedDeployment = findDeploymentByName(updatedDeployments, deploymentRequest.name || deploymentName); + + // Format response + const restDeployment = { + name: updatedDeployment.name, + key: updatedDeployment.key, + package: updatedDeployment.package || null, + packageHistory: updatedDeployment.packageHistory || [] + }; + + return res.status(200).json({ deployment: restDeployment }); +} + +module.exports = { + getDeployments, + postDeployment, + getDeployment, + deleteDeployment, + patchDeployment +}; + diff --git a/e2e-mocks/mock-callback/routes/releases.js b/e2e-mocks/mock-callback/routes/releases.js new file mode 100644 index 0000000..6ced33f --- /dev/null +++ b/e2e-mocks/mock-callback/routes/releases.js @@ -0,0 +1,565 @@ +const db = require('../mock_data'); +const deploymentsRoutes = require('./deployments'); +const fileStorage = require('../utils/file-storage'); +const { getUserId } = require('../utils/auth'); + +// Helper: Extract tenant ID from header +function getTenantId(req) { + const tenant = req.headers.tenant; + return Array.isArray(tenant) ? tenant[0] : tenant || null; +} + +// Helper: Check if user is owner +function isOwner(accountId, appId) { + const collab = db.getCollaboratorForApp(accountId, appId); + return collab && collab.permission === 'Owner'; +} + +// Helper: Check if user has collaborator access +function hasAccess(accountId, appId) { + return db.getCollaboratorForApp(accountId, appId) !== undefined; +} + +// Helper: Validate app version (semver or range) +function isValidAppVersionRange(version) { + if (!version || typeof version !== 'string') return false; + // Simple check - should be valid semver or range + // For mock, we'll be lenient + return version.trim().length > 0; +} + +// Helper: Validate rollout value (1-100) +function isValidRollout(rollout) { + if (rollout === null || rollout === undefined) return true; // Optional + if (typeof rollout !== 'number') return false; + return rollout >= 1 && rollout <= 100; +} + +// Helper: Validate boolean +function isValidBoolean(val) { + if (val === null || val === undefined) return true; // Optional + return typeof val === 'boolean'; +} + +// Helper: Validate package info +function validatePackageInfo(packageInfo, allOptional = false) { + const errors = []; + + if (!allOptional && !packageInfo.appVersion) { + errors.push({ field: 'appVersion', message: 'Field is required' }); + } + + if (packageInfo.appVersion && !isValidAppVersionRange(packageInfo.appVersion)) { + errors.push({ field: 'appVersion', message: 'Field is invalid' }); + } + + if (packageInfo.rollout !== undefined && !isValidRollout(packageInfo.rollout)) { + errors.push({ field: 'rollout', message: 'Field is invalid' }); + } + + if (packageInfo.isDisabled !== undefined && !isValidBoolean(packageInfo.isDisabled)) { + errors.push({ field: 'isDisabled', message: 'Field is invalid' }); + } + + if (packageInfo.isMandatory !== undefined && !isValidBoolean(packageInfo.isMandatory)) { + errors.push({ field: 'isMandatory', message: 'Field is invalid' }); + } + + return errors; +} + +// Helper: Check if rollout is unfinished (< 100) +function isUnfinishedRollout(rollout) { + return rollout !== null && rollout !== undefined && rollout < 100; +} + +// Helper: Generate mock blob URL +function generateBlobUrl() { + return `https://mock-blob-storage.example.com/packages/${Date.now()}-${Math.random().toString(36).substr(2, 9)}.zip`; +} + +// Helper: Generate mock package hash +function generatePackageHash() { + return Math.random().toString(36).substr(2, 16) + Math.random().toString(36).substr(2, 16); +} + +// GET /apps/:appName/deployments/:deploymentName/history - Get package history +function getHistory(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let appName = req.params.appName; + const deploymentName = req.params.deploymentName; + let tenantId = getTenantId(req); + + // Parse appName if it contains tenant/appName format (e.g., "testOrg/testApp") + if (appName.includes('/')) { + const parts = appName.split('/'); + if (parts.length === 2 && !tenantId) { + tenantId = parts[0]; + appName = parts[1]; + } + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get deployments for app + const appDeployments = db.getDeployments(app.id); + const deployment = appDeployments.find(d => d.name === deploymentName); + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Get package history + const history = db.getPackageHistory(deployment.id); + + return res.status(200).json({ history: history }); +} + +// POST /apps/:appName/deployments/:deploymentName/release - Create a new release +async function postRelease(req, res) { + // Handle release request + + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let appName = req.params.appName; + const deploymentName = req.params.deploymentName; + let tenantId = getTenantId(req); + + // minimal parsing + + // Parse appName if it contains tenant/appName format (e.g., "testOrg/testApp") + if (appName.includes('/')) { + const parts = appName.split('/'); + if (parts.length === 2 && !tenantId) { + tenantId = parts[0]; + appName = parts[1]; + // parsed + } + } + + // Handle both multipart file upload and JSON-only requests + let packageInfo = {}; + let uploadedFile = null; + let fileMetadata = null; + + // Check if file was uploaded (multipart/form-data) + if (req.file) { + // uploaded file + uploadedFile = req.file; + + // Parse packageInfo from form field (if provided) + try { + if (req.body.packageInfo) { + packageInfo = typeof req.body.packageInfo === 'string' + ? JSON.parse(req.body.packageInfo) + : req.body.packageInfo; + // parsed packageInfo + } else if (req.body.appVersion || req.body.description) { + // Support flat form fields + packageInfo = { + appVersion: req.body.appVersion, + description: req.body.description, + isMandatory: req.body.isMandatory === 'true' || req.body.isMandatory === true, + isDisabled: req.body.isDisabled === 'true' || req.body.isDisabled === true, + rollout: req.body.rollout ? parseInt(req.body.rollout) : undefined + }; + // parsed flat fields + } else { + // no packageInfo + } + + // Save the uploaded file + try { + // saving file + fileMetadata = await fileStorage.saveFile(uploadedFile.buffer, uploadedFile.originalname); + // saved file + } catch (fileError) { + console.error(' ❌ Error saving file:', fileError); + return res.status(500).json({ error: 'Failed to save uploaded file' }); + } + } catch (parseError) { + console.error('Invalid packageInfo format:', parseError.message); + return res.status(400).json({ error: 'Invalid packageInfo format' }); + } + } else { + // JSON-only request (backward compatible) + packageInfo = req.body.packageInfo || req.body || {}; + // json body + } + + // Validate package info (appVersion is required) + const validationErrors = validatePackageInfo(packageInfo, false); + if (validationErrors.length) { + return res.status(400).json(validationErrors); + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + // app details ok + + // If app exists but user doesn't have access, return 403 + if (!app) { + // No access to app + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get deployments for app + const appDeployments = db.getDeployments(app.id); + const deployment = appDeployments.find(d => d.name === deploymentName); + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Check if there's an unfinished rollout + const currentPackage = deployment.package; + if (currentPackage && isUnfinishedRollout(currentPackage.rollout) && !currentPackage.isDisabled) { + return res.status(409).json({ + error: 'Please update the previous release to 100% rollout before releasing a new package.' + }); + } + + // Get package history to check for duplicate + const history = db.getPackageHistory(deployment.id); + // history size not logged + + // Get package hash - use actual hash if file uploaded, otherwise generate mock + let packageHash; + let packageSize; + let blobUrl; + let fileName = null; + + if (fileMetadata) { + // Real file uploaded - use actual metadata + packageHash = fileMetadata.hash; + packageSize = fileMetadata.size; + fileName = fileMetadata.fileName; + // Generate download URL via MockServer gateway + const baseUrl = process.env.MOCK_SERVER_URL || 'http://localhost:1080'; + blobUrl = `${baseUrl}${fileStorage.getDownloadUrl(fileName)}`; + + // Check for duplicate package hash with same app version + const lastPackageWithSameVersion = history + .slice() + .reverse() + .find(pkg => pkg.appVersion === packageInfo.appVersion); + + if (lastPackageWithSameVersion && lastPackageWithSameVersion.packageHash === packageHash) { + // Delete the duplicate file we just saved + fileStorage.deleteFile(fileName); + return res.status(409).json({ + error: 'A package with the same content hash already exists for this app version.' + }); + } + } else { + // No file uploaded - use mock values (backward compatible) + packageHash = generatePackageHash(); + packageSize = packageInfo.size || 1024; + blobUrl = generateBlobUrl(); + } + + // Get account for releasedBy + const account = db.getAccount(accountId); + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + // Create package data + const appPackage = { + appVersion: packageInfo.appVersion, + blobUrl: blobUrl, + description: packageInfo.description || null, + isDisabled: packageInfo.isDisabled !== undefined ? packageInfo.isDisabled : false, + isMandatory: packageInfo.isMandatory !== undefined ? packageInfo.isMandatory : false, + packageHash: packageHash, + rollout: packageInfo.rollout !== undefined ? packageInfo.rollout : null, + size: packageSize, + uploadTime: Date.now(), + releasedBy: account.email, + releaseMethod: 'Upload', + manifestBlobUrl: null, + fileName: fileName, + isBundlePatchingEnabled: false, + }; + + // Commit package + let committedPackage; + try { + committedPackage = db.commitPackage(deployment.id, appPackage); + } catch (error) { + console.error(`Error committing package:`, error); + return res.status(500).json({ error: error.message }); + } + + // Format response + const restPackage = { + appVersion: committedPackage.appVersion, + blobUrl: committedPackage.blobUrl, + description: committedPackage.description, + isDisabled: committedPackage.isDisabled, + isMandatory: committedPackage.isMandatory, + label: committedPackage.label, + packageHash: committedPackage.packageHash, + rollout: committedPackage.rollout, + size: committedPackage.size, + uploadTime: committedPackage.uploadTime, + releasedBy: committedPackage.releasedBy, + releaseMethod: committedPackage.releaseMethod, + isBundlePatchingEnabled: committedPackage.isBundlePatchingEnabled, + }; + + res.setHeader('Location', `/apps/${appName}/deployments/${deploymentName}`); + return res.status(201).json({ package: restPackage }); +} + +// PATCH /apps/:appName/deployments/:deploymentName/release - Update release properties +function patchRelease(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let appName = req.params.appName; + const deploymentName = req.params.deploymentName; + let tenantId = getTenantId(req); + + // Parse appName if it contains tenant/appName format (e.g., "testOrg/testApp") + if (appName.includes('/')) { + const parts = appName.split('/'); + if (parts.length === 2 && !tenantId) { + tenantId = parts[0]; + appName = parts[1]; + } + } + const packageInfo = req.body.packageInfo || req.body || {}; + + // Validate package info (all fields optional for update) + const validationErrors = validatePackageInfo(packageInfo, true); + if (validationErrors.length) { + return res.status(400).json(validationErrors); + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Collaborator permissions on the app!' }); + } + + // Get deployments for app + const appDeployments = db.getDeployments(app.id); + const deployment = appDeployments.find(d => d.name === deploymentName); + + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Get package history + let packageHistory = db.getPackageHistory(deployment.id); + + if (!packageHistory || packageHistory.length === 0) { + return res.status(404).json({ error: 'Deployment has no releases.' }); + } + + // Determine which package to update (by label or latest) + let packageToUpdate; + if (packageInfo.label) { + // Find package by label (search from end) + for (let i = packageHistory.length - 1; i >= 0; i--) { + if (packageHistory[i].label === packageInfo.label) { + packageToUpdate = packageHistory[i]; + break; + } + } + if (!packageToUpdate) { + return res.status(404).json({ error: 'Release not found for given label.' }); + } + } else { + // Use latest package + packageToUpdate = packageHistory[packageHistory.length - 1]; + } + + let updateRelease = false; + const updates = {}; + + // Update isDisabled + if (packageInfo.isDisabled !== undefined && packageToUpdate.isDisabled !== packageInfo.isDisabled) { + updates.isDisabled = packageInfo.isDisabled; + updateRelease = true; + } + + // Update isMandatory + if (packageInfo.isMandatory !== undefined && packageToUpdate.isMandatory !== packageInfo.isMandatory) { + updates.isMandatory = packageInfo.isMandatory; + updateRelease = true; + } + + // Update description + if (packageInfo.description !== undefined && packageToUpdate.description !== packageInfo.description) { + updates.description = packageInfo.description; + updateRelease = true; + } + + // Update appVersion + if (packageInfo.appVersion && packageToUpdate.appVersion !== packageInfo.appVersion) { + updates.appVersion = packageInfo.appVersion; + updateRelease = true; + } + + // Update rollout + if (packageInfo.rollout !== undefined) { + const newRolloutValue = packageInfo.rollout; + + // Validate rollout value + if (!isValidRollout(newRolloutValue)) { + return res.status(400).json([{ field: 'rollout', message: 'Field is invalid' }]); + } + + // Check rollout update rules + const isFinished = !isUnfinishedRollout(packageToUpdate.rollout); + if (isFinished && !updateRelease) { + return res.status(409).json({ error: 'Cannot update rollout value for a completed rollout release.' }); + } + + if (packageToUpdate.rollout !== null && packageToUpdate.rollout !== undefined && + packageToUpdate.rollout > newRolloutValue && newRolloutValue !== 100) { + return res.status(409).json({ + error: `Rollout value must be greater than "${packageToUpdate.rollout}", the existing value.` + }); + } + + updates.rollout = newRolloutValue === 100 ? 100 : newRolloutValue; + updateRelease = true; + } + + // If no updates needed, return 204 + if (!updateRelease) { + return res.status(204).send(); + } + + // Update the package in history + Object.assign(packageToUpdate, updates); + + // Update deployment's current package if it's the one being updated + if (deployment.package && deployment.package.label === packageToUpdate.label) { + Object.assign(deployment.package, updates); + } + + // Update package history in database + try { + db.updatePackageHistory(deployment.id, packageHistory); + } catch (error) { + return res.status(500).json({ error: error.message }); + } + + // Format response + const restPackage = { + appVersion: packageToUpdate.appVersion, + blobUrl: packageToUpdate.blobUrl, + description: packageToUpdate.description, + isDisabled: packageToUpdate.isDisabled, + isMandatory: packageToUpdate.isMandatory, + label: packageToUpdate.label, + packageHash: packageToUpdate.packageHash, + rollout: packageToUpdate.rollout, + size: packageToUpdate.size, + uploadTime: packageToUpdate.uploadTime, + releasedBy: packageToUpdate.releasedBy, + releaseMethod: packageToUpdate.releaseMethod || 'Upload' + }; + + return res.status(200).json({ package: restPackage }); +} + +// DELETE /apps/:appName/deployments/:deploymentName/history - Clear package history +function deleteHistory(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + let appName = req.params.appName; + const deploymentName = req.params.deploymentName; + let tenantId = getTenantId(req); + + // Parse appName if it contains tenant/appName format (e.g., "testOrg/testApp") + if (appName.includes('/')) { + const parts = appName.split('/'); + if (parts.length === 2 && !tenantId) { + tenantId = parts[0]; + appName = parts[1]; + } + } + + // Check if app exists + const appExists = db.appExists(appName, tenantId); + if (!appExists) { + return res.status(404).json({ error: `App "${appName}" does not exist.` }); + } + + // Get app + const app = db.getAppByName(accountId, appName, tenantId); + + // If app exists but user doesn't have access, return 403 + if (!app) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Check if user is owner + if (!isOwner(accountId, app.id)) { + return res.status(403).json({ error: 'This action requires Owner permissions on the app!' }); + } + + // Get deployments for app + const appDeployments = db.getDeployments(app.id); + const deployment = appDeployments.find(d => d.name === deploymentName); + if (!deployment) { + return res.status(404).json({ error: `Deployment "${deploymentName}" does not exist.` }); + } + + // Clear package history + try { + db.clearPackageHistory(deployment.id); + return res.status(201).send('Deployment History deleted successfully'); + } catch (error) { + return res.status(500).json({ error: error.message }); + } +} + +module.exports = { + getHistory, + postRelease, + patchRelease, + deleteHistory +}; + diff --git a/e2e-mocks/mock-callback/routes/tenants.js b/e2e-mocks/mock-callback/routes/tenants.js new file mode 100644 index 0000000..7dabe89 --- /dev/null +++ b/e2e-mocks/mock-callback/routes/tenants.js @@ -0,0 +1,53 @@ +const db = require('../mock_data'); +const { json, html, fieldErr, sendError } = require('../utils/response'); + +const { getUserId } = require('../utils/auth'); + +// GET /tenants - Get all tenants/organizations for account +function getTenants(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const account = db.getAccount(accountId); + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + const organisations = db.getTenants(accountId); + + return res.status(200).json({ organisations }); +} + +// DELETE /tenants/:tenantId - Delete a tenant/organization +function deleteTenant(req, res) { + const accountId = getUserId(req); + if (!accountId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const tenantId = req.params.tenantId; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID is required' }); + } + + try { + db.removeTenant(accountId, tenantId); + return res.status(201).send('Org deleted successfully'); + } catch (error) { + if (error.message === 'Specified Organisation does not exist.') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'User does not have admin permissions for the specified tenant.') { + return res.status(403).json({ error: error.message }); + } + return res.status(500).json({ error: error.message }); + } +} + +module.exports = { + getTenants, + deleteTenant +}; + diff --git a/e2e-mocks/mock-callback/server.js b/e2e-mocks/mock-callback/server.js new file mode 100644 index 0000000..45ca688 --- /dev/null +++ b/e2e-mocks/mock-callback/server.js @@ -0,0 +1,201 @@ +const express = require('express'); +const bodyParser = require('body-parser'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const accountRoutes = require('./routes/account'); +const appsRoutes = require('./routes/apps'); +const tenantsRoutes = require('./routes/tenants'); +const collaboratorsRoutes = require('./routes/collaborators'); +const deploymentsRoutes = require('./routes/deployments'); +const releasesRoutes = require('./routes/releases'); +const accessKeysRoutes = require('./routes/accesskeys'); +const authenticationRoutes = require('./routes/authentication'); +const acquisitionRoutes = require('./routes/acquisition'); +const fileStorage = require('./utils/file-storage'); +const db = require('./mock_data'); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Configure multer for file uploads (memory storage for now, we'll save manually) +// File size limit is configurable via UPLOAD_SIZE_LIMIT_MB environment variable +// - Default: 10240 MB (10GB) - effectively unlimited for most use cases +// - Set to 0 for truly unlimited (uses 1TB as max) +// - Set to any positive number for a specific limit in MB +const UPLOAD_SIZE_LIMIT_MB_ENV = process.env.UPLOAD_SIZE_LIMIT_MB; +let UPLOAD_SIZE_LIMIT_MB; + +if (UPLOAD_SIZE_LIMIT_MB_ENV === undefined || UPLOAD_SIZE_LIMIT_MB_ENV === '') { + // Default: 10GB (effectively unlimited for most use cases) + UPLOAD_SIZE_LIMIT_MB = 10240; +} else if (parseInt(UPLOAD_SIZE_LIMIT_MB_ENV) === 0) { + // 0 means unlimited - use 1TB as practical maximum + UPLOAD_SIZE_LIMIT_MB = 1024 * 1024; // 1TB +} else { + UPLOAD_SIZE_LIMIT_MB = parseInt(UPLOAD_SIZE_LIMIT_MB_ENV); +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: UPLOAD_SIZE_LIMIT_MB * 1024 * 1024 + } +}); + +app.use(bodyParser.json()); + +// Request logging middleware - controllable via LOG_LEVEL +app.use((req, res, next) => { + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${req.method} ${req.originalUrl || req.url}`); + } + next(); +}); + +app.get("/ping", (req, res) => { + res.status(200).json({ message: 'pong' }); +}); + +// Authentication routes +app.get('/authenticated', authenticationRoutes.getAuthenticated); + +// Acquisition routes (public, no auth) +app.get('/updateCheck', acquisitionRoutes.updateCheck); +app.get('/v0.1/public/codepush/update_check', acquisitionRoutes.updateCheck); +app.post('/reportStatus/deploy', acquisitionRoutes.reportStatusDeploy); +app.post('/v0.1/public/codepush/report_status/deploy', acquisitionRoutes.reportStatusDeploy); +app.post('/reportStatus/download', acquisitionRoutes.reportStatusDownload); +app.post('/v0.1/public/codepush/report_status/download', acquisitionRoutes.reportStatusDownload); +app.get('/healthcheck', acquisitionRoutes.healthcheck); + +// Account routes +app.get('/account', accountRoutes.getAccount); +app.post('/account', accountRoutes.postAccount); + +// Apps routes +app.get('/apps', appsRoutes.getApps); +app.post('/apps', appsRoutes.postApps); +app.get('/apps/:appName', appsRoutes.getApp); +app.patch('/apps/:appName', appsRoutes.patchApp); +app.delete('/apps/:appName', appsRoutes.deleteApp); + +// Tenants routes +app.get('/tenants', tenantsRoutes.getTenants); +app.delete('/tenants/:tenantId', tenantsRoutes.deleteTenant); + +// Collaborators routes +app.get('/apps/:appName/collaborators', collaboratorsRoutes.getCollaborators); +app.post('/apps/:appName/collaborators/:email', collaboratorsRoutes.postCollaborator); +app.patch('/apps/:appName/collaborators/:email', collaboratorsRoutes.patchCollaborator); +app.delete('/apps/:appName/collaborators/:email', collaboratorsRoutes.deleteCollaborator); + +// Deployments routes +app.get('/apps/:appName/deployments', deploymentsRoutes.getDeployments); +app.post('/apps/:appName/deployments', deploymentsRoutes.postDeployment); +app.get('/apps/:appName/deployments/:deploymentName', deploymentsRoutes.getDeployment); +app.patch('/apps/:appName/deployments/:deploymentName', deploymentsRoutes.patchDeployment); +app.delete('/apps/:appName/deployments/:deploymentName', deploymentsRoutes.deleteDeployment); + +// File serving route - serves uploaded packages +app.get('/packages/:fileName', (req, res) => { + const fileName = req.params.fileName; + const filePath = fileStorage.getFilePath(fileName); + + if (!filePath) { + return res.status(404).json({ error: 'Package file not found' }); + } + + // Set appropriate headers for file download + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + + // Stream the file + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(res); + + fileStream.on('error', (err) => { + console.error('Error streaming file:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Error serving file' }); + } + }); +}); + +// Error handler for multer file upload errors +function handleMulterError(err, req, res, next) { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + const limitText = UPLOAD_SIZE_LIMIT_MB >= 1024 + ? `${(UPLOAD_SIZE_LIMIT_MB / 1024).toFixed(1)}GB` + : `${UPLOAD_SIZE_LIMIT_MB}MB`; + return res.status(413).json({ + error: `The uploaded file is larger than the size limit of ${limitText} (${UPLOAD_SIZE_LIMIT_MB}MB).` + }); + } + // Handle other multer errors + return res.status(400).json({ + error: `File upload error: ${err.message}` + }); + } + // Pass non-multer errors to next error handler + next(err); +} + +// Releases routes +// Support both formats: /apps/appName/... and /apps/tenant/appName/... +// Use wildcard * to match appName with slashes (e.g., "testOrg/testApp") +app.get('/apps/*/deployments/:deploymentName/history', (req, res) => { + req.params.appName = req.params[0]; // Express captures wildcard in params[0] + releasesRoutes.getHistory(req, res); +}); +// Handle file upload for release endpoint with error handling +app.post('/apps/*/deployments/:deploymentName/release', + upload.single('package'), + handleMulterError, + (req, res, next) => { + req.params.appName = req.params[0]; // Express captures wildcard in params[0] + releasesRoutes.postRelease(req, res, next); + }); +app.patch('/apps/*/deployments/:deploymentName/release', (req, res) => { + req.params.appName = req.params[0]; + releasesRoutes.patchRelease(req, res); +}); +app.delete('/apps/*/deployments/:deploymentName/history', (req, res) => { + req.params.appName = req.params[0]; + releasesRoutes.deleteHistory(req, res); +}); + + + +// Access Keys routes +app.get('/accessKeys', accessKeysRoutes.getAccessKeys); +app.post('/accessKeys', accessKeysRoutes.postAccessKeys); +app.get('/accessKeys/:accessKeyName', accessKeysRoutes.getAccessKey); +app.patch('/accessKeys/:accessKeyName', accessKeysRoutes.patchAccessKey); +app.delete('/accessKeys/:accessKeyName', accessKeysRoutes.deleteAccessKey); +app.delete('/sessions/:createdBy', accessKeysRoutes.deleteSessions); +app.get('/accountByaccessKeyName', accessKeysRoutes.getAccountByAccessKeyName); + +// (removed test-logging endpoint) + +// Default 404 +app.all('*', (req, res) => { + res.status(404).json({ message: 'Not handled' }); +}); + +// General error handler +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.listen(PORT, () => { + if ((process.env.LOG_LEVEL || '').toLowerCase() === 'debug') { + console.log(`Mock callback service running on port ${PORT}`); + } + db.initializePreconfiguredData(); +}); diff --git a/e2e-mocks/mock-callback/utils/auth.js b/e2e-mocks/mock-callback/utils/auth.js new file mode 100644 index 0000000..8777b35 --- /dev/null +++ b/e2e-mocks/mock-callback/utils/auth.js @@ -0,0 +1,36 @@ +const db = require('../mock_data'); + +/** + * Extract user ID from Authorization header + * Handles both regular Bearer tokens (userId) and CLI access keys (cli-accessKeyName) + */ +function getUserId(req) { + // Handle Authorization header first + const auth = req.headers.authorization || ''; + const token = auth.replace('Bearer ', '').trim(); + + if (token) { + // Handle access key authentication (cli- prefix) + if (token.startsWith('cli-')) { + const accessKeyName = token.replace('cli-', ''); + try { + const account = db.getUserFromAccessKey(accessKeyName); + if (account && account.id) { + return account.id; + } + } catch (error) { + return null; + } + } + + // Regular Bearer token (treat as userId) + return token || null; + } + + return null; +} + +module.exports = { + getUserId +}; + diff --git a/e2e-mocks/mock-callback/utils/file-storage.js b/e2e-mocks/mock-callback/utils/file-storage.js new file mode 100644 index 0000000..9cfae91 --- /dev/null +++ b/e2e-mocks/mock-callback/utils/file-storage.js @@ -0,0 +1,114 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// Storage directory - will be created in the container +const STORAGE_DIR = process.env.STORAGE_DIR || '/tmp/codepush-packages'; + +/** + * Ensure storage directory exists + */ +function ensureStorageDir() { + if (!fs.existsSync(STORAGE_DIR)) { + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + } +} + +/** + * Generate a unique filename for a package + * Format: {timestamp}-{random}.zip + */ +function generateFileName() { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.zip`; +} + +/** + * Save uploaded file to storage + * @param {Buffer|Stream} fileData - The file data to save + * @param {string} originalName - Original filename + * @returns {Promise<{filePath: string, fileName: string, size: number, hash: string}>} + */ +async function saveFile(fileData, originalName = null) { + ensureStorageDir(); + + const fileName = generateFileName(); + const filePath = path.join(STORAGE_DIR, fileName); + + // Handle both Buffer and Stream + if (Buffer.isBuffer(fileData)) { + fs.writeFileSync(filePath, fileData); + } else { + // If it's a stream, write it to file + const writeStream = fs.createWriteStream(filePath); + await new Promise((resolve, reject) => { + fileData.pipe(writeStream); + writeStream.on('finish', resolve); + writeStream.on('error', reject); + }); + } + + // Get file stats + const stats = fs.statSync(filePath); + const size = stats.size; + + // Compute SHA256 hash of the file + const fileBuffer = fs.readFileSync(filePath); + const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + + return { + filePath, + fileName, + size, + hash + }; +} + +/** + * Get file path by filename + * @param {string} fileName - The filename + * @returns {string|null} - Full path if file exists, null otherwise + */ +function getFilePath(fileName) { + const filePath = path.join(STORAGE_DIR, fileName); + if (fs.existsSync(filePath)) { + return filePath; + } + return null; +} + +/** + * Delete a file from storage + * @param {string} fileName - The filename to delete + * @returns {boolean} - True if deleted, false if not found + */ +function deleteFile(fileName) { + const filePath = path.join(STORAGE_DIR, fileName); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + +/** + * Generate a download URL for a file + * This will be used in the blobUrl field + * @param {string} fileName - The filename + * @returns {string} - Download URL + */ +function getDownloadUrl(fileName) { + // Return a path that can be served by our file serving route + return `/packages/${fileName}`; +} + +// Initialize storage directory on module load +ensureStorageDir(); + +module.exports = { + saveFile, + getFilePath, + deleteFile, + getDownloadUrl, + STORAGE_DIR +}; + diff --git a/e2e-mocks/mock-callback/utils/response.js b/e2e-mocks/mock-callback/utils/response.js new file mode 100644 index 0000000..542e3be --- /dev/null +++ b/e2e-mocks/mock-callback/utils/response.js @@ -0,0 +1,33 @@ +// Response helper utilities + +function json(statusCode, body) { + return { + statusCode, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }; +} + +function html(statusCode, msg) { + return { + statusCode, + body: msg, + headers: { 'Content-Type': 'text/html' } + }; +} + +function fieldErr(field, msg) { + return html(400, JSON.stringify([{ field, message: msg }])); +} + +function sendError(res, statusCode, message) { + return res.status(statusCode).json({ error: message }); +} + +module.exports = { + json, + html, + fieldErr, + sendError +}; + diff --git a/e2e-mocks/register-expectations.sh b/e2e-mocks/register-expectations.sh new file mode 100755 index 0000000..fc42a21 --- /dev/null +++ b/e2e-mocks/register-expectations.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +echo "Clearing existing expectations..." +curl -s -X PUT "http://localhost:1080/mockserver/clear" >/dev/null + +echo "Registering expectations..." +for file in expectations/*.json; do + echo " → $file" + curl -s -X PUT "http://localhost:1080/mockserver/expectation" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d @"$file" >/dev/null + echo " ✅ loaded" +done + +echo "Validation:" +curl -s "http://localhost:1080/mockserver/retrieve?type=ACTIVE_EXPECTATIONS" | jq . +echo "✅ Done" diff --git a/pm2/pm2-dev.json b/pm2/pm2-dev.json index 7c84a69..37724f9 100644 --- a/pm2/pm2-dev.json +++ b/pm2/pm2-dev.json @@ -1,7 +1,7 @@ { "apps": [ { - "name": "code-push-server", + "name": "dota-server", "script": "./bin/script/server.js", "instances": "-2", "watch": true, diff --git a/pm2/pm2-load.json b/pm2/pm2-load.json index 23da8f2..02bcf6c 100644 --- a/pm2/pm2-load.json +++ b/pm2/pm2-load.json @@ -1,14 +1,14 @@ { "apps": [ { - "name": "code-push-server", + "name": "dota-server", "script": "./bin/script/server.js", "instances": "-2", "watch": true, "merge_logs": true, "exec_mode": "cluster", "log_date_format": "DD-MM-YYYY HH:mm Z", - "cwd": "/var/www/code-push-server/api", + "cwd": "/var/www/dota-server/api", "env": { "NODE_ENV": "load" } diff --git a/pm2/pm2-prod.json b/pm2/pm2-prod.json index e605ee7..daf84a3 100644 --- a/pm2/pm2-prod.json +++ b/pm2/pm2-prod.json @@ -1,14 +1,14 @@ { "apps": [ { - "name": "code-push-server", + "name": "dota-server", "script": "./bin/script/server.js", "instances": "-2", "watch": true, "merge_logs": true, "exec_mode": "cluster", "log_date_format": "DD-MM-YYYY HH:mm Z", - "cwd": "/var/www/code-push-server/api", + "cwd": "/var/www/dota-server/api", "env": { "NODE_ENV": "prod" } diff --git a/pm2/pm2-uat.json b/pm2/pm2-uat.json new file mode 100644 index 0000000..b8e85fc --- /dev/null +++ b/pm2/pm2-uat.json @@ -0,0 +1,17 @@ +{ + "apps": [ + { + "name": "dota-server", + "script": "./bin/script/server.js", + "instances": "-2", + "watch": true, + "merge_logs": true, + "exec_mode": "cluster", + "log_date_format": "DD-MM-YYYY HH:mm Z", + "cwd": "/var/www/dota-server/api", + "env": { + "NODE_ENV": "uat" + } + } + ] +} \ No newline at end of file