Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/core/http/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
35 changes: 28 additions & 7 deletions src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.`);
Expand Down Expand Up @@ -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<Profile> {
const profileVariables = this.getProfileEnvVariables();
const profile: Profile = {
Expand All @@ -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;
}

Expand Down
25 changes: 19 additions & 6 deletions src/core/profile/profile.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,31 @@ import { Profile, ProfileType } from "./profile.interface";

export class ProfileValidator {
public static async validateProfile(profile: Profile): Promise<any> {
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."));
}
Expand Down
66 changes: 66 additions & 0 deletions tests/core/http/http-client.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Profile>): 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");
});
});
119 changes: 109 additions & 10 deletions tests/core/profile/profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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;
Expand All @@ -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";
Expand Down
Loading
Loading