diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1d8e9d0..01397a2 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -123,7 +123,7 @@ on your behalf. With Client Credentials, you need to provide the credentials (Client ID, Client Secret) configured for your OAuth client. You can create and configure an OAuth clients in the `Admin & Settings` section of your Celonis account, under `Applications`. -The client needs to have all four scopes configured: "studio", "integration.data-pools", "action-engine.projects" and "package-manager". +The client needs to have any combination of these four scopes configured: "studio", "integration.data-pools", "action-engine.projects" and "package-manager". After creating an OAuth client, you should assign it the permissions necessary for the respective commands. More information on registering OAuth clients can be found [here](https://docs.celonis.com/en/registering-oauth-client.html). diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index dbd3ebc..72a9017 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -6,15 +6,17 @@ import { ProfileValidator } from "./profile.validator"; import * as path from "path"; import * as fs from "fs"; import { FatalError, logger } from "../utils/logger"; -import { Issuer } from "openid-client"; +import { Client, Issuer } from "openid-client"; import axios from "axios"; import os = require("os"); const homedir = os.homedir(); // use 5 seconds buffer to avoid rare cases when accessToken is just about to expire before the command is sent const expiryBuffer = 5000; -const deviceCodeScopes = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"]; -const clientCredentialsScopes = ["studio", "integration.data-pools", "action-engine.projects", "package-manager"]; +/** All OAuth scopes; used for both device code and client credentials. */ +const OAUTH_SCOPES = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"]; +/** Device code fallback: try without action-engine.projects if all 4 scopes fail. */ +const DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE = ["studio", "package-manager", "integration.data-pools"]; export interface Config { defaultProfile: string; @@ -153,63 +155,65 @@ export class ProfileService { } break; case ProfileType.DEVICE_CODE: - try { - const deviceCodeIssuer = await Issuer.discover(profile.team); - const deviceCodeOAuthClient = new deviceCodeIssuer.Client({ - client_id: "content-cli", - token_endpoint_auth_method: "none", - }); - const deviceCodeHandle = await deviceCodeOAuthClient.deviceAuthorization({ - scope: deviceCodeScopes.join(" ") - }); - logger.info(`Continue authorization here: ${deviceCodeHandle.verification_uri_complete}`); - const deviceCodeTokenSet = await deviceCodeHandle.poll(); - profile.apiToken = deviceCodeTokenSet.access_token; - profile.refreshToken = deviceCodeTokenSet.refresh_token; - profile.expiresAt = deviceCodeTokenSet.expires_at; - } catch (err) { - logger.error(new FatalError("The provided team is wrong.")); - logger.error(err); + const deviceCodeIssuer = await Issuer.discover(profile.team); + const deviceCodeOAuthClient = new deviceCodeIssuer.Client({ + client_id: "content-cli", + token_endpoint_auth_method: "none", + }); + const deviceCodeScopeAttempts: string[][] = [OAUTH_SCOPES, DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE]; + let deviceCodeSuccess = false; + for (const scopeList of deviceCodeScopeAttempts) { + try { + const deviceCodeHandle = await deviceCodeOAuthClient.deviceAuthorization({ + scope: scopeList.join(" "), + }); + logger.info(`Continue authorization here: ${deviceCodeHandle.verification_uri_complete}`); + const deviceCodeTokenSet = await deviceCodeHandle.poll(); + profile.apiToken = deviceCodeTokenSet.access_token; + profile.refreshToken = deviceCodeTokenSet.refresh_token; + profile.expiresAt = deviceCodeTokenSet.expires_at; + deviceCodeSuccess = true; + break; + } catch (err) { + // This scope set failed; try next or fail below + } + } + if (!deviceCodeSuccess) { + throw new FatalError( + "Device code authorization failed. The provided team or requested scopes may be invalid." + ); } break; case ProfileType.CLIENT_CREDENTIALS: const clientCredentialsIssuer = await Issuer.discover(profile.team); - try { - // try with client secret basic - const clientCredentialsOAuthClient = new clientCredentialsIssuer.Client({ - client_id: profile.clientId, - client_secret: profile.clientSecret, - token_endpoint_auth_method: ClientAuthenticationMethod.CLIENT_SECRET_BASIC, - }); - const clientCredentialsTokenSet = await clientCredentialsOAuthClient.grant({ - grant_type: "client_credentials", - scope: clientCredentialsScopes.join(" ") - }); + const basicResult = await this.tryClientCredentialsGrant( + clientCredentialsIssuer, + profile, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC + ); + if (basicResult) { profile.clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC; - profile.apiToken = clientCredentialsTokenSet.access_token; - profile.expiresAt = clientCredentialsTokenSet.expires_at; - } catch (e) { - try { - // try with client secret post - const clientCredentialsOAuthClient = new clientCredentialsIssuer.Client({ - client_id: profile.clientId, - client_secret: profile.clientSecret, - token_endpoint_auth_method: ClientAuthenticationMethod.CLIENT_SECRET_POST, - }); - const clientCredentialsTokenSet = await clientCredentialsOAuthClient.grant({ - grant_type: "client_credentials", - scope: clientCredentialsScopes.join(" ") - }); + profile.apiToken = basicResult.tokenSet.access_token; + profile.expiresAt = basicResult.tokenSet.expires_at; + profile.scopes = basicResult.scopes; + } else { + const postResult = await this.tryClientCredentialsGrant( + clientCredentialsIssuer, + profile, + ClientAuthenticationMethod.CLIENT_SECRET_POST + ); + if (postResult) { profile.clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_POST; - profile.apiToken = clientCredentialsTokenSet.access_token; - profile.expiresAt = clientCredentialsTokenSet.expires_at; - } catch (err) { - logger.error(new FatalError("The OAuth client configuration is incorrect. " + - "Check the id, secret and scopes for correctness.")); + profile.apiToken = postResult.tokenSet.access_token; + profile.expiresAt = postResult.tokenSet.expires_at; + profile.scopes = postResult.scopes; + } else { + throw new FatalError( + "The OAuth client configuration is incorrect. " + + "Check the id, secret and allowed scopes for this client." + ); } } - profile.scopes = [...clientCredentialsScopes]; - break; default: logger.error(new FatalError("Unsupported profile type")); @@ -320,6 +324,81 @@ export class ProfileService { delete process.env.API_TOKEN; } } + + /** + * Returns all non-empty combinations of scopes, ordered by size descending (4, then 3, 2, 1). + * Order within a combination does not matter; each set is tried once. + */ + private getScopeCombinationsOrderedBySize(scopes: string[]): string[][] { + function combinations(arr: T[], k: number): T[][] { + if (k === 0) return [[]]; + if (k > arr.length) return []; + const result: T[][] = []; + for (let i = 0; i <= arr.length - k; i++) { + const rest = combinations(arr.slice(i + 1), k - 1); + rest.forEach(r => result.push([arr[i], ...r])); + } + return result; + } + return [ + ...combinations(scopes, 4), + ...combinations(scopes, 3), + ...combinations(scopes, 2), + ...combinations(scopes, 1), + ]; + } + + /** + * Tries to obtain a client_credentials token. First tries with all scopes (including + * action-engine.projects); if no token is returned, tries again without action-engine.projects. + * Returns { tokenSet, scopes } or null if no grant succeeded. + */ + private async tryClientCredentialsGrant( + issuer: Issuer, + profile: Profile, + tokenEndpointAuthMethod: ClientAuthenticationMethod + ): Promise<{ tokenSet: { access_token: string; expires_at: number; scope?: string }; scopes: string[] } | null> { + const Client = issuer.Client as new (args: object) => { + grant: (params: { grant_type: string; scope?: string }) => Promise<{ access_token: string; expires_at: number; scope?: string }>; + }; + const client = new Client({ + client_id: profile.clientId, + client_secret: profile.clientSecret, + token_endpoint_auth_method: tokenEndpointAuthMethod, + }); + + const scopeAttempts = this.getScopeCombinationsOrderedBySize(OAUTH_SCOPES); + + for (const scopeList of scopeAttempts) { + try { + const tokenSet = await client.grant({ + grant_type: "client_credentials", + scope: scopeList.join(" "), + }); + if (tokenSet && tokenSet.access_token) { + const scopes = this.extractScopesFromTokenSet(tokenSet, scopeList); + return { tokenSet, scopes }; + } + } catch (_e) { + // This scope set failed (e.g. invalid_scope); try next + } + } + return null; + } + + /** + * Extracts granted scopes from the token response (OAuth 2.0 scope parameter). + * Used at profile creation so we store the scopes the server actually granted for this client. + */ + private extractScopesFromTokenSet(tokenSet: { scope?: string }, fallbackScopes: string[]): string[] { + if (tokenSet.scope && typeof tokenSet.scope === "string") { + const scopes = tokenSet.scope.trim().split(/\s+/).filter(Boolean); + if (scopes.length > 0) { + return scopes; + } + } + return [...fallbackScopes]; + } } export const profileService = new ProfileService(); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index e4b2448..c96b867 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -8,6 +8,23 @@ jest.mock("os", () => ({ homedir: jest.fn(() => "/mock/home") })); +const mockIssuerDiscover = jest.fn(); +jest.mock("openid-client", () => ({ + Issuer: { + discover: (...args: any[]) => mockIssuerDiscover(...args), + }, +})); + +jest.mock("../../../src/core/utils/logger", () => ({ + logger: { error: jest.fn(), info: jest.fn() }, + FatalError: class FatalError extends Error { + constructor(m: string) { + super(m); + this.name = "FatalError"; + } + }, +})); + import { ProfileService } from "../../../src/core/profile/profile.service"; describe("ProfileService - mapCelonisEnvProfile", () => { @@ -464,3 +481,684 @@ describe("ProfileService - findProfile", () => { }); }); +describe("ProfileService - getScopeCombinationsOrderedBySize", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return 15 combinations for 4 scopes", () => { + const scopes = ["a", "b", "c", "d"]; + const result = (profileService as any).getScopeCombinationsOrderedBySize(scopes); + expect(result).toHaveLength(15); + }); + + it("should order by size descending: first 1 combination of 4, then 4 of 3, then 6 of 2, then 4 of 1", () => { + const scopes = ["a", "b", "c", "d"]; + const result = (profileService as any).getScopeCombinationsOrderedBySize(scopes); + expect(result[0]).toHaveLength(4); + expect(result[0]).toEqual(["a", "b", "c", "d"]); + const size3 = result.slice(1, 5); + expect(size3.every((s: string[]) => s.length === 3)).toBe(true); + expect(size3).toHaveLength(4); + const size2 = result.slice(5, 11); + expect(size2.every((s: string[]) => s.length === 2)).toBe(true); + expect(size2).toHaveLength(6); + const size1 = result.slice(11, 15); + expect(size1.every((s: string[]) => s.length === 1)).toBe(true); + expect(size1).toHaveLength(4); + }); + + it("should return each combination once (order within set does not matter)", () => { + const scopes = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"]; + const result = (profileService as any).getScopeCombinationsOrderedBySize(scopes); + const sorted = result.map((combo: string[]) => [...combo].sort()); + const unique = new Set(sorted.map((s: string[]) => s.join(","))); + expect(unique.size).toBe(15); + }); +}); + +describe("ProfileService - extractScopesFromTokenSet", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return scopes from token response when scope is present", () => { + const tokenSet = { scope: "studio package-manager integration.data-pools" }; + const fallback = ["fallback"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["studio", "package-manager", "integration.data-pools"]); + }); + + it("should return fallback when token response has no scope", () => { + const tokenSet = {}; + const fallback = ["studio", "package-manager"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["studio", "package-manager"]); + }); + + it("should return fallback when scope is empty string", () => { + const tokenSet = { scope: " " }; + const fallback = ["fallback"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["fallback"]); + }); + + it("should trim and split single scope", () => { + const tokenSet = { scope: " studio " }; + const fallback = ["fallback"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["studio"]); + }); + + it("should return copy of fallback so original is not mutated", () => { + const tokenSet = {}; + const fallback = ["studio", "package-manager"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["studio", "package-manager"]); + expect(result).not.toBe(fallback); + }); + + it("should handle scope string with multiple spaces between scopes", () => { + const tokenSet = { scope: "studio package-manager integration.data-pools" }; + const fallback = ["fallback"]; + const result = (profileService as any).extractScopesFromTokenSet(tokenSet, fallback); + expect(result).toEqual(["studio", "package-manager", "integration.data-pools"]); + }); +}); + +describe("ProfileService - tryClientCredentialsGrant", () => { + let profileService: ProfileService; + const mockProfile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "client-id", + clientSecret: "client-secret", + }; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return tokenSet and scopes when grant succeeds for first scope combination", async () => { + const tokenSet = { + access_token: "access-token-123", + expires_at: Math.floor(Date.now() / 1000) + 3600, + scope: "studio package-manager", + }; + const mockGrant = jest.fn().mockResolvedValue(tokenSet); + const mockIssuer = { + Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), + }; + jest.spyOn(profileService as any, "getScopeCombinationsOrderedBySize").mockReturnValue([ + ["studio", "package-manager"], + ["studio"], + ]); + + const result = await (profileService as any).tryClientCredentialsGrant( + mockIssuer, + mockProfile, + "client_secret_basic" + ); + + expect(result).not.toBeNull(); + expect(result!.tokenSet.access_token).toBe("access-token-123"); + expect(result!.tokenSet.expires_at).toBe(tokenSet.expires_at); + expect(result!.scopes).toEqual(["studio", "package-manager"]); + expect(mockGrant).toHaveBeenCalledWith({ + grant_type: "client_credentials", + scope: "studio package-manager", + }); + }); + + it("should try next scope combination when first grant fails", async () => { + const tokenSet = { + access_token: "token-2", + expires_at: Math.floor(Date.now() / 1000) + 3600, + scope: "studio", + }; + const mockGrant = jest.fn() + .mockRejectedValueOnce(new Error("invalid_scope")) + .mockResolvedValueOnce(tokenSet); + const mockIssuer = { + Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), + }; + jest.spyOn(profileService as any, "getScopeCombinationsOrderedBySize").mockReturnValue([ + ["studio", "package-manager"], + ["studio"], + ]); + + const result = await (profileService as any).tryClientCredentialsGrant( + mockIssuer, + mockProfile, + "client_secret_post" + ); + + expect(result).not.toBeNull(); + expect(result!.tokenSet.access_token).toBe("token-2"); + expect(result!.scopes).toEqual(["studio"]); + expect(mockGrant).toHaveBeenCalledTimes(2); + }); + + it("should return null when all scope combinations fail", async () => { + const mockGrant = jest.fn().mockRejectedValue(new Error("invalid_scope")); + const mockIssuer = { + Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), + }; + jest.spyOn(profileService as any, "getScopeCombinationsOrderedBySize").mockReturnValue([ + ["studio"], + ["package-manager"], + ]); + + const result = await (profileService as any).tryClientCredentialsGrant( + mockIssuer, + mockProfile, + "client_secret_basic" + ); + + expect(result).toBeNull(); + expect(mockGrant).toHaveBeenCalledTimes(2); + }); + + it("should use fallback scopes when token response has no scope field", async () => { + const tokenSet = { + access_token: "token-no-scope", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + const mockGrant = jest.fn().mockResolvedValue(tokenSet); + const mockIssuer = { + Client: jest.fn().mockImplementation(() => ({ grant: mockGrant })), + }; + jest.spyOn(profileService as any, "getScopeCombinationsOrderedBySize").mockReturnValue([["studio"]]); + + const result = await (profileService as any).tryClientCredentialsGrant( + mockIssuer, + mockProfile, + "client_secret_basic" + ); + + expect(result).not.toBeNull(); + expect(result!.scopes).toEqual(["studio"]); + }); +}); + +describe("ProfileService - authorizeProfile (device code)", () => { + let profileService: ProfileService; + let mockClientInstance: { deviceAuthorization: jest.Mock }; + + beforeEach(() => { + profileService = new ProfileService(); + mockIssuerDiscover.mockReset(); + mockClientInstance = { + deviceAuthorization: jest.fn(), + }; + mockIssuerDiscover.mockResolvedValue({ + Client: jest.fn().mockReturnValue(mockClientInstance), + }); + }); + + it("should throw FatalError when both scope attempts fail", async () => { + mockClientInstance.deviceAuthorization.mockRejectedValue(new Error("invalid_scope")); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.DEVICE_CODE, + }; + + await expect(profileService.authorizeProfile(profile)).rejects.toThrow("Device code authorization failed"); + }); + + it("should set profile token when second scope attempt succeeds", async () => { + const tokenSet = { + access_token: "access-123", + refresh_token: "refresh-456", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + const mockPoll = jest.fn().mockResolvedValue(tokenSet); + mockClientInstance.deviceAuthorization + .mockRejectedValueOnce(new Error("invalid_scope")) + .mockResolvedValueOnce({ verification_uri_complete: "https://example.com/device", poll: mockPoll }); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.DEVICE_CODE, + }; + + await profileService.authorizeProfile(profile); + + expect(profile.apiToken).toBe("access-123"); + expect(profile.refreshToken).toBe("refresh-456"); + expect(profile.expiresAt).toBe(tokenSet.expires_at); + }); +}); + +describe("ProfileService - authorizeProfile (client credentials)", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + mockIssuerDiscover.mockResolvedValue({}); + }); + + it("should set profile scopes and token when tryClientCredentialsGrant returns result", async () => { + const tokenSet = { + access_token: "token-123", + expires_at: Math.floor(Date.now() / 1000) + 3600, + scope: "studio package-manager", + }; + const tryGrantSpy = jest.spyOn(profileService as any, "tryClientCredentialsGrant") + .mockResolvedValueOnce({ tokenSet, scopes: ["studio", "package-manager"] }); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "client-id", + clientSecret: "client-secret", + }; + + await profileService.authorizeProfile(profile); + + expect(tryGrantSpy).toHaveBeenCalled(); + expect(profile.apiToken).toBe("token-123"); + expect(profile.expiresAt).toBe(tokenSet.expires_at); + expect(profile.scopes).toEqual(["studio", "package-manager"]); + expect(profile.clientAuthenticationMethod).toBe("client_secret_basic"); + }); + + it("should throw when both basic and post client credentials fail", async () => { + jest.spyOn(profileService as any, "tryClientCredentialsGrant").mockResolvedValue(null); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "client-id", + clientSecret: "client-secret", + }; + + await expect(profileService.authorizeProfile(profile)).rejects.toThrow( + "The OAuth client configuration is incorrect" + ); + }); + + it("should use post result when basic fails", async () => { + const tokenSet = { + access_token: "token-post", + expires_at: Math.floor(Date.now() / 1000) + 3600, + scope: "studio", + }; + jest.spyOn(profileService as any, "tryClientCredentialsGrant") + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ tokenSet, scopes: ["studio"] }); + + const profile: Profile = { + name: "test", + team: "https://example.celonis.cloud", + apiToken: "", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "client-id", + clientSecret: "client-secret", + }; + + await profileService.authorizeProfile(profile); + + expect(profile.apiToken).toBe("token-post"); + expect(profile.scopes).toEqual(["studio"]); + expect(profile.clientAuthenticationMethod).toBe("client_secret_post"); + }); +}); + +describe("ProfileService - makeDefaultProfile", () => { + let profileService: ProfileService; + const mockHomedir = "/mock/home"; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + const configPath = path.resolve(mockProfilePath, "config.json"); + + beforeEach(() => { + (os.homedir as jest.Mock).mockReturnValue(mockHomedir); + profileService = new ProfileService(); + jest.spyOn(profileService, "findProfile").mockResolvedValue({ + name: "my-profile", + team: "https://example.celonis.cloud", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.KEY, + } as Profile); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should call findProfile and store default profile name in config", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); + + await profileService.makeDefaultProfile("my-profile"); + + expect(profileService.findProfile).toHaveBeenCalledWith("my-profile"); + expect(fs.writeFileSync).toHaveBeenCalledWith(configPath, JSON.stringify({ defaultProfile: "my-profile" }), { encoding: "utf-8" }); + }); + + it("should reject when findProfile fails", async () => { + jest.spyOn(profileService, "findProfile").mockRejectedValue(new Error("Profile not found")); + + await expect(profileService.makeDefaultProfile("missing")).rejects.toThrow("Profile not found"); + }); +}); + +describe("ProfileService - getDefaultProfile", () => { + let profileService: ProfileService; + const mockHomedir = "/mock/home"; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + const configPath = path.resolve(mockProfilePath, "config.json"); + + beforeEach(() => { + (os.homedir as jest.Mock).mockReturnValue(mockHomedir); + profileService = new ProfileService(); + }); + + it("should return default profile name when config exists", () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify({ defaultProfile: "my-default" })); + + const result = profileService.getDefaultProfile(); + + expect(result).toBe("my-default"); + expect(fs.readFileSync).toHaveBeenCalledWith(configPath, { encoding: "utf-8" }); + }); + + it("should return null when config file does not exist", () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = profileService.getDefaultProfile(); + + expect(result).toBeNull(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("ProfileService - storeProfile", () => { + let profileService: ProfileService; + const mockHomedir = "/mock/home"; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + (os.homedir as jest.Mock).mockReturnValue(mockHomedir); + profileService = new ProfileService(); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); + }); + + it("should create profile container if not exists and write profile with normalized team URL", () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const profile: Profile = { + name: "test-profile", + team: "https://example.celonis.cloud/some/path", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.KEY, + }; + + profileService.storeProfile(profile); + + expect(fs.mkdirSync).toHaveBeenCalledWith(mockProfilePath); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.resolve(mockProfilePath, "test-profile.json"), + expect.stringContaining("https://example.celonis.cloud"), + { encoding: "utf-8" } + ); + expect(profile.team).toBe("https://example.celonis.cloud"); + }); + + it("should write profile with correct filename", () => { + const profile: Profile = { + name: "my-profile", + team: "https://team.celonis.cloud", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.KEY, + }; + + profileService.storeProfile(profile); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.resolve(mockProfilePath, "my-profile.json"), + expect.any(String), + { encoding: "utf-8" } + ); + }); +}); + +describe("ProfileService - readAllProfiles", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should resolve with list of profile names from getAllFilesInDirectory", async () => { + const mockNames = ["profile-a", "profile-b"]; + jest.spyOn(profileService, "getAllFilesInDirectory").mockReturnValue(mockNames); + + const result = await profileService.readAllProfiles(); + + expect(result).toEqual(mockNames); + expect(profileService.getAllFilesInDirectory).toHaveBeenCalled(); + }); +}); + +describe("ProfileService - getAllFilesInDirectory", () => { + let profileService: ProfileService; + const mockHomedir = "/mock/home"; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + (os.homedir as jest.Mock).mockReturnValue(mockHomedir); + profileService = new ProfileService(); + }); + + it("should return profile names (without .json) when directory exists", () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readdirSync as jest.Mock).mockReturnValue([ + { name: "profile1.json", isDirectory: () => false }, + { name: "profile2.json", isDirectory: () => false }, + { name: "config.json", isDirectory: () => false }, + { name: "subdir", isDirectory: () => true }, + ]); + + const result = profileService.getAllFilesInDirectory(); + + expect(result).toEqual(["profile1", "profile2"]); + expect(fs.readdirSync).toHaveBeenCalledWith(mockProfilePath, { withFileTypes: true }); + }); + + it("should return empty array when directory does not exist", () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = profileService.getAllFilesInDirectory(); + + expect(result).toEqual([]); + expect(fs.readdirSync).not.toHaveBeenCalled(); + }); +}); + +describe("ProfileService - getBaseTeamUrl", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return origin for URL with path", () => { + const result = (profileService as any).getBaseTeamUrl("https://example.celonis.cloud/team/path"); + expect(result).toBe("https://example.celonis.cloud"); + }); + + it("should return origin for URL without path", () => { + const result = (profileService as any).getBaseTeamUrl("https://example.celonis.cloud"); + expect(result).toBe("https://example.celonis.cloud"); + }); + + it("should return null for null or undefined input", () => { + expect((profileService as any).getBaseTeamUrl(null)).toBeNull(); + }); +}); + +describe("ProfileService - isProfileExpired", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return false for KEY profile type", () => { + const profile: Profile = { + name: "key-profile", + team: "https://example.com", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.KEY, + }; + expect((profileService as any).isProfileExpired(profile)).toBe(false); + }); + + it("should return false when profile has null or undefined type", () => { + const profile = { + name: "p", + team: "https://example.com", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: null, + } as unknown as Profile; + expect((profileService as any).isProfileExpired(profile)).toBe(false); + }); + + it("should return true when expiresAt is in the past", () => { + const profile: Profile = { + name: "oauth-profile", + team: "https://example.com", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + expiresAt: Math.floor(Date.now() / 1000) - 3600, + }; + expect((profileService as any).isProfileExpired(profile)).toBe(true); + }); + + it("should return false when expiresAt is in the future", () => { + const profile: Profile = { + name: "oauth-profile", + team: "https://example.com", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + expect((profileService as any).isProfileExpired(profile)).toBe(false); + }); +}); + +describe("ProfileService - checkIfMissingProfile", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + }); + + it("should return true when profileName is empty string", () => { + expect((profileService as any).checkIfMissingProfile("")).toBe(true); + }); + + it("should return true when profileName is null or undefined", () => { + expect((profileService as any).checkIfMissingProfile(null)).toBe(true); + expect((profileService as any).checkIfMissingProfile(undefined)).toBe(true); + }); + + it("should return undefined when profileName is non-empty", () => { + expect((profileService as any).checkIfMissingProfile("my-profile")).toBeUndefined(); + }); +}); + +describe("ProfileService - refreshProfile", () => { + let profileService: ProfileService; + + beforeEach(() => { + profileService = new ProfileService(); + mockIssuerDiscover.mockResolvedValue({}); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); + }); + + it("should not refresh when profile is not expired", async () => { + const profile: Profile = { + name: "test", + team: "https://example.com", + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "id", + clientSecret: "secret", + scopes: ["studio"], + clientAuthenticationMethod: "client_secret_basic", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(() => {}); + + await profileService.refreshProfile(profile); + + expect(mockIssuerDiscover).not.toHaveBeenCalled(); + expect(storeSpy).not.toHaveBeenCalled(); + }); + + it("should refresh client credentials profile and store when expired", async () => { + const profile: Profile = { + name: "test", + team: "https://example.com", + apiToken: "old-token", + authenticationType: AuthenticationType.BEARER, + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "id", + clientSecret: "secret", + scopes: ["studio"], + clientAuthenticationMethod: "client_secret_basic", + expiresAt: Math.floor(Date.now() / 1000) - 10, + }; + const newTokenSet = { + access_token: "new-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + mockIssuerDiscover.mockResolvedValue({ + Client: jest.fn().mockImplementation(() => ({ + grant: jest.fn().mockResolvedValue(newTokenSet), + })), + }); + const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(() => {}); + + await profileService.refreshProfile(profile); + + expect(profile.apiToken).toBe("new-token"); + expect(profile.expiresAt).toBe(newTokenSet.expires_at); + expect(storeSpy).toHaveBeenCalledWith(profile); + }); +}); +