@redocly/cli 1.29.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/__tests__/commands/push-region.test.js +3 -3
  3. package/lib/auth/__tests__/device-flow.test.js +62 -0
  4. package/lib/auth/__tests__/oauth-client.test.js +93 -0
  5. package/lib/auth/device-flow.d.ts +26 -0
  6. package/lib/auth/device-flow.js +133 -0
  7. package/lib/auth/oauth-client.d.ts +14 -0
  8. package/lib/auth/oauth-client.js +93 -0
  9. package/lib/commands/auth.d.ts +13 -0
  10. package/lib/commands/auth.js +51 -0
  11. package/lib/commands/push.d.ts +1 -1
  12. package/lib/commands/push.js +4 -4
  13. package/lib/index.js +14 -15
  14. package/lib/otel.d.ts +10 -0
  15. package/lib/otel.js +47 -0
  16. package/lib/reunite/api/__tests__/domains.test.js +32 -0
  17. package/lib/{cms → reunite}/api/api-client.d.ts +9 -0
  18. package/lib/{cms → reunite}/api/api-client.js +2 -1
  19. package/lib/reunite/api/domains.d.ts +4 -0
  20. package/lib/reunite/api/domains.js +22 -0
  21. package/lib/reunite/commands/__tests__/push.test.d.ts +1 -0
  22. package/lib/reunite/commands/__tests__/utils.test.d.ts +1 -0
  23. package/lib/types.d.ts +4 -4
  24. package/lib/utils/miscellaneous.d.ts +5 -4
  25. package/lib/utils/miscellaneous.js +14 -14
  26. package/package.json +7 -2
  27. package/src/__tests__/commands/push-region.test.ts +2 -2
  28. package/src/auth/__tests__/device-flow.test.ts +73 -0
  29. package/src/auth/__tests__/oauth-client.test.ts +117 -0
  30. package/src/auth/device-flow.ts +175 -0
  31. package/src/auth/oauth-client.ts +111 -0
  32. package/src/commands/auth.ts +66 -0
  33. package/src/commands/push.ts +3 -3
  34. package/src/index.ts +14 -15
  35. package/src/otel.ts +59 -0
  36. package/src/reunite/api/__tests__/domains.test.ts +41 -0
  37. package/src/{cms → reunite}/api/api-client.ts +1 -1
  38. package/src/reunite/api/domains.ts +23 -0
  39. package/src/types.ts +4 -3
  40. package/src/utils/miscellaneous.ts +20 -18
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/lib/cms/api/__tests__/domains.test.js +0 -13
  43. package/lib/cms/api/domains.d.ts +0 -1
  44. package/lib/cms/api/domains.js +0 -11
  45. package/lib/commands/login.d.ts +0 -9
  46. package/lib/commands/login.js +0 -23
  47. package/src/cms/api/__tests__/domains.test.ts +0 -15
  48. package/src/cms/api/domains.ts +0 -11
  49. package/src/commands/login.ts +0 -34
  50. /package/lib/{cms/api/__tests__/api-keys.test.d.ts → auth/__tests__/device-flow.test.d.ts} +0 -0
  51. /package/lib/{cms/api/__tests__/api.client.test.d.ts → auth/__tests__/oauth-client.test.d.ts} +0 -0
  52. /package/lib/{cms/api/__tests__/domains.test.d.ts → reunite/api/__tests__/api-keys.test.d.ts} +0 -0
  53. /package/lib/{cms → reunite}/api/__tests__/api-keys.test.js +0 -0
  54. /package/lib/{cms/commands/__tests__/push-status.test.d.ts → reunite/api/__tests__/api.client.test.d.ts} +0 -0
  55. /package/lib/{cms → reunite}/api/__tests__/api.client.test.js +0 -0
  56. /package/lib/{cms/commands/__tests__/push.test.d.ts → reunite/api/__tests__/domains.test.d.ts} +0 -0
  57. /package/lib/{cms → reunite}/api/api-keys.d.ts +0 -0
  58. /package/lib/{cms → reunite}/api/api-keys.js +0 -0
  59. /package/lib/{cms → reunite}/api/index.d.ts +0 -0
  60. /package/lib/{cms → reunite}/api/index.js +0 -0
  61. /package/lib/{cms → reunite}/api/types.d.ts +0 -0
  62. /package/lib/{cms → reunite}/api/types.js +0 -0
  63. /package/lib/{cms/commands/__tests__/utils.test.d.ts → reunite/commands/__tests__/push-status.test.d.ts} +0 -0
  64. /package/lib/{cms → reunite}/commands/__tests__/push-status.test.js +0 -0
  65. /package/lib/{cms → reunite}/commands/__tests__/push.test.js +0 -0
  66. /package/lib/{cms → reunite}/commands/__tests__/utils.test.js +0 -0
  67. /package/lib/{cms → reunite}/commands/push-status.d.ts +0 -0
  68. /package/lib/{cms → reunite}/commands/push-status.js +0 -0
  69. /package/lib/{cms → reunite}/commands/push.d.ts +0 -0
  70. /package/lib/{cms → reunite}/commands/push.js +0 -0
  71. /package/lib/{cms → reunite}/commands/utils.d.ts +0 -0
  72. /package/lib/{cms → reunite}/commands/utils.js +0 -0
  73. /package/lib/{cms → reunite}/utils.d.ts +0 -0
  74. /package/lib/{cms → reunite}/utils.js +0 -0
  75. /package/src/{cms → reunite}/api/__tests__/api-keys.test.ts +0 -0
  76. /package/src/{cms → reunite}/api/__tests__/api.client.test.ts +0 -0
  77. /package/src/{cms → reunite}/api/api-keys.ts +0 -0
  78. /package/src/{cms → reunite}/api/index.ts +0 -0
  79. /package/src/{cms → reunite}/api/types.ts +0 -0
  80. /package/src/{cms → reunite}/commands/__tests__/push-status.test.ts +0 -0
  81. /package/src/{cms → reunite}/commands/__tests__/push.test.ts +0 -0
  82. /package/src/{cms → reunite}/commands/__tests__/utils.test.ts +0 -0
  83. /package/src/{cms → reunite}/commands/push-status.ts +0 -0
  84. /package/src/{cms → reunite}/commands/push.ts +0 -0
  85. /package/src/{cms → reunite}/commands/utils.ts +0 -0
  86. /package/src/{cms → reunite}/utils.ts +0 -0
