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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ jobs:
TEST_REALM: ${{ vars.TEST_REALM }}
SFCC_ACCOUNT_MANAGER_HOST: ${{ vars.SFCC_ACCOUNT_MANAGER_HOST }}
SFCC_SANDBOX_API_HOST: ${{ vars.SFCC_SANDBOX_API_HOST }}
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
run: |
if [ -n "$SFCC_CLIENT_ID" ] && [ -n "$SFCC_CLIENT_SECRET" ] && [ -n "$TEST_REALM" ] && [ -n "$SFCC_ACCOUNT_MANAGER_HOST" ] && [ -n "$SFCC_SANDBOX_API_HOST" ]; then
if [ -n "$SFCC_CLIENT_ID" ] && [ -n "$SFCC_CLIENT_SECRET" ] && [ -n "$TEST_REALM" ] && [ -n "$SFCC_ACCOUNT_MANAGER_HOST" ] && [ -n "$SFCC_SANDBOX_API_HOST" ] && [ -n "$SFCC_SHORTCODE" ]; then
echo "has-secrets=true" >> $GITHUB_OUTPUT
else
echo "has-secrets=false" >> $GITHUB_OUTPUT
Expand All @@ -61,6 +62,7 @@ jobs:
echo " - TEST_REALM (var): ${TEST_REALM:+✓}" >> $GITHUB_STEP_SUMMARY
echo " - SFCC_ACCOUNT_MANAGER_HOST (var): ${SFCC_ACCOUNT_MANAGER_HOST:+✓}" >> $GITHUB_STEP_SUMMARY
echo " - SFCC_SANDBOX_API_HOST (var): ${SFCC_SANDBOX_API_HOST:+✓}" >> $GITHUB_STEP_SUMMARY
echo " - SFCC_SHORTCODE (var): ${SFCC_SHORTCODE:+✓}" >> $GITHUB_STEP_SUMMARY
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -97,6 +99,7 @@ jobs:
SFCC_ACCOUNT_MANAGER_HOST: ${{ inputs.sfcc_account_manager_host || vars.SFCC_ACCOUNT_MANAGER_HOST }}
SFCC_SANDBOX_API_HOST: ${{ inputs.sfcc_sandbox_api_host || vars.SFCC_SANDBOX_API_HOST }}
TEST_REALM: ${{ inputs.test_realm || vars.TEST_REALM }}
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
# Test configuration
NODE_ENV: test
SFCC_LOG_LEVEL: silent
Expand Down
11 changes: 10 additions & 1 deletion packages/b2c-cli/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export default [
// node_modules must be explicitly ignored because the .gitignore pattern only covers
// packages/b2c-cli/node_modules, not the monorepo root node_modules
{
ignores: ['**/node_modules/**', 'test/functional/fixtures/**/*.js', '**/node_modules/marked-terminal/**'],
ignores: [
'**/node_modules/**',
'test/functional/fixtures/**/*.js',
'**/node_modules/marked-terminal/**',
'test/functional/fixtures/**/*.js',
],
},
includeIgnoreFile(gitignorePath),
...oclif,
Expand All @@ -35,6 +40,10 @@ export default [
},
rules: {
'header/header': ['error', 'block', copyrightHeader],
// Avoid eslint-plugin-import parsing dependency entrypoints (can stack overflow on CJS bundles)
'import/namespace': 'off',
'import/no-named-as-default-member': 'off',
'import/no-named-as-default': 'off',
...sharedRules,
...oclifRules,
},
Expand Down
9 changes: 8 additions & 1 deletion packages/b2c-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,14 @@
"test:ci": "c8 env OCLIF_TEST_ROOT=. mocha --forbid-only --exclude \"test/functional/e2e/**\" --reporter json --reporter-option output=test-results.json \"test/**/*.test.ts\"",
"test:unit": "env OCLIF_TEST_ROOT=. mocha --forbid-only --exclude \"test/functional/e2e/**\" \"test/**/*.test.ts\"",
"test:agent": "env OCLIF_TEST_ROOT=. mocha --forbid-only --reporter min --exclude \"test/functional/e2e/**\" \"test/**/*.test.ts\"",
"test:e2e": "env OCLIF_TEST_ROOT=. mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/functional/e2e/**/*.test.ts\"",
"test:e2e": "env TEST_USE_SHARED_SANDBOX=true OCLIF_TEST_ROOT=. mocha --forbid-only --config test/functional/e2e/.mocharc.json \"test/functional/e2e/**/*.test.ts\"",
"test:e2e:auth": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/auth-token.test.ts\"",
"test:e2e:code": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/code-lifecycle.test.ts\"",
"test:e2e:jobs": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/job-execution.test.ts\"",
"test:e2e:ods": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/ods-lifecycle.test.ts\"",
"test:e2e:sites": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/sites-operations.test.ts\"",
"test:e2e:slas": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/slas-lifecycle.test.ts\"",
"test:e2e:webdav": "env OCLIF_TEST_ROOT=. mocha --forbid-only \"test/functional/e2e/webdav-operations.test.ts\"",
"coverage": "c8 report",
"version": "oclif readme && git add README.md",
"dev": "node ./bin/dev.js"
Expand Down
11 changes: 11 additions & 0 deletions packages/b2c-cli/test/functional/e2e/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"node-option": ["import=tsx"],
"timeout": 30000,
"slow": 5000,
"reporter": "spec",
"color": true,
"bail": false,
"require": [
"./test/functional/e2e/hooks.ts"
]
}
147 changes: 147 additions & 0 deletions packages/b2c-cli/test/functional/e2e/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {expect} from 'chai';
import {execa} from 'execa';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
* E2E Tests for Authentication Token Generation
*/
describe('Auth Token E2E Tests', function () {
this.timeout(120_000); // 2 minutes
this.retries(2);

const CLI_BIN = path.resolve(__dirname, '../../../bin/run.js');

before(function () {
if (!process.env.SFCC_CLIENT_ID || !process.env.SFCC_CLIENT_SECRET) {
this.skip();
}
});

async function runCLI(args: string[], env?: Record<string, string>) {
const result = await execa('node', [CLI_BIN, ...args], {
env: {
...process.env,
...env,
SFCC_LOG_LEVEL: 'silent',
},
reject: false,
});
return result;
}

function decodeJWT(token: string): Record<string, unknown> {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
return JSON.parse(payload);
}

it('should generate a valid OAuth token with correct format, scopes, and expiration', async function () {
const result = await runCLI(['auth:token', '--json']);
expect(result.exitCode).to.equal(0, `Token generation failed: ${result.stderr}`);
expect(result.stdout).to.not.be.empty;

const response = JSON.parse(result.stdout);
expect(response).to.be.an('object');
expect(response.accessToken).to.be.a('string').and.not.be.empty;
expect(response.expires).to.be.a('string');
expect(response.scopes).to.be.an('array').that.is.not.empty;

// Validate JWT format
const payload = decodeJWT(response.accessToken);
expect(payload.sub).to.exist;
expect(payload.exp).to.exist;

// Validate expiration
const now = Math.floor(Date.now() / 1000);
expect(payload.exp as number).to.be.greaterThan(now);
expect((payload.exp as number) - now).to.be.lessThan(86_400);

// Validate expires field matches exp approximately
const expiresDate = new Date(response.expires).getTime() / 1000;
expect(Math.abs(expiresDate - (payload.exp as number))).to.be.lessThan(10);

// Validate scopes
expect(payload.scope, 'Token should contain scope claim').to.exist;
const tokenScopes = Array.isArray(payload.scope) ? payload.scope : (payload.scope as string).split(' ');
for (const s of response.scopes as string[]) {
expect(tokenScopes, `Token should include scope "${s}"`).to.include(s);
}
});

describe('Generate Token With Additional Scopes', function () {
it('should generate a token with allowed additional scopes', async function () {
// Use only scopes your client actually has
const extraScopes = ['profile', 'roles'];

const result = await runCLI(['auth:token', '--scope', extraScopes.join(','), '--json']);

expect(result.exitCode).to.equal(0, `Token generation with extra scopes failed: ${result.stderr}`);

const response = JSON.parse(result.stdout);
const accessToken = response.accessToken as string;
expect(accessToken).to.be.a('string').and.not.be.empty;
expect(response.scopes).to.include.members(extraScopes);

const payload = decodeJWT(accessToken);
expect(payload.scope).to.exist;

const tokenScopes = Array.isArray(payload.scope) ? payload.scope : (payload.scope as string).split(' ');

for (const s of extraScopes) {
expect(tokenScopes, `Token should include scope "${s}"`).to.include(s);
}

console.log(`Token with additional scopes: ${tokenScopes.join(', ')}`);
});
});

describe('Invalid Credentials', function () {
it('should fail with invalid client credentials', async function () {
const result = await runCLI(['auth:token', '--json'], {
SFCC_CLIENT_ID: 'invalid-client-id',
SFCC_CLIENT_SECRET: 'invalid-client-secret',
});

expect(result.exitCode).to.not.equal(0);
expect(result.stderr).to.not.be.empty;
expect(result.stderr).to.match(/401|unauthorized|invalid.*client/i);
});
});

describe('JSON Output Structure', function () {
it('should return correct JSON keys', async function () {
const result = await runCLI(['auth:token', '--json']);
const response = JSON.parse(result.stdout);
expect(response).to.have.all.keys('accessToken', 'expires', 'scopes');
});
});

describe('Default Scopes', function () {
it('should return default scopes when no scopes are requested', async function () {
const result = await runCLI(['auth:token', '--json']);
const response = JSON.parse(result.stdout);
expect(response.scopes.length).to.be.greaterThan(0);
});
});

describe('Non-JSON Output', function () {
it('should output raw token in non-JSON mode', async function () {
const result = await runCLI(['auth:token']);
expect(result.exitCode).to.equal(0);
expect(result.stdout).to.match(/^ey[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/); // JWT regex
});
});
});
Loading
Loading