diff --git a/README.md b/README.md index 3b261122..ceab78aa 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Designed with developer experience in mind, the CLI makes it easy to integrate * - 🏗️ Automate policy operations in CI/CD with IaC and GitOps - ✨ Generate policies from natural language using AI - 🔐 Manage users, roles, and permissions directly from your terminal -- 🌍 Multi-region support for US and EU deployments > :bulb: The CLI is fully open source and is built with Pastel, using TypeScript and a React-style architecture. Contributions welcome! @@ -147,46 +146,26 @@ The `login` command will take you to the browser to perform user authentication - `--api-key ` - store a Permit API key in your workstation keychain instead of running browser authentication - `--workspace ` - predefined workspace key to skip the workspace selection step -- `--region ` - specify the Permit region to use (`default: us`). The region determines which Permit.io API endpoints the CLI will communicate with. +- `--region ` - specify the Permit region to use (`default: us`). `us` is the only supported region. **Examples:** -Login with default US region: +Login with the default region: ```bash $ permit login ``` -Login with EU region: - -```bash -$ permit login --region eu -``` - -Login with API key and EU region: - -```bash -$ permit login --api-key permit_key_abc123 --region eu -``` - **Region Support:** -Permit.io operates in multiple regions. When you log in with a specific region, the CLI will: - -- Store your region preference in your system keychain -- Use the appropriate regional endpoints for all subsequent commands -- Generate Terraform configurations with the correct regional API URLs +All Permit.io accounts are served from the US region (`https://api.permit.io`). -Available regions: - -- `us` (default) - United States region (`https://api.permit.io`) -- `eu` - European Union region (`https://api.eu.permit.io`) - -You can also set the region using the `PERMIT_REGION` environment variable: +The EU region (`eu`) was retired on 2026-09-28. If you previously logged in with `--region eu`, or have `PERMIT_REGION=eu` set, the CLI stops with an error before making any request. To fix it, unset `PERMIT_REGION` and log in again: ```bash -export PERMIT_REGION=eu -permit login +$ unset PERMIT_REGION +$ permit logout +$ permit login ``` --- @@ -450,27 +429,7 @@ Print out the output to the console - $ permit env export terraform ``` -**Region Support:** - -The generated Terraform configuration will automatically use the correct API URL based on your configured region: - -- **US region**: `api_url = "https://api.permit.io"` -- **EU region**: `api_url = "https://api.eu.permit.io"` - -The region is determined by: - -1. The `PERMIT_REGION` environment variable (if set) -2. The region stored from your last `permit login --region ` command -3. Defaults to `us` if no region is specified - -Example for EU region: - -```bash -$ export PERMIT_REGION=eu -$ permit env export terraform --file permit-eu-config.tf -``` - -This ensures that when you run `terraform apply`, the Terraform provider will communicate with the correct regional Permit.io API. +The generated Terraform configuration uses `api_url = "https://api.permit.io"`. ## Fine-Grained Authorization Configuration diff --git a/source/cli.tsx b/source/cli.tsx index ae763fa4..24b09a6b 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -1,5 +1,18 @@ #!/usr/bin/env node import Pastel from 'pastel'; +import { getRegion, RetiredRegionError } from './config.js'; + +// Fail fast, before any command runs or any request is made, when +// PERMIT_REGION points at a retired region (e.g. 'eu'). +try { + getRegion(); +} catch (error) { + if (error instanceof RetiredRegionError) { + console.error(error.message); + process.exit(1); + } + throw error; +} const app = new Pastel({ importMeta: import.meta, diff --git a/source/commands/login.tsx b/source/commands/login.tsx index e759f075..fe19468b 100644 --- a/source/commands/login.tsx +++ b/source/commands/login.tsx @@ -3,7 +3,11 @@ import { Text } from 'ink'; import { type infer as zInfer, object, string } from 'zod'; import { option } from 'pastel'; import { saveAuthToken, saveRegion } from '../lib/auth.js'; -import { setRegion } from '../config.js'; +import { + isRetiredRegion, + setRegion, + EU_REGION_RETIRED_MESSAGE, +} from '../config.js'; import LoginFlow from '../components/LoginFlow.js'; import EnvironmentSelection, { ActiveState, @@ -30,7 +34,7 @@ export const options = object({ .optional() .describe( option({ - description: 'Permit region: us or eu (default: us)', + description: 'Permit region: us (default: us)', alias: 'r', }), ), @@ -50,9 +54,12 @@ export default function Login({ options: { apiKey, workspace, region }, loginSuccess, }: Props) { + // A retired region (e.g. 'eu') is rejected before any request is made. + const retiredRegion = isRetiredRegion(region); + // Set region IMMEDIATELY before anything else (synchronously) - if (region && (region === 'us' || region === 'eu')) { - setRegion(region as 'us' | 'eu'); + if (region === 'us') { + setRegion(region); } const [state, setState] = useState<'login' | 'signup' | 'env' | 'done'>( @@ -65,12 +72,13 @@ export default function Login({ const [organization, setOrganization] = useState(''); const [environment, setEnvironment] = useState(''); - // Save region to keystore after successful login useEffect(() => { - if (region && (region === 'us' || region === 'eu')) { - saveRegion(region as 'us' | 'eu'); + if (retiredRegion) { + setTimeout(() => { + process.exit(1); + }, 100); } - }, [region]); + }, [retiredRegion]); const onEnvironmentSelectSuccess = useCallback( async ( @@ -82,6 +90,17 @@ export default function Login({ setOrganization(organisation.label); setEnvironment(environment.label); await saveAuthToken(secret); + // Save region to keystore after successful login. 'us' is the only + // region, so this also replaces a retired region (e.g. 'eu') left in + // the keychain by an earlier login. + try { + await saveRegion('us'); + } catch (err) { + setError( + `Failed to save the region: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } if (loginSuccess) { loginSuccess(organisation, project, environment, secret); return; @@ -113,6 +132,10 @@ export default function Login({ setState('env'); }, []); + if (retiredRegion) { + return {EU_REGION_RETIRED_MESSAGE}; + } + return ( <> {state == 'login' && ( diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx index aa06ea06..77095b77 100644 --- a/source/components/AuthProvider.tsx +++ b/source/components/AuthProvider.tsx @@ -14,6 +14,7 @@ import React, { } from 'react'; import { Text, Newline } from 'ink'; import { loadAuthToken, loadRegion } from '../lib/auth.js'; +import { RetiredRegionError } from '../config.js'; import Login from '../commands/login.js'; import { ApiKeyCreate, @@ -30,6 +31,21 @@ import { globalTokenGetterSetter, } from '../hooks/useClient.js'; +// Loads the saved region. Keychain read failures still default to 'us' (as +// before), but a retired region such as 'eu' is rethrown so the caller can +// stop with a clear error instead of silently sending old-region credentials +// to the US endpoints. +const loadStoredRegion = async (): Promise => { + try { + await loadRegion(); + } catch (err) { + if (err instanceof RetiredRegionError) { + throw err; + } + // Ignore other errors - will default to 'us' + } +}; + // Define the AuthContext type export type AuthContextType = { authToken: string; @@ -132,10 +148,15 @@ export function AuthProvider({ ) => { try { // Load region from storage BEFORE validating API key - await loadRegion().catch(() => { - // Ignore errors - will default to 'us' - }); + await loadStoredRegion(); + } catch (err) { + // A retired region (e.g. 'eu') must stop here, before any request, + // instead of falling through to the login flow or to US. + setError(err instanceof Error ? err.message : String(err)); + return; + } + try { const token = await loadAuthToken(); const { valid, @@ -194,9 +215,12 @@ export function AuthProvider({ if (state === 'validate') { (async () => { // Load region from storage BEFORE validating API key - await loadRegion().catch(() => { - // Ignore errors - will default to 'us' - }); + try { + await loadStoredRegion(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } const { valid, diff --git a/source/components/pdp/PDPRunComponent.tsx b/source/components/pdp/PDPRunComponent.tsx index a968e315..a0b6bc37 100644 --- a/source/components/pdp/PDPRunComponent.tsx +++ b/source/components/pdp/PDPRunComponent.tsx @@ -4,7 +4,7 @@ import Spinner from 'ink-spinner'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { loadAuthToken } from '../../lib/auth.js'; -import { API_PDPS_CONFIG_URL } from '../../config.js'; +import { getApiPdpsConfigUrl } from '../../config.js'; import { useAuth } from '../AuthProvider.js'; import SelectInput from 'ink-select-input'; @@ -66,7 +66,7 @@ export default function PDPRunComponent({ } // Fetch PDP configuration - const response = await fetch(API_PDPS_CONFIG_URL, { + const response = await fetch(getApiPdpsConfigUrl(), { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, diff --git a/source/components/pdp/PDPStatComponent.tsx b/source/components/pdp/PDPStatComponent.tsx index 15c468d0..016ec21b 100644 --- a/source/components/pdp/PDPStatComponent.tsx +++ b/source/components/pdp/PDPStatComponent.tsx @@ -3,7 +3,7 @@ import { Box, Newline, Text } from 'ink'; import Spinner from 'ink-spinner'; import { useAuth } from '../AuthProvider.js'; import { PDPStatsProps } from '../../commands/pdp/stats.js'; -import { PERMIT_API_STATISTICS_URL } from '../../config.js'; +import { getPermitApiStatisticsUrl } from '../../config.js'; import TableComponent from '../ui/Table.js'; import { fetchUtil, MethodE } from '../../utils/fetchUtil.js'; @@ -22,7 +22,7 @@ export default function PDPStatComponent({ options }: PDPStatsProps) { // State To Store Statistics URL // eslint-disable-next-line @typescript-eslint/no-unused-vars const [statisticsURL, _] = useState( - `${options.statsUrl || PERMIT_API_STATISTICS_URL}/${auth.scope.project_id || options.projectKey}/${auth.scope.environment_id || options.environmentKey}/pdps`, + `${options.statsUrl || getPermitApiStatisticsUrl()}/${auth.scope.project_id || options.projectKey}/${auth.scope.environment_id || options.environmentKey}/pdps`, ); // State to store API response data diff --git a/source/config.ts b/source/config.ts index a019f0f9..9cf76df1 100644 --- a/source/config.ts +++ b/source/config.ts @@ -3,55 +3,84 @@ export const KEYSTORE_PERMIT_SERVICE_NAME = 'Permit.io'; export const DEFAULT_PERMIT_KEYSTORE_ACCOUNT = 'PERMIT_DEFAULT_ENV'; export const REGION_KEYSTORE_ACCOUNT = 'PERMIT_REGION'; -// Region type -export type PermitRegion = 'us' | 'eu'; +// Region type. The EU region was retired on 2026-09-28 (PER-16377); every +// Permit account is served from the US region now. +export type PermitRegion = 'us'; + +// Regions that used to exist but whose endpoints are gone. A saved or passed +// value from this list must fail loudly: silently treating it as US would send +// credentials issued by the old region to the US endpoints. +export const RETIRED_REGIONS: readonly string[] = ['eu']; + +export const EU_REGION_RETIRED_MESSAGE = + 'The Permit EU region was retired on 2026-09-28 and its endpoints no longer exist. ' + + 'All Permit accounts are now served from the US region. ' + + 'Unset PERMIT_REGION if it is set to "eu", then run `permit logout` and `permit login` ' + + '(without --region, or with --region us) to log in again.'; + +export class RetiredRegionError extends Error { + constructor(readonly region: string) { + super(EU_REGION_RETIRED_MESSAGE); + this.name = 'RetiredRegionError'; + } +} + +export const isRetiredRegion = (value: unknown): boolean => + typeof value === 'string' && + RETIRED_REGIONS.includes(value.trim().toLowerCase()); -// Get region from environment variable or default to 'us' -let currentRegion: PermitRegion = - (process.env['PERMIT_REGION'] as PermitRegion) || 'us'; +// Raw region value as configured (env var, keychain or --region). It is kept +// raw so that a retired value is still visible to getRegion() and every URL +// getter below refuses to build a URL for it. +let currentRegion: string = process.env['PERMIT_REGION'] || 'us'; // Function to set the current region export const setRegion = (region: PermitRegion) => { currentRegion = region; }; -// Function to get the current region +// Function to get the current region. Throws RetiredRegionError if the +// configured region was retired, so no request is ever built for it. export const getRegion = (): PermitRegion => { - return currentRegion; + if (isRetiredRegion(currentRegion)) { + throw new RetiredRegionError(currentRegion); + } + return 'us'; }; -// Function to get region-specific subdomain -const getRegionSubdomain = (region: PermitRegion): string => { - return region === 'eu' ? 'eu.' : ''; +// Adopt a region value read from storage or user input. Throws +// RetiredRegionError for a retired region; any other value resolves to 'us'. +export const adoptRegion = ( + region: string | null | undefined, +): PermitRegion => { + currentRegion = region || 'us'; + return getRegion(); }; -// Region-aware URL getters +// URL getters. Each one calls getRegion() first so a retired region fails +// before any network request is made. export const getPermitApiUrl = (): string => { - const subdomain = getRegionSubdomain(currentRegion); - return `https://api.${subdomain}permit.io`; + getRegion(); + return 'https://api.permit.io'; }; export const getPermitOriginUrl = (): string => { - const subdomain = getRegionSubdomain(currentRegion); - return `https://app.${subdomain}permit.io`; + getRegion(); + return 'https://app.permit.io'; }; export const getAuthPermitDomain = (): string => { - const subdomain = getRegionSubdomain(currentRegion); - return `app.${subdomain}permit.io`; + getRegion(); + return 'app.permit.io'; }; export const getCloudPdpUrl = (): string => { - if (currentRegion === 'eu') { - return 'https://cloudpdp.api.eu-central-1.permit.io'; - } + getRegion(); return 'https://cloudpdp.api.permit.io'; }; export const getPermitApiStatisticsUrl = (): string => { - if (currentRegion === 'eu') { - return 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats'; - } + getRegion(); return 'https://pdp-statistics.api.permit.io/v2/stats'; }; @@ -71,23 +100,11 @@ export const getAuthApiUrl = (): string => { return `${getPermitApiUrl()}/v1/`; }; -// Legacy exports (maintain backwards compatibility) -export const CLOUD_PDP_URL = getCloudPdpUrl(); -export const PERMIT_API_URL = getPermitApiUrl(); -export const PERMIT_API_STATISTICS_URL = getPermitApiStatisticsUrl(); -export const API_URL = getApiUrl(); -export const FACTS_API_URL = getFactsApiUrl(); -export const API_PDPS_CONFIG_URL = getApiPdpsConfigUrl(); -export const PERMIT_ORIGIN_URL = getPermitOriginUrl(); -export const AUTH_PERMIT_DOMAIN = getAuthPermitDomain(); -export const AUTH_API_URL = getAuthApiUrl(); - export const AUTH_REDIRECT_HOST = 'localhost'; export const AUTH_REDIRECT_PORT = 62419; export const AUTH_REDIRECT_URI = `http://${AUTH_REDIRECT_HOST}:${AUTH_REDIRECT_PORT}`; -// auth.permit.io is common for both regions export const AUTH_PERMIT_URL = 'https://auth.permit.io'; -export const AUTH0_AUDIENCE = 'https://api.permit.io/v1/'; // Auth0 audience is shared across all regions +export const AUTH0_AUDIENCE = 'https://api.permit.io/v1/'; export const TERRAFORM_PERMIT_URL = 'https://permit-cli-terraform.up.railway.app'; diff --git a/source/lib/auth.ts b/source/lib/auth.ts index b9b4ebcb..aa95b817 100644 --- a/source/lib/auth.ts +++ b/source/lib/auth.ts @@ -13,6 +13,7 @@ import { REGION_KEYSTORE_ACCOUNT, type PermitRegion, setRegion, + adoptRegion, getAuthPermitDomain, } from '../config.js'; import { URL, URLSearchParams } from 'url'; @@ -94,9 +95,10 @@ export const loadRegion = async (): Promise => { KEYSTORE_PERMIT_SERVICE_NAME, REGION_KEYSTORE_ACCOUNT, ); - const permitRegion = (region as PermitRegion) || 'us'; - setRegion(permitRegion); - return permitRegion; + // Throws RetiredRegionError if the saved region was retired (e.g. 'eu'). + // Callers must not swallow that error: falling back to US would send the + // old region's credentials to the US endpoints. + return adoptRegion(region); }; export const authCallbackServer = async (verifier: string): Promise => { diff --git a/source/utils/permitApi.ts b/source/utils/permitApi.ts index 60c3700f..1b3967a2 100644 --- a/source/utils/permitApi.ts +++ b/source/utils/permitApi.ts @@ -1,6 +1,6 @@ import { fetchUtil, MethodE } from './fetchUtil.js'; import type { AuthContextType } from '../components/AuthProvider.js'; -import { FACTS_API_URL } from '../config.js'; +import { getFactsApiUrl } from '../config.js'; type PermitApiOptions = { auth: AuthContextType; @@ -16,7 +16,7 @@ export async function permitApi( body?: object, queryParams?: Record, ) { - const baseUrl = `${FACTS_API_URL}${ + const baseUrl = `${getFactsApiUrl()}${ auth.scope.project_id || projectId }/${auth.scope.environment_id || envId}`; diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index 4ddf8e6c..0344bd6d 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -16,4 +16,28 @@ describe('Cli script', () => { const pastelInstance = (Pastel as any).mock.results[0].value; expect(pastelInstance.run).toHaveBeenCalled(); }); + + it('Should exit with the retired-region error when PERMIT_REGION=eu', async () => { + vi.resetModules(); + vi.mocked(Pastel).mockClear(); + process.env.PERMIT_REGION = 'eu'; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((( + code?: number, + ) => { + throw new Error(`process.exit(${code})`); + }) as never); + try { + await expect(import('../source/cli.js')).rejects.toThrow( + 'process.exit(1)', + ); + const { EU_REGION_RETIRED_MESSAGE } = await import('../source/config.js'); + expect(errorSpy).toHaveBeenCalledWith(EU_REGION_RETIRED_MESSAGE); + expect(Pastel).not.toHaveBeenCalled(); + } finally { + delete process.env.PERMIT_REGION; + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); }); diff --git a/tests/components/AuthProviderRetiredRegion.test.tsx b/tests/components/AuthProviderRetiredRegion.test.tsx new file mode 100644 index 00000000..504d48f2 --- /dev/null +++ b/tests/components/AuthProviderRetiredRegion.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import delay from 'delay'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as keytar from 'keytar'; +import { AuthProvider } from '../../source/components/AuthProvider.js'; +import { loadAuthToken } from '../../source/lib/auth.js'; +import { EU_REGION_RETIRED_MESSAGE, setRegion } from '../../source/config.js'; +import { getMockFetchResponse } from '../utils.js'; + +const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + +vi.mock('../../source/lib/auth.js', async () => { + const original = await vi.importActual('../../source/lib/auth.js'); + return { + ...original, + loadAuthToken: vi.fn(), + browserAuth: vi.fn(), + authCallbackServer: vi.fn(), + }; +}); + +vi.mock('keytar', () => { + const keytar = { + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn(), + }; + return { ...keytar, default: keytar }; +}); + +const storedRegion = (region: string | null) => + (keytar.getPassword as any).mockImplementation( + async (_service: string, account: string) => + account === 'PERMIT_REGION' ? region : demoPermitKey, + ); + +// The frame wraps long lines, so compare without whitespace. +const squash = (s: string | undefined) => (s ?? '').replace(/\s+/g, ''); + +describe('AuthProvider with a retired region saved', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + setRegion('us'); + global.fetch = vi.fn(); + (loadAuthToken as any).mockResolvedValue(demoPermitKey); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + setRegion('us'); + }); + + it('stops with the retired-region error before any request (stored token)', async () => { + storedRegion('eu'); + + const { lastFrame } = render( + + Child Component + , + ); + await delay(100); + + expect(squash(lastFrame())).toContain(squash(EU_REGION_RETIRED_MESSAGE)); + expect(lastFrame()).not.toContain('Child Component'); + expect(loadAuthToken).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('stops with the retired-region error before any request (--api-key)', async () => { + storedRegion('eu'); + + const { lastFrame } = render( + + Child Component + , + ); + await delay(100); + + expect(squash(lastFrame())).toContain(squash(EU_REGION_RETIRED_MESSAGE)); + expect(lastFrame()).not.toContain('Child Component'); + expect(fetch).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('still uses the US API when us is saved', async () => { + storedRegion('us'); + (fetch as any).mockResolvedValueOnce({ + ...getMockFetchResponse(), + ok: true, + json: async () => ({ + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }), + status: 200, + }); + + const { lastFrame } = render( + + Child Component + , + ); + await delay(200); + + expect(lastFrame()).toContain('Child Component'); + expect(fetch).toHaveBeenCalled(); + const request = (fetch as any).mock.calls[0][0]; + const url = typeof request === 'string' ? request : request.url; + expect(url.startsWith('https://api.permit.io/')).toBe(true); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/auth-oauth-region.test.ts b/tests/lib/auth-oauth-region.test.ts index 440a84d2..93740f8d 100644 --- a/tests/lib/auth-oauth-region.test.ts +++ b/tests/lib/auth-oauth-region.test.ts @@ -32,26 +32,14 @@ vi.mock('http', () => ({ import * as auth from '../../source/lib/auth.js'; describe('Auth OAuth - Region Support', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + const { setRegion } = await import('../../source/config.js'); + setRegion('us'); }); - describe('OAuth URL Generation', () => { - it('should open browser with Auth0 URL for US region', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('us'); - - await auth.browserAuth(); - - expect(open).toHaveBeenCalledWith( - expect.stringContaining('https://auth.permit.io/authorize'), - ); - }); - - it('should open browser with Auth0 URL for EU region', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('eu'); - + describe('US region', () => { + it('should open browser with the Auth0 URL', async () => { await auth.browserAuth(); expect(open).toHaveBeenCalledWith( @@ -59,155 +47,34 @@ describe('Auth OAuth - Region Support', () => { ); }); - it('should use same Auth0 audience for both US and EU regions', async () => { - const { AUTH0_AUDIENCE } = await import('../../source/config.js'); - - // Auth0 audience should be constant - expect(AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); - }); - - it('should include correct domain parameter for US region', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('us'); - + it('should include the US domain and screen_hint', async () => { await auth.browserAuth(); - // Check that the URL contains the US domain const callArgs = (open as any).mock.calls[0][0]; expect(callArgs).toContain('domain=app.permit.io'); + expect(callArgs).toContain('screen_hint=app.permit.io'); }); - it('should include correct domain parameter for EU region', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('eu'); - - await auth.browserAuth(); - - // Check that the URL contains the EU domain - const callArgs = (open as any).mock.calls[0][0]; - expect(callArgs).toContain('domain=app.eu.permit.io'); - }); - - it('should include shared Auth0 audience in OAuth parameters', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('eu'); - - await auth.browserAuth(); - - // Check that the URL contains the shared audience - const callArgs = (open as any).mock.calls[0][0]; - expect(callArgs).toContain( - 'audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F', - ); - }); - - it('should include screen_hint with correct region domain', async () => { - const { setRegion } = await import('../../source/config.js'); - setRegion('eu'); - - await auth.browserAuth(); - - const callArgs = (open as any).mock.calls[0][0]; - expect(callArgs).toContain('screen_hint=app.eu.permit.io'); - }); - }); - - describe('OAuth Parameters Consistency', () => { - it('should use consistent parameters across regions except domain', async () => { - const { setRegion } = await import('../../source/config.js'); - - // Test US - setRegion('us'); - await auth.browserAuth(); - const usUrl = (open as any).mock.calls[0][0]; - - vi.clearAllMocks(); - - // Test EU - setRegion('eu'); - await auth.browserAuth(); - const euUrl = (open as any).mock.calls[0][0]; - - // Both should have same client_id - expect(usUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); - expect(euUrl).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); - - // Both should have same audience (shared) - expect(usUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); - expect(euUrl).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); - - // Both should have same redirect_uri - expect(usUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); - expect(euUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); - - // Both should have PKCE parameters - expect(usUrl).toContain('code_challenge_method=S256'); - expect(euUrl).toContain('code_challenge_method=S256'); - }); - - it('should only differ in domain and screen_hint between regions', async () => { - const { setRegion } = await import('../../source/config.js'); - - // Test US - setRegion('us'); - await auth.browserAuth(); - const usUrl = (open as any).mock.calls[0][0]; - - vi.clearAllMocks(); - - // Test EU - setRegion('eu'); - await auth.browserAuth(); - const euUrl = (open as any).mock.calls[0][0]; - - // US should have US domain - expect(usUrl).toContain('domain=app.permit.io'); - expect(usUrl).not.toContain('domain=app.eu.permit.io'); - - // EU should have EU domain - expect(euUrl).toContain('domain=app.eu.permit.io'); - expect(euUrl).not.toContain('domain=app.permit.io'); - }); - }); - - describe('Critical Bug Fix Verification', () => { - it('should NOT use region-specific API URL as Auth0 audience (bug fix)', async () => { - const { setRegion } = await import('../../source/config.js'); - - // The bug was using region-specific URL as audience - // Correct behavior: use shared Auth0 audience - setRegion('eu'); + it('should include the shared Auth0 audience and fixed OAuth parameters', async () => { await auth.browserAuth(); const url = (open as any).mock.calls[0][0]; - - // Should NOT contain EU-specific API URL as audience - expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); - - // Should contain shared audience expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + expect(url).toContain('client_id=Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1'); + expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A62419'); + expect(url).toContain('code_challenge_method=S256'); }); + }); - it('should use shared Auth0 audience even when switching regions', async () => { - const { setRegion } = await import('../../source/config.js'); - - // Test multiple region switches - const regions: Array<'us' | 'eu'> = ['us', 'eu', 'us', 'eu']; - - for (const region of regions) { - setRegion(region); - await auth.browserAuth(); - - const url = (open as any).mock.calls[ - (open as any).mock.calls.length - 1 - ][0]; - - // Always use shared audience - expect(url).toContain('audience=https%3A%2F%2Fapi.permit.io%2Fv1%2F'); + describe('retired EU region', () => { + it('should reject browser login for eu without opening the browser', async () => { + const { adoptRegion, RetiredRegionError } = await import( + '../../source/config.js' + ); + expect(() => adoptRegion('eu')).toThrow(RetiredRegionError); - // Never use region-specific API URL - expect(url).not.toContain('audience=https%3A%2F%2Fapi.eu.permit.io'); - } + await expect(auth.browserAuth()).rejects.toThrow(RetiredRegionError); + expect(open).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/lib/auth.test.ts b/tests/lib/auth.test.ts index eefdcfb4..a37f45ed 100644 --- a/tests/lib/auth.test.ts +++ b/tests/lib/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, vi, it, expect } from 'vitest'; +import { describe, vi, it, expect, beforeEach } from 'vitest'; import * as http from 'http'; import { KEYSTORE_PERMIT_SERVICE_NAME, @@ -126,21 +126,27 @@ describe('Browser Auth', () => { }); describe('Region Support in Auth', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { setRegion } = await import('../../source/config'); + setRegion('us'); + }); + it('Should save region to keystore', async () => { const { setPassword } = pkg; - await auth.saveRegion('eu'); + await auth.saveRegion('us'); expect(setPassword).toHaveBeenCalledWith( 'Permit.io', 'PERMIT_REGION', - 'eu', + 'us', ); }); - it('Should load region from keystore', async () => { + it('Should load us region from keystore', async () => { const { getPassword } = pkg; - (getPassword as any).mockResolvedValueOnce('eu'); + (getPassword as any).mockResolvedValueOnce('us'); const region = await auth.loadRegion(); - expect(region).toBe('eu'); + expect(region).toBe('us'); expect(getPassword).toHaveBeenCalledWith('Permit.io', 'PERMIT_REGION'); }); @@ -151,6 +157,18 @@ describe('Region Support in Auth', () => { expect(region).toBe('us'); }); + it('Should reject a stored eu region instead of falling back to us', async () => { + const { getPassword } = pkg; + const { RetiredRegionError, EU_REGION_RETIRED_MESSAGE, getPermitApiUrl } = + await import('../../source/config'); + (getPassword as any).mockResolvedValueOnce('eu'); + await expect(auth.loadRegion()).rejects.toThrow(RetiredRegionError); + (getPassword as any).mockResolvedValueOnce('eu'); + await expect(auth.loadRegion()).rejects.toThrow(EU_REGION_RETIRED_MESSAGE); + // The retired value stays in effect, so no US URL can be built with it. + expect(() => getPermitApiUrl()).toThrow(RetiredRegionError); + }); + it('Should clean region when cleaning auth token', async () => { const { deletePassword } = pkg; await auth.cleanAuthToken(); diff --git a/tests/lib/client-region.test.ts b/tests/lib/client-region.test.ts index b361c1e3..926e3bc0 100644 --- a/tests/lib/client-region.test.ts +++ b/tests/lib/client-region.test.ts @@ -1,42 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import createClient from 'openapi-fetch'; -let currentRegion: 'us' | 'eu' = 'us'; - -const getRegionSubdomain = (region: 'us' | 'eu'): string => { - return region === 'eu' ? 'eu.' : ''; -}; - -// Mock the config module -vi.mock('../../source/config.js', async () => { - return { - getPermitApiUrl: vi.fn(() => { - const subdomain = getRegionSubdomain(currentRegion); - return `https://api.${subdomain}permit.io`; - }), - getPermitOriginUrl: vi.fn(() => { - const subdomain = getRegionSubdomain(currentRegion); - return `https://app.${subdomain}permit.io`; - }), - getCloudPdpUrl: vi.fn(() => { - if (currentRegion === 'eu') { - return 'https://cloudpdp.api.eu-central-1.permit.io'; - } - return 'https://cloudpdp.api.permit.io'; - }), - setRegion: vi.fn((region: 'us' | 'eu') => { - currentRegion = region; - }), - getRegion: vi.fn(() => currentRegion), - }; -}); - -// Mock React hooks +// Mock React hooks so useClient can be called outside a component vi.mock('react', async () => { const React = await vi.importActual('react'); return { ...React, - useCallback: fn => fn, - useMemo: fn => fn(), + useCallback: (fn: unknown) => fn, + useMemo: (fn: () => unknown) => fn(), }; }); @@ -55,86 +26,66 @@ vi.mock('openapi-fetch', () => ({ }), })); +import useClient from '../../source/hooks/useClient.js'; +import { + RetiredRegionError, + adoptRegion, + setRegion, +} from '../../source/config.js'; + describe('useClient - Region Support', () => { beforeEach(() => { vi.clearAllMocks(); - currentRegion = 'us'; + setRegion('us'); }); - describe('Region-Aware URL Functions', () => { - it('should use correct API URL for US region', async () => { - const { getPermitApiUrl } = await import('../../source/config.js'); - expect(getPermitApiUrl()).toBe('https://api.permit.io'); - }); - - it('should use correct API URL for EU region', async () => { - const { setRegion, getPermitApiUrl } = await import( - '../../source/config.js' + describe('US region', () => { + it('builds the API client against the US API and origin', () => { + useClient().authenticatedApiClient(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://api.permit.io', + headers: expect.objectContaining({ + Origin: 'https://app.permit.io', + }), + }), ); - setRegion('eu'); - expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); - }); - - it('should use correct PDP URL for US region', async () => { - const { getCloudPdpUrl } = await import('../../source/config.js'); - expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); }); - it('should use correct PDP URL for EU region', async () => { - const { setRegion, getCloudPdpUrl } = await import( - '../../source/config.js' - ); - setRegion('eu'); - expect(getCloudPdpUrl()).toBe( - 'https://cloudpdp.api.eu-central-1.permit.io', + it('builds the PDP client against the US cloud PDP', () => { + useClient().authenticatedPdpClient(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://cloudpdp.api.permit.io', + }), ); }); + }); - it('should use correct Origin URL for US region', async () => { - const { getPermitOriginUrl } = await import('../../source/config.js'); - expect(getPermitOriginUrl()).toBe('https://app.permit.io'); + describe('retired EU region', () => { + beforeEach(() => { + expect(() => adoptRegion('eu')).toThrow(RetiredRegionError); }); - it('should use correct Origin URL for EU region', async () => { - const { setRegion, getPermitOriginUrl } = await import( - '../../source/config.js' + it('refuses to build the API client instead of falling back to US', () => { + expect(() => useClient().authenticatedApiClient()).toThrow( + RetiredRegionError, ); - setRegion('eu'); - expect(getPermitOriginUrl()).toBe('https://app.eu.permit.io'); + expect(createClient).not.toHaveBeenCalled(); }); - }); - - describe('Region Switching', () => { - it('should update URLs when switching from US to EU', async () => { - const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( - '../../source/config.js' - ); - - // Start with US - expect(getPermitApiUrl()).toBe('https://api.permit.io'); - expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); - // Switch to EU - setRegion('eu'); - expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); - expect(getCloudPdpUrl()).toBe( - 'https://cloudpdp.api.eu-central-1.permit.io', + it('refuses to build the default PDP client instead of falling back to US', () => { + expect(() => useClient().authenticatedPdpClient()).toThrow( + RetiredRegionError, ); + expect(createClient).not.toHaveBeenCalled(); }); - it('should update URLs when switching from EU to US', async () => { - const { setRegion, getPermitApiUrl, getCloudPdpUrl } = await import( - '../../source/config.js' - ); - - // Start with EU - setRegion('eu'); - expect(getPermitApiUrl()).toBe('https://api.eu.permit.io'); - - // Switch to US - setRegion('us'); - expect(getPermitApiUrl()).toBe('https://api.permit.io'); - expect(getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); + it('refuses to build the unauthenticated API client', () => { + expect(() => + useClient().unAuthenticatedApiClient('token', 'cookie'), + ).toThrow(RetiredRegionError); + expect(createClient).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index 1ac42dd5..0f705ffc 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +const US_URLS = { + getPermitApiUrl: 'https://api.permit.io', + getPermitOriginUrl: 'https://app.permit.io', + getAuthPermitDomain: 'app.permit.io', + getCloudPdpUrl: 'https://cloudpdp.api.permit.io', + getPermitApiStatisticsUrl: 'https://pdp-statistics.api.permit.io/v2/stats', + getApiUrl: 'https://api.permit.io/v2/', + getFactsApiUrl: 'https://api.permit.io/v2/facts/', + getApiPdpsConfigUrl: 'https://api.permit.io/v2/pdps/me/config', + getAuthApiUrl: 'https://api.permit.io/v1/', +} as const; + +type UrlGetter = keyof typeof US_URLS; +const URL_GETTERS = Object.keys(US_URLS) as UrlGetter[]; + describe('Config - Region Support', () => { // Reset modules before each test to ensure clean state beforeEach(async () => { @@ -7,206 +22,98 @@ describe('Config - Region Support', () => { delete process.env.PERMIT_REGION; }); - describe('Region Configuration', () => { + describe('US region', () => { it('should default to US region when no env var is set', async () => { const config = await import('../../source/config.js'); expect(config.getRegion()).toBe('us'); }); - it('should use EU region when PERMIT_REGION=eu is set', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getRegion()).toBe('eu'); - }); - it('should use US region when PERMIT_REGION=us is set', async () => { process.env.PERMIT_REGION = 'us'; const config = await import('../../source/config.js'); expect(config.getRegion()).toBe('us'); }); - it('should allow setting region programmatically', async () => { - const config = await import('../../source/config.js'); - config.setRegion('eu'); - expect(config.getRegion()).toBe('eu'); - config.setRegion('us'); - expect(config.getRegion()).toBe('us'); - }); - }); - - describe('US Region URLs', () => { - it('should return correct US API URL', async () => { - process.env.PERMIT_REGION = 'us'; - const config = await import('../../source/config.js'); - expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); - }); - - it('should return correct US origin URL', async () => { - process.env.PERMIT_REGION = 'us'; - const config = await import('../../source/config.js'); - expect(config.getPermitOriginUrl()).toBe('https://app.permit.io'); - }); - - it('should return correct US auth domain', async () => { + it.each(URL_GETTERS)('%s returns the US URL', async getter => { process.env.PERMIT_REGION = 'us'; const config = await import('../../source/config.js'); - expect(config.getAuthPermitDomain()).toBe('app.permit.io'); - }); - - it('should return correct US PDP URL', async () => { - process.env.PERMIT_REGION = 'us'; - const config = await import('../../source/config.js'); - expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); - }); - - it('should return correct US statistics URL', async () => { - process.env.PERMIT_REGION = 'us'; - const config = await import('../../source/config.js'); - expect(config.getPermitApiStatisticsUrl()).toBe( - 'https://pdp-statistics.api.permit.io/v2/stats', - ); - }); - }); - - describe('EU Region URLs', () => { - it('should return correct EU API URL', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); - }); - - it('should return correct EU origin URL', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getPermitOriginUrl()).toBe('https://app.eu.permit.io'); - }); - - it('should return correct EU auth domain', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getAuthPermitDomain()).toBe('app.eu.permit.io'); - }); - - it('should return correct EU PDP URL', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getCloudPdpUrl()).toBe( - 'https://cloudpdp.api.eu-central-1.permit.io', - ); + expect(config[getter]()).toBe(US_URLS[getter]); }); - it('should return correct EU statistics URL', async () => { - process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getPermitApiStatisticsUrl()).toBe( - 'https://pdp-statistics.api.eu-central-1.permit.io/v2/stats', - ); - }); - }); - - describe('Auth0 Configuration', () => { - it('should use same Auth0 audience for all regions', async () => { - // Test US - process.env.PERMIT_REGION = 'us'; - const configUS = await import('../../source/config.js'); - const usAudience = configUS.AUTH0_AUDIENCE; - - vi.resetModules(); - delete process.env.PERMIT_REGION; - - // Test EU - process.env.PERMIT_REGION = 'eu'; - const configEU = await import('../../source/config.js'); - const euAudience = configEU.AUTH0_AUDIENCE; - - expect(usAudience).toBe('https://api.permit.io/v1/'); - expect(euAudience).toBe('https://api.permit.io/v1/'); - expect(usAudience).toBe(euAudience); - }); + it.each(URL_GETTERS)( + '%s returns the US URL when no region is set', + async getter => { + const config = await import('../../source/config.js'); + expect(config[getter]()).toBe(US_URLS[getter]); + }, + ); - it('should have correct Auth0 audience constant', async () => { + it('should have the shared Auth0 audience and auth URL', async () => { const config = await import('../../source/config.js'); expect(config.AUTH0_AUDIENCE).toBe('https://api.permit.io/v1/'); - }); - - it('should have shared auth.permit.io URL', async () => { - const config = await import('../../source/config.js'); expect(config.AUTH_PERMIT_URL).toBe('https://auth.permit.io'); }); }); - describe('API URL Functions', () => { - it('should return correct API URL for default region', async () => { - const config = await import('../../source/config.js'); - expect(config.getApiUrl()).toBe('https://api.permit.io/v2/'); - }); - - it('should return correct API URL for EU region', async () => { + describe('retired EU region', () => { + it.each(['eu', 'EU', ' eu '])( + 'PERMIT_REGION=%j makes getRegion throw RetiredRegionError', + async value => { + process.env.PERMIT_REGION = value; + const config = await import('../../source/config.js'); + expect(() => config.getRegion()).toThrow(config.RetiredRegionError); + expect(() => config.getRegion()).toThrow( + config.EU_REGION_RETIRED_MESSAGE, + ); + }, + ); + + it('importing config with PERMIT_REGION=eu does not throw', async () => { process.env.PERMIT_REGION = 'eu'; - const config = await import('../../source/config.js'); - expect(config.getApiUrl()).toBe('https://api.eu.permit.io/v2/'); + await expect(import('../../source/config.js')).resolves.toBeDefined(); }); - it('should return correct Facts API URL for US', async () => { - const config = await import('../../source/config.js'); - expect(config.getFactsApiUrl()).toBe('https://api.permit.io/v2/facts/'); - }); + it.each(URL_GETTERS)( + '%s throws instead of falling back to US when PERMIT_REGION=eu', + async getter => { + process.env.PERMIT_REGION = 'eu'; + const config = await import('../../source/config.js'); + expect(() => config[getter]()).toThrow(config.RetiredRegionError); + }, + ); - it('should return correct Facts API URL for EU', async () => { - process.env.PERMIT_REGION = 'eu'; + it('adoptRegion("eu") throws and keeps every URL getter failing', async () => { const config = await import('../../source/config.js'); - expect(config.getFactsApiUrl()).toBe( - 'https://api.eu.permit.io/v2/facts/', - ); + expect(() => config.adoptRegion('eu')).toThrow(config.RetiredRegionError); + for (const getter of URL_GETTERS) { + expect(() => config[getter]()).toThrow(config.RetiredRegionError); + } }); - it('should return correct Auth API URL for US', async () => { + it('adoptRegion accepts us and empty values', async () => { const config = await import('../../source/config.js'); - expect(config.getAuthApiUrl()).toBe('https://api.permit.io/v1/'); + expect(config.adoptRegion('us')).toBe('us'); + expect(config.adoptRegion(null)).toBe('us'); + expect(config.adoptRegion(undefined)).toBe('us'); + expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); }); - it('should return correct Auth API URL for EU', async () => { + it('setRegion("us") recovers from a retired region', async () => { process.env.PERMIT_REGION = 'eu'; const config = await import('../../source/config.js'); - expect(config.getAuthApiUrl()).toBe('https://api.eu.permit.io/v1/'); - }); - }); - - describe('Region Switching', () => { - it('should update URLs when region is changed', async () => { - const config = await import('../../source/config.js'); - - // Start with US - expect(config.getRegion()).toBe('us'); - expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); - - // Switch to EU - config.setRegion('eu'); - expect(config.getRegion()).toBe('eu'); - expect(config.getPermitApiUrl()).toBe('https://api.eu.permit.io'); - expect(config.getCloudPdpUrl()).toBe( - 'https://cloudpdp.api.eu-central-1.permit.io', - ); - - // Switch back to US + expect(() => config.getPermitApiUrl()).toThrow(); config.setRegion('us'); - expect(config.getRegion()).toBe('us'); expect(config.getPermitApiUrl()).toBe('https://api.permit.io'); - expect(config.getCloudPdpUrl()).toBe('https://cloudpdp.api.permit.io'); }); - it('should maintain Auth0 audience when switching regions', async () => { + it('the error message tells the user to use the US region and log in again', async () => { const config = await import('../../source/config.js'); - - const initialAudience = config.AUTH0_AUDIENCE; - config.setRegion('eu'); - const euAudience = config.AUTH0_AUDIENCE; - config.setRegion('us'); - const usAudience = config.AUTH0_AUDIENCE; - - expect(initialAudience).toBe('https://api.permit.io/v1/'); - expect(euAudience).toBe('https://api.permit.io/v1/'); - expect(usAudience).toBe('https://api.permit.io/v1/'); + expect(config.EU_REGION_RETIRED_MESSAGE).toContain('EU region'); + expect(config.EU_REGION_RETIRED_MESSAGE).toContain('US region'); + expect(config.EU_REGION_RETIRED_MESSAGE).toContain('permit login'); + expect(config.isRetiredRegion('eu')).toBe(true); + expect(config.isRetiredRegion('us')).toBe(false); + expect(config.isRetiredRegion(undefined)).toBe(false); }); }); }); diff --git a/tests/loginReplacesRetiredRegion.test.tsx b/tests/loginReplacesRetiredRegion.test.tsx new file mode 100644 index 00000000..99c0be00 --- /dev/null +++ b/tests/loginReplacesRetiredRegion.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { vi, expect, it, describe, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import delay from 'delay'; +import * as keytar from 'keytar'; +import Login from '../source/commands/login'; +import { loadRegion } from '../source/lib/auth'; +import { setRegion } from '../source/config'; + +const secret = 'permit_key_'.concat('a'.repeat(97)); + +vi.mock('keytar', () => { + const store = new Map(); + const keytar = { + store, + setPassword: vi.fn( + async (_service: string, account: string, value: string) => { + store.set(account, value); + }, + ), + getPassword: vi.fn( + async (_service: string, account: string) => store.get(account) ?? null, + ), + deletePassword: vi.fn(async (_service: string, account: string) => + store.delete(account), + ), + }; + return { ...keytar, default: keytar }; +}); + +// Stand-ins for the browser login and the environment picker, which need the +// network. They succeed right away so the test exercises what Login saves. +vi.mock('../source/components/LoginFlow.js', async () => { + const { useEffect } = await import('react'); + return { + default: ({ + onSuccess, + }: { + onSuccess: (accessToken: string, cookie: string) => void; + }) => { + useEffect(() => { + onSuccess('access-token', 'cookie'); + }, [onSuccess]); + return null; + }, + }; +}); + +vi.mock('../source/components/EnvironmentSelection.js', async () => { + const { useEffect } = await import('react'); + type ActiveState = { label: string; value: string }; + return { + default: ({ + onComplete, + }: { + onComplete: ( + organisation: ActiveState, + project: ActiveState, + environment: ActiveState, + secret: string, + ) => void; + }) => { + useEffect(() => { + onComplete( + { label: 'Org', value: 'org' }, + { label: 'Proj', value: 'proj' }, + { label: 'Env', value: 'env' }, + secret, + ); + }, [onComplete]); + return null; + }, + }; +}); + +const store = (keytar as unknown as { store: Map }).store; + +describe('Login with a retired region saved in the keychain', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + store.clear(); + store.set('PERMIT_REGION', 'eu'); + setRegion('us'); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + setRegion('us'); + }); + + it('Should replace the saved eu region with us after a successful login', async () => { + const { lastFrame } = render(); + await delay(100); + + expect(lastFrame()).toContain('Logged in to Org'); + expect(store.get('PERMIT_DEFAULT_ENV')).toBe(secret); + expect(store.get('PERMIT_REGION')).toBe('us'); + await expect(loadRegion()).resolves.toBe('us'); + }); + + it('Should fail the login when the region cannot be saved', async () => { + vi.mocked(keytar.setPassword).mockImplementationOnce(async () => {}); + vi.mocked(keytar.setPassword).mockRejectedValueOnce( + new Error('keychain locked'), + ); + + const { lastFrame } = render(); + await delay(200); + + expect(lastFrame()).toContain('Failed to save the region: keychain locked'); + expect(lastFrame()).not.toContain('Logged in to'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/loginRetiredRegion.test.tsx b/tests/loginRetiredRegion.test.tsx new file mode 100644 index 00000000..7ce908fc --- /dev/null +++ b/tests/loginRetiredRegion.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { vi, expect, it, describe } from 'vitest'; +import { render } from 'ink-testing-library'; +import Login from '../source/commands/login'; +import delay from 'delay'; +import * as keytar from 'keytar'; + +vi.mock('keytar', () => { + const keytar = { + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn(), + }; + return { ...keytar, default: keytar }; +}); + +describe('Login Component with a retired region', () => { + it('Should reject --region eu before any request', async () => { + const { EU_REGION_RETIRED_MESSAGE } = await import('../source/config'); + global.fetch = vi.fn(); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + try { + const { lastFrame } = render( + , + ); + await delay(200); + const squash = (s: string | undefined) => (s ?? '').replace(/\s+/g, ''); + expect(squash(lastFrame())).toContain(squash(EU_REGION_RETIRED_MESSAGE)); + expect(lastFrame()).not.toContain('Logging in'); + expect(fetch).not.toHaveBeenCalled(); + expect(keytar.setPassword).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + }); +});