Merge branch 'main' into feature/webui

This commit is contained in:
Badri Narayanan S
2026-01-07 02:01:23 +05:30
committed by GitHub
11 changed files with 652 additions and 24 deletions

View File

@@ -29,6 +29,7 @@ npm run dev
# Account management
npm run accounts # Interactive account management
npm run accounts:add # Add a new Google account via OAuth
npm run accounts:add -- --no-browser # Add account on headless server (manual code input)
npm run accounts:list # List configured accounts
npm run accounts:verify # Verify account tokens are valid
@@ -43,6 +44,7 @@ npm run test:interleaved # Interleaved thinking
npm run test:images # Image processing
npm run test:caching # Prompt caching
npm run test:crossmodel # Cross-model thinking signatures
npm run test:oauth # OAuth no-browser mode
```
## Architecture
@@ -99,7 +101,8 @@ src/
└── utils/ # Utilities
├── helpers.js # formatDuration, sleep
── logger.js # Structured logging
── logger.js # Structured logging
└── native-module-helper.js # Auto-rebuild for native modules
```
**Key Modules:**
@@ -107,7 +110,7 @@ src/
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
- **src/auth/**: Authentication including Google OAuth, token extraction, and database access
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
@@ -142,6 +145,13 @@ src/
- For Gemini targets: strict validation - drops unknown or mismatched signatures
- For Claude targets: lenient - lets Claude validate its own signatures
**Native Module Auto-Rebuild:**
- When Node.js is updated, native modules like `better-sqlite3` may become incompatible
- The proxy automatically detects `NODE_MODULE_VERSION` mismatch errors
- On detection, it attempts to rebuild the module using `npm rebuild`
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
## Testing Notes
- Tests require the server to be running (`npm start` in separate terminal)

View File

@@ -69,7 +69,9 @@ If you have Antigravity installed and logged in, the proxy will automatically ex
**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)**
Add one or more Google accounts for load balancing:
Add one or more Google accounts for load balancing.
#### Desktop/Laptop (with browser)
```bash
# If installed via npm
@@ -84,7 +86,22 @@ npm run accounts:add
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts.
Manage accounts:
#### Headless Server (Docker, SSH, no desktop)
```bash
# If installed via npm
antigravity-claude-proxy accounts add --no-browser
# If using npx
npx antigravity-claude-proxy accounts add -- --no-browser
# If cloned locally
npm run accounts:add -- --no-browser
```
This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal.
#### Manage accounts
```bash
# List all accounts
@@ -144,12 +161,14 @@ Add this configuration:
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-5-thinking",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-thinking",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-sonnet-4-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-5-thinking"
}
}
```
(Please use **gemini-2.5-flash-lite** as the default haiku model, even if others are claude, as claude code makes several calls via the haiku model for background tasks. If you use claude model for it, you may use you claude usage sooner)
Or to use Gemini models:
```json
@@ -383,4 +402,4 @@ MIT
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=badri-s2001/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badri-s2001/antigravity-claude-proxy&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)

View File

@@ -27,7 +27,8 @@
"test:interleaved": "node tests/test-interleaved-thinking.cjs",
"test:images": "node tests/test-images.cjs",
"test:caching": "node tests/test-caching-streaming.cjs",
"test:crossmodel": "node tests/test-cross-model-thinking.cjs"
"test:crossmodel": "node tests/test-cross-model-thinking.cjs",
"test:oauth": "node tests/test-oauth-no-browser.cjs"
},
"keywords": [
"claude",

View File

@@ -6,10 +6,80 @@
* - Windows compatibility (no CLI dependency)
* - Native performance
* - Synchronous API (simple error handling)
*
* Includes auto-rebuild capability for handling Node.js version updates
* that cause native module incompatibility.
*/
import Database from 'better-sqlite3';
import { createRequire } from 'module';
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
import { logger } from '../utils/logger.js';
import { NativeModuleError } from '../errors.js';
const require = createRequire(import.meta.url);
// Lazy-loaded Database constructor
let Database = null;
let moduleLoadError = null;
/**
* Load the better-sqlite3 module with auto-rebuild on version mismatch
* Uses synchronous require to maintain API compatibility
* @returns {Function} The Database constructor
* @throws {Error} If module cannot be loaded even after rebuild
*/
function loadDatabaseModule() {
// Return cached module if already loaded
if (Database) return Database;
// Re-throw cached error if previous load failed permanently
if (moduleLoadError) throw moduleLoadError;
try {
Database = require('better-sqlite3');
return Database;
} catch (error) {
if (isModuleVersionError(error)) {
logger.warn('[Database] Native module version mismatch detected');
if (attemptAutoRebuild(error)) {
// Clear require cache and retry
try {
const resolvedPath = require.resolve('better-sqlite3');
// Clear the module and all its dependencies from cache
clearRequireCache(resolvedPath, require.cache);
Database = require('better-sqlite3');
logger.success('[Database] Module reloaded successfully after rebuild');
return Database;
} catch (retryError) {
// Rebuild succeeded but reload failed - user needs to restart
moduleLoadError = new NativeModuleError(
'Native module rebuild completed. Please restart the server to apply the fix.',
true, // rebuildSucceeded
true // restartRequired
);
logger.info('[Database] Rebuild succeeded - server restart required');
throw moduleLoadError;
}
} else {
moduleLoadError = new NativeModuleError(
'Failed to auto-rebuild native module. Please run manually:\n' +
' npm rebuild better-sqlite3\n' +
'Or if using npx, find the package location in the error and run:\n' +
' cd /path/to/better-sqlite3 && npm rebuild',
false, // rebuildSucceeded
false // restartRequired
);
throw moduleLoadError;
}
}
// Non-version-mismatch error, just throw it
throw error;
}
}
/**
* Query Antigravity database for authentication status
@@ -18,10 +88,11 @@ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
* @throws {Error} If database doesn't exist, query fails, or no auth status found
*/
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
const Db = loadDatabaseModule();
let db;
try {
// Open database in read-only mode
db = new Database(dbPath, {
db = new Db(dbPath, {
readonly: true,
fileMustExist: true
});
@@ -56,6 +127,10 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
throw error;
}
// Re-throw native module errors from loadDatabaseModule without wrapping
if (error instanceof NativeModuleError) {
throw error;
}
throw new Error(`Failed to read Antigravity database: ${error.message}`);
} finally {
// Always close database connection
@@ -73,7 +148,8 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
let db;
try {
db = new Database(dbPath, {
const Db = loadDatabaseModule();
db = new Db(dbPath, {
readonly: true,
fileMustExist: true
});

View File

@@ -57,6 +57,56 @@ export function getAuthorizationUrl() {
};
}
/**
* Extract authorization code and state from user input.
* User can paste either:
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
* - Just the code parameter: 4/0xxx...
*
* @param {string} input - User input (URL or code)
* @returns {{code: string, state: string|null}} Extracted code and optional state
*/
export function extractCodeFromInput(input) {
if (!input || typeof input !== 'string') {
throw new Error('No input provided');
}
const trimmed = input.trim();
// Check if it looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const url = new URL(trimmed);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code) {
throw new Error('No authorization code found in URL');
}
return { code, state };
} catch (e) {
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
throw e;
}
throw new Error('Invalid URL format');
}
}
// Assume it's a raw code
// Google auth codes typically start with "4/" and are long
if (trimmed.length < 10) {
throw new Error('Input is too short to be a valid authorization code');
}
return { code: trimmed, state: null };
}
/**
* Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code
@@ -81,10 +131,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
<p>Error: ${error}</p>
@@ -98,10 +148,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
<p>State mismatch - possible CSRF attack.</p>
@@ -115,10 +165,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
<p>No authorization code received.</p>
@@ -132,10 +182,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
// Success!
res.writeHead(200, { 'Content-Type': 'text/html' });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<head><title>Authentication Successful</title></head>
<head><meta charset="UTF-8"><title>Authentication Successful</title></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
<p>You can close this window and return to the terminal.</p>
@@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) {
export default {
getAuthorizationUrl,
extractCodeFromInput,
startCallbackServer,
exchangeCode,
refreshAccessToken,

View File

@@ -25,7 +25,8 @@ import {
startCallbackServer,
completeOAuthFlow,
refreshAccessToken,
getUserEmail
getUserEmail,
extractCodeFromInput
} from '../auth/oauth.js';
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
@@ -229,6 +230,63 @@ async function addAccount(existingAccounts) {
}
}
/**
* Add a new account via OAuth with manual code input (no-browser mode)
* For headless servers without a desktop environment
*/
async function addAccountNoBrowser(existingAccounts, rl) {
console.log('\n=== Add Google Account (No-Browser Mode) ===\n');
// Generate authorization URL
const { url, verifier, state } = getAuthorizationUrl();
console.log('Copy the following URL and open it in a browser on another device:\n');
console.log(` ${url}\n`);
console.log('After signing in, you will be redirected to a localhost URL.');
console.log('Copy the ENTIRE redirect URL or just the authorization code.\n');
const input = await rl.question('Paste the callback URL or authorization code: ');
try {
const { code, state: extractedState } = extractCodeFromInput(input);
// Validate state if present
if (extractedState && extractedState !== state) {
console.log('\n⚠ State mismatch detected. This could indicate a security issue.');
console.log('Proceeding anyway as this is manual mode...');
}
console.log('\nExchanging authorization code for tokens...');
const result = await completeOAuthFlow(code, verifier);
// Check if account already exists
const existing = existingAccounts.find(a => a.email === result.email);
if (existing) {
console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`);
existing.refreshToken = result.refreshToken;
existing.projectId = result.projectId;
existing.addedAt = new Date().toISOString();
return null; // Don't add duplicate
}
console.log(`\n✓ Successfully authenticated: ${result.email}`);
if (result.projectId) {
console.log(` Project ID: ${result.projectId}`);
}
return {
email: result.email,
refreshToken: result.refreshToken,
projectId: result.projectId,
addedAt: new Date().toISOString(),
modelRateLimits: {}
};
} catch (error) {
console.error(`\n✗ Authentication failed: ${error.message}`);
return null;
}
}
/**
* Interactive remove accounts flow
*/
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
/**
* Interactive add accounts flow (Main Menu)
* @param {Object} rl - readline interface
* @param {boolean} noBrowser - if true, use manual code input mode
*/
async function interactiveAdd(rl) {
async function interactiveAdd(rl, noBrowser = false) {
if (noBrowser) {
console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n');
}
const accounts = loadAccounts();
if (accounts.length > 0) {
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
return;
}
const newAccount = await addAccount(accounts);
// Use appropriate add function based on mode
const newAccount = noBrowser
? await addAccountNoBrowser(accounts, rl)
: await addAccount(accounts);
if (newAccount) {
accounts.push(newAccount);
saveAccounts(accounts);
@@ -388,9 +456,11 @@ async function verifyAccounts() {
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'add';
const noBrowser = args.includes('--no-browser');
console.log('╔════════════════════════════════════════╗');
console.log('║ Antigravity Proxy Account Manager ║');
console.log('║ Use --no-browser for headless mode ║');
console.log('╚════════════════════════════════════════╝');
const rl = createRL();
@@ -399,7 +469,7 @@ async function main() {
switch (command) {
case 'add':
await ensureServerStopped();
await interactiveAdd(rl);
await interactiveAdd(rl, noBrowser);
break;
case 'list':
await listAccounts();
@@ -418,6 +488,8 @@ async function main() {
console.log(' node src/cli/accounts.js verify Verify account tokens');
console.log(' node src/cli/accounts.js clear Remove all accounts');
console.log(' node src/cli/accounts.js help Show this help');
console.log('\nOptions:');
console.log(' --no-browser Manual authorization code input (for headless servers)');
break;
case 'remove':
await ensureServerStopped();

View File

@@ -76,7 +76,7 @@ export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
// Uses platform-specific path detection
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10

View File

@@ -118,6 +118,23 @@ export class ApiError extends AntigravityError {
}
}
/**
* Native module error (version mismatch, rebuild required)
*/
export class NativeModuleError extends AntigravityError {
/**
* @param {string} message - Error message
* @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded
* @param {boolean} restartRequired - Whether server restart is needed
*/
constructor(message, rebuildSucceeded = false, restartRequired = false) {
super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired });
this.name = 'NativeModuleError';
this.rebuildSucceeded = rebuildSucceeded;
this.restartRequired = restartRequired;
}
}
/**
* Check if an error is a rate limit error
* Works with both custom error classes and legacy string-based errors
@@ -154,6 +171,7 @@ export default {
NoAccountsError,
MaxRetriesError,
ApiError,
NativeModuleError,
isRateLimitError,
isAuthError
};

View File

@@ -0,0 +1,162 @@
/**
* Native Module Helper
* Detects and auto-rebuilds native Node.js modules when they become
* incompatible after a Node.js version update.
*/
import { execSync } from 'child_process';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { logger } from './logger.js';
/**
* Check if an error is a NODE_MODULE_VERSION mismatch error
* @param {Error} error - The error to check
* @returns {boolean} True if it's a version mismatch error
*/
export function isModuleVersionError(error) {
const message = error?.message || '';
return message.includes('NODE_MODULE_VERSION') &&
message.includes('was compiled against a different Node.js version');
}
/**
* Extract the module path from a NODE_MODULE_VERSION error message
* @param {Error} error - The error containing the module path
* @returns {string|null} The path to the .node file, or null if not found
*/
export function extractModulePath(error) {
const message = error?.message || '';
// Match pattern like: "The module '/path/to/module.node'"
const match = message.match(/The module '([^']+\.node)'/);
return match ? match[1] : null;
}
/**
* Find the package root directory from a .node file path
* @param {string} nodeFilePath - Path to the .node file
* @returns {string|null} Path to the package root, or null if not found
*/
export function findPackageRoot(nodeFilePath) {
// Walk up from the .node file to find package.json
let dir = dirname(nodeFilePath);
while (dir) {
const packageJsonPath = join(dir, 'package.json');
if (existsSync(packageJsonPath)) {
return dir;
}
const parentDir = dirname(dir);
// Stop when we've reached the filesystem root (dirname returns same path)
if (parentDir === dir) {
break;
}
dir = parentDir;
}
return null;
}
/**
* Attempt to rebuild a native module
* @param {string} packagePath - Path to the package root directory
* @returns {boolean} True if rebuild succeeded, false otherwise
*/
export function rebuildModule(packagePath) {
try {
logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`);
// Run npm rebuild in the package directory
const output = execSync('npm rebuild', {
cwd: packagePath,
stdio: 'pipe', // Capture output instead of printing
timeout: 120000 // 2 minute timeout
});
// Log rebuild output for debugging
const outputStr = output?.toString().trim();
if (outputStr) {
logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`);
}
logger.success('[NativeModule] Rebuild completed successfully');
return true;
} catch (error) {
// Include stdout/stderr from the failed command for troubleshooting
const stdout = error.stdout?.toString().trim();
const stderr = error.stderr?.toString().trim();
let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`;
if (stdout) {
errorDetails += `\n[NativeModule] stdout: ${stdout}`;
}
if (stderr) {
errorDetails += `\n[NativeModule] stderr: ${stderr}`;
}
logger.error(errorDetails);
return false;
}
}
/**
* Attempt to auto-rebuild a native module from an error
* @param {Error} error - The NODE_MODULE_VERSION error
* @returns {boolean} True if rebuild succeeded, false otherwise
*/
export function attemptAutoRebuild(error) {
const nodePath = extractModulePath(error);
if (!nodePath) {
logger.error('[NativeModule] Could not extract module path from error');
return false;
}
const packagePath = findPackageRoot(nodePath);
if (!packagePath) {
logger.error('[NativeModule] Could not find package root');
return false;
}
logger.warn('[NativeModule] Native module version mismatch detected');
logger.info('[NativeModule] Attempting automatic rebuild...');
return rebuildModule(packagePath);
}
/**
* Recursively clear a module and its dependencies from the require cache
* This is needed after rebuilding a native module to force re-import
* @param {string} modulePath - Resolved path to the module
* @param {object} cache - The require.cache object
* @param {Set} [visited] - Set of already-visited paths to prevent cycles
*/
export function clearRequireCache(modulePath, cache, visited = new Set()) {
if (visited.has(modulePath)) return;
visited.add(modulePath);
const mod = cache[modulePath];
if (!mod) return;
// Recursively clear children first
if (mod.children) {
for (const child of mod.children) {
clearRequireCache(child.id, cache, visited);
}
}
// Remove from parent's children array
if (mod.parent && mod.parent.children) {
const idx = mod.parent.children.indexOf(mod);
if (idx !== -1) {
mod.parent.children.splice(idx, 1);
}
}
// Delete from cache
delete cache[modulePath];
}
export default {
isModuleVersionError,
extractModulePath,
findPackageRoot,
rebuildModule,
attemptAutoRebuild,
clearRequireCache
};

View File

@@ -14,7 +14,9 @@ const tests = [
{ name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' },
{ name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' },
{ name: 'Image Support', file: 'test-images.cjs' },
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' }
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' },
{ name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' },
{ name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' }
];
async function runTest(test) {

View File

@@ -0,0 +1,217 @@
/**
* OAuth No-Browser Mode Unit Tests
*
* Tests the extractCodeFromInput() function which enables OAuth authentication
* on headless servers without a desktop environment.
*
* ============================================================================
* FEATURE: --no-browser OAuth Mode
* ============================================================================
*
* PURPOSE:
* Allow users to add Google accounts on remote servers (headless Linux,
* Docker containers, SSH sessions) where automatic browser opening is
* not possible.
*
* USAGE:
* npm run accounts:add -- --no-browser
*
* USER FLOW:
* 1. User runs command on headless server
* 2. System displays Google OAuth URL
* 3. User opens URL on another device (phone/laptop) with a browser
* 4. User signs in to Google and authorizes the app
* 5. Browser redirects to localhost (page won't load - this is expected)
* 6. User copies the redirect URL or authorization code from address bar
* 7. User pastes into server terminal
* 8. System extracts code using extractCodeFromInput() (tested here)
* 9. Account is added successfully
*
* FUNCTION UNDER TEST:
* extractCodeFromInput(input: string) => { code: string, state: string|null }
*
* Accepts either:
* - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy
* - Raw authorization code: 4/0AQSTgQG...
*
* Throws on:
* - Empty/null input
* - Too short input (< 10 chars)
* - URL with OAuth error parameter
* - URL without code parameter
*
* ============================================================================
*
* Run: node tests/test-oauth-no-browser.cjs
*/
// Note: Using dynamic import because oauth.js is ESM
async function runTests() {
console.log('='.repeat(60));
console.log('OAUTH NO-BROWSER MODE UNIT TESTS');
console.log('Testing: extractCodeFromInput()');
console.log('='.repeat(60));
console.log('');
// Import the ESM module
const { extractCodeFromInput } = await import('../src/auth/oauth.js');
let allPassed = true;
const results = [];
/**
* Helper to run a single test case
* @param {string} name - Test name
* @param {Function} testFn - Test function that returns { passed, message }
*/
async function test(name, testFn) {
try {
const { passed, message } = await testFn();
results.push({ name, passed, message });
const status = passed ? 'PASS' : 'FAIL';
console.log(` [${status}] ${name}`);
if (message) console.log(` ${message}`);
if (!passed) allPassed = false;
} catch (error) {
results.push({ name, passed: false, message: error.message });
console.log(` [FAIL] ${name}`);
console.log(` Error: ${error.message}`);
allPassed = false;
}
}
// ===== Test Group 1: Valid URL Inputs =====
console.log('\n--- Valid URL Inputs ---');
await test('Parse full callback URL with code and state', () => {
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123';
const result = extractCodeFromInput(input);
const passed = result.code === '4/0AQSTg123' && result.state === 'abc123';
return { passed, message: `code=${result.code}, state=${result.state}` };
});
await test('Parse URL with only code (no state)', () => {
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456';
const result = extractCodeFromInput(input);
const passed = result.code === '4/0AQSTg456' && result.state === null;
return { passed, message: `code=${result.code}, state=${result.state}` };
});
await test('Parse HTTPS URL', () => {
const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz';
const result = extractCodeFromInput(input);
const passed = result.code === 'secureCode123';
return { passed, message: `code=${result.code}` };
});
await test('Parse URL with additional query params', () => {
const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email';
const result = extractCodeFromInput(input);
const passed = result.code === 'myCode' && result.state === 'myState';
return { passed, message: `code=${result.code}, state=${result.state}` };
});
// ===== Test Group 2: Raw Code Inputs =====
console.log('\n--- Raw Authorization Code Inputs ---');
await test('Parse raw authorization code (Google format)', () => {
const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const result = extractCodeFromInput(input);
const passed = result.code === input && result.state === null;
return { passed, message: `code length=${result.code.length}` };
});
await test('Parse raw code with whitespace (should trim)', () => {
const input = ' 4/0AQSTgQGcode123 \n';
const result = extractCodeFromInput(input);
const passed = result.code === '4/0AQSTgQGcode123' && result.state === null;
return { passed, message: `trimmed code=${result.code}` };
});
// ===== Test Group 3: Error Cases =====
console.log('\n--- Error Handling ---');
await test('Throw on empty input', () => {
try {
extractCodeFromInput('');
return { passed: false, message: 'Should have thrown' };
} catch (e) {
return { passed: e.message.includes('No input'), message: e.message };
}
});
await test('Throw on null input', () => {
try {
extractCodeFromInput(null);
return { passed: false, message: 'Should have thrown' };
} catch (e) {
return { passed: e.message.includes('No input'), message: e.message };
}
});
await test('Throw on too short code', () => {
try {
extractCodeFromInput('abc');
return { passed: false, message: 'Should have thrown' };
} catch (e) {
return { passed: e.message.includes('too short'), message: e.message };
}
});
await test('Throw on OAuth error in URL', () => {
try {
const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied';
extractCodeFromInput(input);
return { passed: false, message: 'Should have thrown' };
} catch (e) {
return { passed: e.message.includes('OAuth error'), message: e.message };
}
});
await test('Throw on URL without code param', () => {
try {
extractCodeFromInput('http://localhost:51121/callback?state=onlyState');
return { passed: false, message: 'Should have thrown' };
} catch (e) {
return { passed: e.message.includes('No authorization code'), message: e.message };
}
});
// ===== Test Group 4: Edge Cases =====
console.log('\n--- Edge Cases ---');
await test('Handle URL-encoded characters in code', () => {
const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test';
const result = extractCodeFromInput(input);
// URL class automatically decodes
const passed = result.code === '4/0AQSTg++';
return { passed, message: `decoded code=${result.code}` };
});
await test('Accept minimum valid code length (10 chars)', () => {
const input = '1234567890';
const result = extractCodeFromInput(input);
const passed = result.code === input;
return { passed, message: `code=${result.code}` };
});
// ===== Summary =====
console.log('\n' + '='.repeat(60));
console.log('SUMMARY');
console.log('='.repeat(60));
const passed = results.filter(r => r.passed).length;
const failed = results.filter(r => !r.passed).length;
console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
console.log('\n' + '='.repeat(60));
console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED');
console.log('='.repeat(60));
process.exit(allPassed ? 0 : 1);
}
runTests().catch(err => {
console.error('Test suite failed:', err);
process.exit(1);
});