package/lib/otel.js ADDED
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.otelTelemetry = exports.OtelServerTelemetry = void 0;
4
+ const api_1 = require("@opentelemetry/api");
5
+ const resources_1 = require("@opentelemetry/resources");
6
+ const sdk_trace_node_1 = require("@opentelemetry/sdk-trace-node");
7
+ const exporter_trace_otlp_http_1 = require("@opentelemetry/exporter-trace-otlp-http");
8
+ const semantic_conventions_1 = require("@opentelemetry/semantic-conventions");
9
+ const update_version_notifier_1 = require("./utils/update-version-notifier");
10
+ const fetch_with_timeout_1 = require("./utils/fetch-with-timeout");
11
+ const OTEL_TRACES_URL = process.env.OTEL_TRACES_URL || 'https://otel.cloud.redocly.com/v1/traces';
12
+ class OtelServerTelemetry {
13
+ init() {
14
+ const nodeTracerProvider = new sdk_trace_node_1.NodeTracerProvider({
15
+ resource: new resources_1.Resource({
16
+ [semantic_conventions_1.ATTR_SERVICE_NAME]: `redocly-cli`,
17
+ [semantic_conventions_1.ATTR_SERVICE_VERSION]: `@redocly/cli@${update_version_notifier_1.version}`,
18
+ }),
19
+ });
20
+ nodeTracerProvider.addSpanProcessor(new sdk_trace_node_1.SimpleSpanProcessor(new exporter_trace_otlp_http_1.OTLPTraceExporter({
21
+ url: OTEL_TRACES_URL,
22
+ headers: {},
23
+ timeoutMillis: fetch_with_timeout_1.DEFAULT_FETCH_TIMEOUT,
24
+ })));
25
+ nodeTracerProvider.register();
26
+ }
27
+ send(event, data) {
28
+ const time = new Date();
29
+ const eventId = crypto.randomUUID();
30
+ const span = api_1.trace.getTracer('CliTelemetry').startSpan(`event.${event}`, {
31
+ attributes: {
32
+ 'cloudevents.event_client.id': eventId,
33
+ 'cloudevents.event_client.type': event,
34
+ },
35
+ startTime: time,
36
+ });
37
+ for (const [key, value] of Object.entries(data)) {
38
+ const keySnakeCase = key.replace(/([A-Z])/g, '_$1').toLowerCase();
39
+ if (value !== undefined) {
40
+ span.setAttribute(`cloudevents.event_data.${keySnakeCase}`, value);
41
+ }
42
+ }
43
+ span.end(time);
44
+ }
45
+ }
46
+ exports.OtelServerTelemetry = OtelServerTelemetry;
47
+ exports.otelTelemetry = new OtelServerTelemetry();
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const domains_1 = require("../domains");
4
+ const domains_2 = require("../domains");
5
+ describe('getDomain()', () => {
6
+ afterEach(() => {
7
+ delete process.env.REDOCLY_DOMAIN;
8
+ });
9
+ it('should return the domain from environment variable', () => {
10
+ process.env.REDOCLY_DOMAIN = 'test-domain';
11
+ expect((0, domains_1.getDomain)()).toBe('test-domain');
12
+ });
13
+ it('should return the default domain if no domain provided', () => {
14
+ process.env.REDOCLY_DOMAIN = '';
15
+ expect((0, domains_1.getDomain)()).toBe('https://app.cloud.redocly.com');
16
+ });
17
+ });
18
+ describe('getReuniteUrl()', () => {
19
+ it('should return US API URL when US region specified', () => {
20
+ expect((0, domains_2.getReuniteUrl)('us')).toBe('https://app.cloud.redocly.com/api');
21
+ });
22
+ it('should return EU API URL when EU region specified', () => {
23
+ expect((0, domains_2.getReuniteUrl)('eu')).toBe('https://app.cloud.eu.redocly.com/api');
24
+ });
25
+ it('should return custom domain API URL when custom domain specified', () => {
26
+ const customDomain = 'https://custom.domain.com';
27
+ expect((0, domains_2.getReuniteUrl)(customDomain)).toBe('https://custom.domain.com/api');
28
+ });
29
+ it('should return US API URL when no region specified', () => {
30
+ expect((0, domains_2.getReuniteUrl)()).toBe('https://app.cloud.redocly.com/api');
31
+ });
32
+ });
@@ -15,6 +15,15 @@ export declare class ReuniteApiError extends Error {
15
15
  status: number;
16
16
  constructor(message: string, status: number);
17
17
  }
