From 550270359274fee652eb4abe93e89cf68c64b51a Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Wed, 23 Sep 2026 12:00:46 +0200 Subject: [PATCH] SP-3006: Allow environment profiles without an API token Resolve the environment variable families as URL and token pairs so a complete pair always wins over a URL-only one, and omit the authorization header entirely when no token is available. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/http/http-client.ts | 9 +- src/core/profile/profile.service.ts | 35 ++++-- src/core/profile/profile.validator.ts | 25 +++- tests/core/http/http-client.spec.ts | 66 ++++++++++ tests/core/profile/profile.service.spec.ts | 119 +++++++++++++++++-- tests/core/profile/profile.validator.spec.ts | 81 +++++++++++++ 6 files changed, 309 insertions(+), 26 deletions(-) create mode 100644 tests/core/http/http-client.spec.ts create mode 100644 tests/core/profile/profile.validator.spec.ts diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index a66b62a2..23e4c86a 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -246,10 +246,13 @@ export class HttpClient { } private buildAuthorizationHeaders(profile: Profile, contentType?: string): RawAxiosRequestHeaders { - const authenticationType = profile.authenticationType || AuthenticationType.BEARER; - return { - Authorization: `${authenticationType} ${profile.apiToken}`, + const headers: RawAxiosRequestHeaders = { "Content-Type": contentType ?? "application/json", }; + if (profile.apiToken) { + const authenticationType = profile.authenticationType || AuthenticationType.BEARER; + headers.Authorization = `${authenticationType} ${profile.apiToken}`; + } + return headers; } } diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 1305dce0..c1b01078 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -23,6 +23,12 @@ export interface Config { defaultProfile: string; } +interface EnvProfileSource { + teamUrl: string; + apiToken: string; + requiresCelonisMapping: boolean; +} + export class ProfileService { private profileContainerPath = path.resolve(homedir, ".celonis-content-cli-profiles"); private configContainer = path.resolve(this.profileContainerPath, "config.json"); @@ -59,13 +65,16 @@ export class ProfileService { .then(() => resolve(profile)) .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); } - } else if (process.env.TEAM_URL && process.env.API_TOKEN) { - resolve(this.buildProfileFromEnvVariables()); - } else if (process.env.CELONIS_URL && process.env.CELONIS_API_TOKEN) { - this.mapCelonisEnvProfile(); - resolve(this.buildProfileFromEnvVariables()); } else { - reject(`The profile ${profileName} couldn't be resolved due to missing environment variables.`); + const envProfileSource = this.selectEnvProfileSource(); + if (envProfileSource) { + if (envProfileSource.requiresCelonisMapping) { + this.mapCelonisEnvProfile(); + } + resolve(this.buildProfileFromEnvVariables()); + } else { + reject(`The profile ${profileName} couldn't be resolved due to missing environment variables.`); + } } } catch (e) { reject(`The profile ${profileName} couldn't be resolved.`); @@ -121,6 +130,15 @@ export class ProfileService { }); } + private selectEnvProfileSource(): EnvProfileSource | null { + const sources: EnvProfileSource[] = [ + { teamUrl: process.env.TEAM_URL, apiToken: process.env.API_TOKEN, requiresCelonisMapping: false }, + { teamUrl: process.env.CELONIS_URL, apiToken: process.env.CELONIS_API_TOKEN, requiresCelonisMapping: true }, + ].filter(source => !!source.teamUrl); + + return sources.find(source => !!source.apiToken) ?? sources[0] ?? null; + } + private async buildProfileFromEnvVariables(): Promise { const profileVariables = this.getProfileEnvVariables(); const profile: Profile = { @@ -130,7 +148,10 @@ export class ProfileService { authenticationType: AuthenticationType.BEARER, type: ProfileType.KEY }; - profile.authenticationType = await ProfileValidator.validateProfile(profile); + ProfileValidator.validateEnvironmentProfile(profile); + if (!profile.apiToken) { + logger.warn(`No API token provided. Requests to ${profile.team} will be sent without an authorization header.`); + } return profile; } diff --git a/src/core/profile/profile.validator.ts b/src/core/profile/profile.validator.ts index 8b78dd47..2d4c2ac9 100644 --- a/src/core/profile/profile.validator.ts +++ b/src/core/profile/profile.validator.ts @@ -4,18 +4,31 @@ import { Profile, ProfileType } from "./profile.interface"; export class ProfileValidator { public static async validateProfile(profile: Profile): Promise { - if (profile.name == null) { - logger.error(new FatalError("The name can not be empty")); - } - if (profile.team == null) { - logger.error(new FatalError("The team can not be empty")); - } + this.validateIdentity(profile); if (profile.type === ProfileType.KEY && profile.apiToken == null) { logger.error(new FatalError("The api token can not be empty for this profile type")); } if (profile.type === ProfileType.CLIENT_CREDENTIALS && (profile.clientId == null || profile.clientSecret == null)) { logger.error(new FatalError("The client id and secret can not be empty for this profile type")); } + this.validateTeamUrl(profile); + } + + public static validateEnvironmentProfile(profile: Profile): void { + this.validateIdentity(profile); + this.validateTeamUrl(profile); + } + + private static validateIdentity(profile: Profile): void { + if (profile.name == null) { + logger.error(new FatalError("The name can not be empty")); + } + if (profile.team == null) { + logger.error(new FatalError("The team can not be empty")); + } + } + + private static validateTeamUrl(profile: Profile): void { if (!validUrl.isUri(profile.team)) { logger.error(new FatalError("The provided url is not a valid url.")); } diff --git a/tests/core/http/http-client.spec.ts b/tests/core/http/http-client.spec.ts new file mode 100644 index 00000000..2764c4fb --- /dev/null +++ b/tests/core/http/http-client.spec.ts @@ -0,0 +1,66 @@ +import { Context } from "../../../src/core/command/cli-context"; +import { HttpClient } from "../../../src/core/http/http-client"; +import { Profile } from "../../../src/core/profile/profile.interface"; +import { mockAxiosGet, mockedAxiosInstance } from "../../utls/http-requests-mock"; + +const TEAM_URL = "https://myTeam.celonis.cloud"; +const RESOURCE_PATH = "/api/test/resource"; + +function httpClientFor(profile: Partial): HttpClient { + const context = new Context({}); + context.profile = { + name: "test", + team: TEAM_URL, + type: "Key", + authenticationType: "Bearer", + ...profile, + } as Profile; + return new HttpClient(context); +} + +function headersOfSentRequest(): any { + return (mockedAxiosInstance.get as jest.Mock).mock.calls[0][1].headers; +} + +describe("HttpClient authorization headers", () => { + + beforeEach(() => { + mockAxiosGet(TEAM_URL + RESOURCE_PATH, {}); + }); + + it("should send an authorization header when the profile has a token", async () => { + await httpClientFor({ apiToken: "test-token" }).get(RESOURCE_PATH); + + expect(headersOfSentRequest().Authorization).toBe("Bearer test-token"); + }); + + it("should use the authentication type of the profile", async () => { + await httpClientFor({ apiToken: "test-token", authenticationType: "AppKey" }).get(RESOURCE_PATH); + + expect(headersOfSentRequest().Authorization).toBe("AppKey test-token"); + }); + + it("should fall back to Bearer when the profile has no authentication type", async () => { + await httpClientFor({ apiToken: "test-token", authenticationType: undefined }).get(RESOURCE_PATH); + + expect(headersOfSentRequest().Authorization).toBe("Bearer test-token"); + }); + + it("should omit the authorization header when the profile has no token", async () => { + await httpClientFor({ apiToken: undefined }).get(RESOURCE_PATH); + + expect(headersOfSentRequest()).not.toHaveProperty("Authorization"); + }); + + it("should omit the authorization header when the profile token is empty", async () => { + await httpClientFor({ apiToken: "" }).get(RESOURCE_PATH); + + expect(headersOfSentRequest()).not.toHaveProperty("Authorization"); + }); + + it("should still send the content type when the profile has no token", async () => { + await httpClientFor({ apiToken: undefined }).get(RESOURCE_PATH); + + expect(headersOfSentRequest()["Content-Type"]).toBe("application/json"); + }); +}); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index d5c888d6..73003b45 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -262,6 +262,7 @@ describe("ProfileService - findProfile", () => { originalApiToken = process.env.API_TOKEN; jest.spyOn(ProfileValidator, "validateProfile").mockResolvedValue(AuthenticationType.BEARER); + jest.spyOn(ProfileValidator, "validateEnvironmentProfile").mockImplementation(() => undefined); }); afterEach(() => { @@ -350,7 +351,7 @@ describe("ProfileService - findProfile", () => { expect(result.team).toBe("https://env.celonis.cloud"); expect(result.apiToken).toBe("env-token"); expect(result.type).toBe(ProfileType.KEY); - expect(ProfileValidator.validateProfile).toHaveBeenCalled(); + expect(ProfileValidator.validateEnvironmentProfile).toHaveBeenCalled(); }); it("should use CELONIS_URL and CELONIS_API_TOKEN when TEAM_URL and API_TOKEN are not set", async () => { @@ -383,17 +384,16 @@ describe("ProfileService - findProfile", () => { expect(result.team).toBe("https://celonis.celonis.cloud"); }); - it("should reject when CELONIS_API_TOKEN is not set but CELONIS_URL is set", async () => { + it("should not pair CELONIS_URL with an API_TOKEN from the other variable family", async () => { process.env.CELONIS_URL = "https://celonis.celonis.cloud"; process.env.API_TOKEN = "old-token"; delete process.env.CELONIS_API_TOKEN; delete process.env.TEAM_URL; - const profileName = ""; + const result = await profileService.findProfile(""); - await expect(profileService.findProfile(profileName)).rejects.toBe( - `The profile ${profileName} couldn't be resolved due to missing environment variables.` - ); + expect(result.team).toBe("https://celonis.celonis.cloud"); + expect(result.apiToken).toBeUndefined(); }); it("should reject when no environment variables are set", async () => { @@ -409,19 +409,55 @@ describe("ProfileService - findProfile", () => { ); }); - it("should reject when only CELONIS_URL is set without CELONIS_API_TOKEN", async () => { + it("should resolve a profile without a token when only CELONIS_URL is set", async () => { process.env.CELONIS_URL = "https://celonis.celonis.cloud"; delete process.env.CELONIS_API_TOKEN; delete process.env.TEAM_URL; delete process.env.API_TOKEN; - const profileName = ""; + const result = await profileService.findProfile(""); - await expect(profileService.findProfile(profileName)).rejects.toBe( - `The profile ${profileName} couldn't be resolved due to missing environment variables.` + expect(result.team).toBe("https://celonis.celonis.cloud"); + expect(result.apiToken).toBeUndefined(); + expect(result.type).toBe(ProfileType.KEY); + }); + + it("should resolve a profile without a token when only TEAM_URL is set", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + delete process.env.API_TOKEN; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + + const result = await profileService.findProfile(""); + + expect(result.team).toBe("https://env.celonis.cloud"); + expect(result.apiToken).toBeUndefined(); + }); + + it("should warn when resolving a profile without a token", async () => { + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + delete process.env.CELONIS_API_TOKEN; + delete process.env.TEAM_URL; + delete process.env.API_TOKEN; + + await profileService.findProfile(""); + + expect(logger.warn).toHaveBeenCalledWith( + "No API token provided. Requests to https://celonis.celonis.cloud will be sent without an authorization header." ); }); + it("should not warn when the resolved profile carries a token", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + process.env.API_TOKEN = "env-token"; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + + await profileService.findProfile(""); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + it("should reject when only CELONIS_API_TOKEN is set without CELONIS_URL", async () => { process.env.CELONIS_API_TOKEN = "celonis-token"; delete process.env.CELONIS_URL; @@ -436,6 +472,69 @@ describe("ProfileService - findProfile", () => { }); }); + describe("precedence between environment variable families", () => { + it("should prefer a complete CELONIS pair over a TEAM_URL without a token", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + delete process.env.API_TOKEN; + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + process.env.CELONIS_API_TOKEN = "celonis-token"; + + const result = await profileService.findProfile(""); + + expect(result.team).toBe("https://celonis.celonis.cloud"); + expect(result.apiToken).toBe("celonis-token"); + }); + + it("should prefer a complete TEAM pair over a CELONIS_URL without a token", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + process.env.API_TOKEN = "env-token"; + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + delete process.env.CELONIS_API_TOKEN; + + const result = await profileService.findProfile(""); + + expect(result.team).toBe("https://env.celonis.cloud"); + expect(result.apiToken).toBe("env-token"); + }); + + it("should prefer the TEAM pair when both families are complete", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + process.env.API_TOKEN = "env-token"; + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + process.env.CELONIS_API_TOKEN = "celonis-token"; + + const result = await profileService.findProfile(""); + + expect(result.team).toBe("https://env.celonis.cloud"); + expect(result.apiToken).toBe("env-token"); + }); + + it("should prefer the TEAM pair when neither family supplies a token", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + delete process.env.API_TOKEN; + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + delete process.env.CELONIS_API_TOKEN; + + const result = await profileService.findProfile(""); + + expect(result.team).toBe("https://env.celonis.cloud"); + expect(result.apiToken).toBeUndefined(); + }); + + it("should not map the CELONIS variables when the TEAM pair wins", async () => { + process.env.TEAM_URL = "https://env.celonis.cloud"; + process.env.API_TOKEN = "env-token"; + process.env.CELONIS_URL = "https://celonis.celonis.cloud"; + delete process.env.CELONIS_API_TOKEN; + + const mapCelonisEnvProfileSpy = jest.spyOn(profileService as any, "mapCelonisEnvProfile"); + + await profileService.findProfile(""); + + expect(mapCelonisEnvProfileSpy).not.toHaveBeenCalled(); + }); + }); + describe("when the stored profile cannot be refreshed", () => { it("should reject instead of resolving an unrefreshed profile", async () => { const profileName = "expired-profile"; diff --git a/tests/core/profile/profile.validator.spec.ts b/tests/core/profile/profile.validator.spec.ts new file mode 100644 index 00000000..7b191509 --- /dev/null +++ b/tests/core/profile/profile.validator.spec.ts @@ -0,0 +1,81 @@ +import { ProfileValidator } from "../../../src/core/profile/profile.validator"; +import { Profile } from "../../../src/core/profile/profile.interface"; +import { loggingTestTransport } from "../../jest.setup"; + +function keyProfile(overrides: Partial = {}): Profile { + return { + name: "test", + team: "https://myTeam.celonis.cloud", + apiToken: "test-token", + authenticationType: "Bearer", + type: "Key", + ...overrides, + } as Profile; +} + +describe("ProfileValidator", () => { + + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + describe("validateProfile", () => { + it("should accept a key profile that has an api token", async () => { + await ProfileValidator.validateProfile(keyProfile()); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("should reject a key profile without an api token", async () => { + await ProfileValidator.validateProfile(keyProfile({ apiToken: undefined })); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + "The api token can not be empty for this profile type" + ); + }); + + it("should reject a client credentials profile without a client secret", async () => { + await ProfileValidator.validateProfile( + keyProfile({ type: "Client Credentials", clientId: "id", clientSecret: undefined }) + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("The client id and secret can not be empty"); + }); + }); + + describe("validateEnvironmentProfile", () => { + it("should accept a profile without an api token", () => { + ProfileValidator.validateEnvironmentProfile(keyProfile({ apiToken: undefined })); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("should accept a profile that has an api token", () => { + ProfileValidator.validateEnvironmentProfile(keyProfile()); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("should reject a profile without a name", () => { + ProfileValidator.validateEnvironmentProfile(keyProfile({ name: undefined })); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("The name can not be empty"); + }); + + it("should reject a profile whose team url is not a valid url", () => { + ProfileValidator.validateEnvironmentProfile(keyProfile({ team: "not a url" })); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("The provided url is not a valid url."); + }); + }); +});