18
+ export declare class ReuniteApiClient implements BaseApiClient {
19
+ protected version: string;
20
+ protected command: string;
21
+ sunsetWarnings: SunsetWarningsBuffer;
22
+ constructor(version: string, command: string);
23
+ request(url: string, options: FetchWithTimeoutOptions): Promise<Response>;
24
+ private collectSunsetWarning;
25
+ private getSunsetDate;
26
+ }
18
27
  declare class RemotesApi {
19
28
  private client;
20
29
  private readonly domain;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ReuniteApi = exports.ReuniteApiError = void 0;
3
+ exports.ReuniteApi = exports.ReuniteApiClient = exports.ReuniteApiError = void 0;
4
4
  exports.streamToBuffer = streamToBuffer;
5
5
  const colorette_1 = require("colorette");
6
6
  const fetch_with_timeout_1 = require("../../utils/fetch-with-timeout");
@@ -59,6 +59,7 @@ class ReuniteApiClient {
59
59
  return Date.parse(sunsetDate);
60
60
  }
61
61
  }
62
+ exports.ReuniteApiClient = ReuniteApiClient;
62
63
  class RemotesApi {
63
64
  constructor(client, domain, apiKey) {
64
65
  this.client = client;
@@ -0,0 +1,4 @@
1
+ import type { Region } from '@redocly/openapi-core';
2
+ export declare const REUNITE_URLS: Record<Region, string>;
3
+ export declare function getDomain(): string;
4
+ export declare function getReuniteUrl(residency?: string): string;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REUNITE_URLS = void 0;
4
+ exports.getDomain = getDomain;
5
+ exports.getReuniteUrl = getReuniteUrl;
6
+ exports.REUNITE_URLS = {
7
+ us: 'https://app.cloud.redocly.com',
8
+ eu: 'https://app.cloud.eu.redocly.com',
9
+ };
10
+ function getDomain() {
11
+ return process.env.REDOCLY_DOMAIN || exports.REUNITE_URLS.us;
12
+ }
13
+ function getReuniteUrl(residency) {
14
+ if (!residency)
15
+ residency = 'us';
16
+ let reuniteUrl = exports.REUNITE_URLS[residency];
17
+ if (!reuniteUrl) {
18
+ reuniteUrl = residency;
19
+ }
20
+ const url = new URL('/api', reuniteUrl).toString();
21
+ return url;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/lib/types.d.ts CHANGED
@@ -3,14 +3,14 @@ import type { ArgumentsCamelCase } from 'yargs';
3
3
  import type { LintOptions } from './commands/lint';
4
4
  import type { BundleOptions } from './commands/bundle';
5
5
  import type { JoinOptions } from './commands/join';
6
- import type { LoginOptions } from './commands/login';
6
+ import type { LoginOptions, LogoutOptions } from './commands/auth';
7
7
  import type { PushOptions } from './commands/push';
8
8
  import type { StatsOptions } from './commands/stats';
9
9
  import type { SplitOptions } from './commands/split';
10
10
  import type { PreviewDocsOptions } from './commands/preview-docs';
11
11
  import type { BuildDocsArgv } from './commands/build-docs/types';
12
- import type { PushOptions as CMSPushOptions } from './cms/commands/push';
13
- import type { PushStatusOptions } from './cms/commands/push-status';
12
+ import type { PushOptions as CMSPushOptions } from './reunite/commands/push';
13
+ import type { PushStatusOptions } from './reunite/commands/push-status';
14
14
  import type { PreviewProjectOptions } from './commands/preview-project/types';
15
15
  import type { TranslationsOptions } from './commands/translations';
16
16
  import type { EjectOptions } from './commands/eject';
@@ -27,7 +27,7 @@ export type Entrypoint = {
27
27
  export declare const outputExtensions: ReadonlyArray<BundleOutputFormat>;
28
28
  export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined;
29
29
  export declare const regionChoices: ReadonlyArray<Region>;
30
- export type CommandOptions = StatsOptions | SplitOptions | JoinOptions | PushOptions | CMSPushOptions | LintOptions | BundleOptions | LoginOptions | PreviewDocsOptions | BuildDocsArgv | PushStatusOptions | PreviewProjectOptions | TranslationsOptions | EjectOptions;
30
+ export type CommandOptions = StatsOptions | SplitOptions | JoinOptions | PushOptions | CMSPushOptions | LintOptions | BundleOptions | LoginOptions | LogoutOptions | PreviewDocsOptions | BuildDocsArgv | PushStatusOptions | PreviewProjectOptions | TranslationsOptions | EjectOptions;
31
31
  export type VerifyConfigOptions = {
32
32
  config?: string;
33
33
  'lint-config'?: RuleSeverity;
@@ -59,17 +59,18 @@ export type ExitCode = 0 | 1 | 2;
59
59
  export type Analytics = {
60
60
  event: string;
61
61
  event_time: string;
62
- logged_in: boolean;
63
- command: string | number;
64
- arguments: Record<string, unknown>;
62
+ logged_in: 'yes' | 'no';
63
+ command: string;
64
+ arguments: string;
65
65
  node_version: string;
66
66
  npm_version: string;
67
+ os_platform: string;
67
68
  version: string;
68
69
  exit_code: ExitCode;
69
70
  environment?: string;
70
71
  environment_ci?: string;
71
72
  raw_input: string;
72
- has_config?: boolean;
73
+ has_config?: 'yes' | 'no';
73
74
  spec_version?: string;
74
75
  spec_keyword?: string;
75
76
  spec_full_version?: string;
@@ -37,6 +37,7 @@ const colorette_1 = require("colorette");
37
37
  const perf_hooks_1 = require("perf_hooks");
38
38
  const glob = require("glob");
39
39
  const fs = require("fs");
40
+ const os = require("os");
40
41
  const readline = require("readline");
41
42
  const stream_1 = require("stream");
42
43
  const child_process_1 = require("child_process");
@@ -48,7 +49,9 @@ const reference_docs_config_schema_1 = require("@redocly/config/lib/reference-do
48
49
  const types_1 = require("../types");
49
50
  const update_version_notifier_1 = require("./update-version-notifier");
50
51
  const push_1 = require("../commands/push");
51
- const fetch_with_timeout_1 = require("./fetch-with-timeout");
52
+ const oauth_client_1 = require("../auth/oauth-client");
53
+ const api_1 = require("../reunite/api");
54
+ const otel_1 = require("../otel");
52
55
  async function getFallbackApisOrExit(argsApis, config) {
53
56
  const { apis } = config;
54
57
  const shouldFallbackToAllDefinitions = !(0, utils_1.isNotEmptyArray)(argsApis) && (0, utils_1.isNotEmptyObject)(apis);
@@ -440,6 +443,7 @@ function cleanColors(input) {
440
443
  // eslint-disable-next-line no-control-regex
441
444
  return input.replace(/\x1b\[\d+m/g, '');
442
445
  }
446
+ otel_1.otelTelemetry.init();
443
447
  async function sendTelemetry(argv, exit_code, has_config, spec_version, spec_keyword, spec_full_version) {
444
448
  try {
445
449
  if (!argv) {
@@ -448,33 +452,29 @@ async function sendTelemetry(argv, exit_code, has_config, spec_version, spec_key
448
452
  const { _: [command], $0: _, ...args } = argv;
449
453
  const event_time = new Date().toISOString();
450
454
  const redoclyClient = new openapi_core_1.RedoclyClient();
451
- const logged_in = redoclyClient.hasTokens();
455
+ const oauthClient = new oauth_client_1.RedoclyOAuthClient('redocly-cli', update_version_notifier_1.version);
456
+ const reuniteUrl = (0, api_1.getReuniteUrl)(argv.residency);
457
+ const logged_in = redoclyClient.hasTokens() || (await oauthClient.isAuthorized(reuniteUrl));
452
458
  const data = {
453
459
  event: 'cli_command',
454
460
  event_time,
455
- logged_in,
456
- command,
457
- arguments: cleanArgs(args),
461
+ logged_in: logged_in ? 'yes' : 'no',
462
+ command: `${command}`,
463
+ arguments: JSON.stringify(cleanArgs(args)),
458
464
  node_version: process.version,
459
465
  npm_version: (0, child_process_1.execSync)('npm -v').toString().replace('\n', ''),
466
+ os_platform: os.platform(),
460
467
  version: update_version_notifier_1.version,
461
468
  exit_code,
462
469
  environment: process.env.REDOCLY_ENVIRONMENT,
463
470
  environment_ci: process.env.CI,
464
471
  raw_input: cleanRawInput(process.argv.slice(2)),
465
- has_config,
472
+ has_config: has_config ? 'yes' : 'no',
466
473
  spec_version,
467
474
  spec_keyword,
468
475
  spec_full_version,
469
476
  };
470
- await (0, fetch_with_timeout_1.default)(`https://api.redocly.com/registry/telemetry/cli`, {
471
- timeout: fetch_with_timeout_1.DEFAULT_FETCH_TIMEOUT,
472
- method: 'POST',
473
- headers: {
474
- 'content-type': 'application/json',
475
- },
476
- body: JSON.stringify(data),
477
- });
477
+ otel_1.otelTelemetry.send(data.command, data);
478
478
  }
479
479
  catch (err) {
480
480
  // Do nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/cli",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "Roman Hotsiy <roman@redocly.com> (https://redocly.com/)"
37
37
  ],
38
38
  "dependencies": {
39
- "@redocly/openapi-core": "1.29.0",
39
+ "@redocly/openapi-core": "1.30.0",
40
40
  "abort-controller": "^3.0.0",
41
41
  "chokidar": "^3.5.1",
42
42
  "colorette": "^1.2.0",
@@ -46,6 +46,11 @@
46
46
  "glob": "^7.1.6",
47
47
  "handlebars": "^4.7.6",
48
48
  "mobx": "^6.0.4",
49
+ "@opentelemetry/api": "1.9.0",
50
+ "@opentelemetry/exporter-trace-otlp-http": "0.53.0",
51
+ "@opentelemetry/resources": "1.26.0",
52
+ "@opentelemetry/sdk-trace-node": "1.26.0",
53
+ "@opentelemetry/semantic-conventions": "1.27.0",
49
54
  "pluralize": "^8.0.0",
50
55
  "react": "^17.0.0 || ^18.2.0 || ^19.0.0",
51
56
  "react-dom": "^17.0.0 || ^18.2.0 || ^19.0.0",
@@ -1,6 +1,6 @@
1
1
  import { getMergedConfig } from '@redocly/openapi-core';
2
2
  import { handlePush } from '../../commands/push';
3
- import { promptClientToken } from '../../commands/login';
3
+ import { promptClientToken } from '../../commands/auth';
4
4
  import { ConfigFixture } from '../fixtures/config';
5
5
  import { Readable } from 'node:stream';
6
6
 
@@ -23,7 +23,7 @@ jest.mock('fs', () => ({
23
23
 
24
24
  // Mock OpenAPI core
25
25
  jest.mock('@redocly/openapi-core');
26
- jest.mock('../../commands/login');
26
+ jest.mock('../../commands/auth');
27
27
  jest.mock('../../utils/miscellaneous');
28
28
 
29
29
  const mockPromptClientToken = promptClientToken as jest.MockedFunction<typeof promptClientToken>;
@@ -0,0 +1,73 @@
1
+ import { RedoclyOAuthDeviceFlow } from '../device-flow';
2
+
3
+ jest.mock('child_process');
4
+
5
+ describe('RedoclyOAuthDeviceFlow', () => {
6
+ const mockBaseUrl = 'https://test.redocly.com';
7
+ const mockClientName = 'test-client';
8
+ const mockVersion = '1.0.0';
9
+ let flow: RedoclyOAuthDeviceFlow;
10
+
11
+ beforeEach(() => {
12
+ flow = new RedoclyOAuthDeviceFlow(mockBaseUrl, mockClientName, mockVersion);
13
+ jest.resetAllMocks();
14
+ });
15
+
16
+ describe('verifyToken', () => {
17
+ it('returns true for valid token', async () => {
18
+ jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
19
+ json: () => Promise.resolve({ user: { id: '123' } }),
20
+ } as Response);
21
+
22
+ const result = await flow.verifyToken('valid-token');
23
+ expect(result).toBe(true);
24
+ });
25
+
26
+ it('returns false for invalid token', async () => {
27
+ jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid token'));
28
+ const result = await flow.verifyToken('invalid-token');
29
+ expect(result).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('verifyApiKey', () => {
34
+ it('returns true for valid API key', async () => {
35
+ jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
36
+ json: () => Promise.resolve({ success: true }),
37
+ } as Response);
38
+
39
+ const result = await flow.verifyApiKey('valid-key');
40
+ expect(result).toBe(true);
41
+ });
42
+
43
+ it('returns false for invalid API key', async () => {
44
+ jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid API key'));
45
+ const result = await flow.verifyApiKey('invalid-key');
46
+ expect(result).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('refreshToken', () => {
51
+ it('successfully refreshes token', async () => {
52
+ const mockResponse = {
53
+ access_token: 'new-token',
54
+ refresh_token: 'new-refresh',
55
+ expires_in: 3600,
56
+ };
57
+ jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
58
+ json: () => Promise.resolve(mockResponse),
59
+ } as Response);
60
+
61
+ const result = await flow.refreshToken('old-refresh-token');
62
+ expect(result).toEqual(mockResponse);
63
+ });
64
+
65
+ it('throws error when refresh fails', async () => {
66
+ jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
67
+ json: () => Promise.resolve({}),
68
+ } as Response);
69
+
70
+ await expect(flow.refreshToken('invalid-refresh')).rejects.toThrow('Failed to refresh token');
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,117 @@
1
+ import { RedoclyOAuthClient } from '../oauth-client';
2
+ import { RedoclyOAuthDeviceFlow } from '../device-flow';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as os from 'node:os';
6
+
7
+ jest.mock('node:fs');
8
+ jest.mock('node:os');
9
+ jest.mock('../device-flow');
10
+
11
+ describe('RedoclyOAuthClient', () => {
12
+ const mockClientName = 'test-client';
13
+ const mockVersion = '1.0.0';
14
+ const mockBaseUrl = 'https://test.redocly.com';
15
+ const mockHomeDir = '/mock/home/dir';
16
+ const mockRedoclyDir = path.join(mockHomeDir, '.redocly');
17
+ let client: RedoclyOAuthClient;
18
+
19
+ beforeEach(() => {
20
+ jest.resetAllMocks();
21
+ (os.homedir as jest.Mock).mockReturnValue(mockHomeDir);
22
+ process.env.HOME = mockHomeDir;
23
+ client = new RedoclyOAuthClient(mockClientName, mockVersion);
24
+ });
25
+
26
+ describe('login', () => {
27
+ it('successfully logs in and saves token', async () => {
28
+ const mockToken = { access_token: 'test-token' };
29
+ const mockDeviceFlow = {
30
+ run: jest.fn().mockResolvedValue(mockToken),
31
+ };
32
+ (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
33
+
34
+ await client.login(mockBaseUrl);
35
+
36
+ expect(mockDeviceFlow.run).toHaveBeenCalled();
37
+ expect(fs.writeFileSync).toHaveBeenCalled();
38
+ });
39
+
40
+ it('throws error when login fails', async () => {
41
+ const mockDeviceFlow = {
42
+ run: jest.fn().mockResolvedValue(null),
43
+ };
44
+ (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
45
+
46
+ await expect(client.login(mockBaseUrl)).rejects.toThrow('Failed to login');
47
+ });
48
+ });
49
+
50
+ describe('logout', () => {
51
+ it('removes token file if it exists', async () => {
52
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
53
+
54
+ await client.logout();
55
+
56
+ expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json'));
57
+ });
58
+
59
+ it('silently fails if token file does not exist', async () => {
60
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
61
+
62
+ await expect(client.logout()).resolves.not.toThrow();
63
+ expect(fs.rmSync).not.toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ describe('isAuthorized', () => {
68
+ it('verifies API key if provided', async () => {
69
+ const mockDeviceFlow = {
70
+ verifyApiKey: jest.fn().mockResolvedValue(true),
71
+ };
72
+ (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
73
+
74
+ const result = await client.isAuthorized(mockBaseUrl, 'test-api-key');
75
+
76
+ expect(result).toBe(true);
77
+ expect(mockDeviceFlow.verifyApiKey).toHaveBeenCalledWith('test-api-key');
78
+ });
79
+
80
+ it('verifies access token if no API key provided', async () => {
81
+ const mockToken = { access_token: 'test-token' };
82
+ const mockDeviceFlow = {
83
+ verifyToken: jest.fn().mockResolvedValue(true),
84
+ };
85
+ (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
86
+ (fs.readFileSync as jest.Mock).mockReturnValue(
87
+ client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
88
+ client['cipher'].final('hex')
89
+ );
90
+
91
+ const result = await client.isAuthorized(mockBaseUrl);
92
+
93
+ expect(result).toBe(true);
94
+ expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
95
+ });
96
+
97
+ it('returns false if token refresh fails', async () => {
98
+ const mockToken = {
99
+ access_token: 'old-token',
100
+ refresh_token: 'refresh-token',
101
+ };
102
+ const mockDeviceFlow = {
103
+ verifyToken: jest.fn().mockResolvedValue(false),
104
+ refreshToken: jest.fn().mockRejectedValue(new Error('Refresh failed')),
105
+ };
106
+ (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
107
+ (fs.readFileSync as jest.Mock).mockReturnValue(
108
+ client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
109
+ client['cipher'].final('hex')
110
+ );
111
+
112
+ const result = await client.isAuthorized(mockBaseUrl);
113
+
114
+ expect(result).toBe(false);
115
+ });
116
+ });
117
+ });