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
21 changes: 21 additions & 0 deletions __tests__/utils/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,4 +536,25 @@ describe('ApiClient', () => {
await expect(client.getOrganizations()).rejects.toThrow(/not yet implemented/);
});
});
describe('axios errors (error.response.status)', () => {
const axiosError = (status: number) => ({ message: `Request failed with status code ${status}`, response: { status } });

it('getApplications maps an axios 401 to the login message', async () => {
mockListApplications.mockRejectedValue(axiosError(401));
const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1');
await expect(client.getApplications()).rejects.toThrow(/Authentication expired/);
});

it('getEnvironments maps an axios 403 to access denied', async () => {
mockListEnvironments.mockRejectedValue(axiosError(403));
const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1', 'app-1');
await expect(client.getEnvironments()).rejects.toThrow(/Access denied/);
});

it('getEnvironmentMetrics maps an axios 401 to the login message', async () => {
mockGetEnvironmentMetrics.mockRejectedValue(axiosError(401));
const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1', 'app-1');
await expect(client.getEnvironmentMetrics({ environmentId: 'prod' })).rejects.toThrow(/Authentication expired/);
});
});
});
4 changes: 4 additions & 0 deletions __tests__/utils/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ jest.unstable_mockModule('../../src/utils/config.js', () => ({
loadMultiPlatformConfig: mockLoadMulti,
saveActivePlatformConfig: mockSaveActive,
savePlatformUpdates: mockSaveNamed,
applyPlatformUpdates: mockSaveNamed,
CREDENTIALS_LOCK_FILE: '/nonexistent/credentials.lock',
CONFIG_FILE: '/nonexistent/credentials',
loadCredentialsLocked: mockLoadMulti,
getActivePlatformConfig: async () => {
const m = await mockLoadMulti();
const { platformInfo, ...auth } = m.platforms[m.activePlatform];
Expand Down
87 changes: 87 additions & 0 deletions __tests__/utils/credentials-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, jest, beforeAll, afterEach, afterAll } from '@jest/globals';
import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, promises as fsp } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

// Real credentials file in a temp config dir.
const home = mkdtempSync(join(tmpdir(), 'qc-credentials-'));
const configDir = join(home, '.quant');
process.env.QUANT_CONFIG_DIR = configDir;
if (!process.env.QUANT_CONFIG_DIR.startsWith(home)) {
throw new Error('refusing to run: QUANT_CONFIG_DIR is not the temp dir');
}
mkdirSync(configDir);

let config: typeof import('../../src/utils/config.js');

beforeAll(async () => {
config = await import('../../src/utils/config.js');
});

afterEach(() => {
jest.restoreAllMocks();
rmSync(config.CONFIG_FILE, { force: true });
});
afterAll(() => rmSync(home, { recursive: true, force: true }));

const info = (id: string) => ({ id, name: id, host: `https://${id}.example` });
const twoPlatforms = () => JSON.stringify({
activePlatform: 'quantcdn',
platforms: {
quantcdn: { token: 'a', host: 'https://quantcdn.example', platformInfo: info('quantcdn') },
quantgov: { token: 'b', host: 'https://quantgov.example', platformInfo: info('quantgov') },
},
});

describe('credentials file save', () => {
it('keeps a symlinked credentials file as a symlink and updates its target', async () => {
const target = join(home, 'dotfiles-credentials');
writeFileSync(target, twoPlatforms());
symlinkSync(target, config.CONFIG_FILE);

await config.savePlatformUpdates('quantcdn', { activeOrganization: 'acme' });

expect(lstatSync(config.CONFIG_FILE).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(target, 'utf-8')).platforms.quantcdn.activeOrganization).toBe('acme');
expect(statSync(target).mode & 0o777).toBe(0o600);
expect(readdirSync(home).filter((f) => f.endsWith('.tmp'))).toEqual([]);
});

it('writes in place when the rename fails with EBUSY (single-file bind mount)', async () => {
writeFileSync(config.CONFIG_FILE, twoPlatforms());
const rename = jest.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }));

await config.savePlatformUpdates('quantgov', { activeOrganization: 'gov-org' });

expect(rename).toHaveBeenCalledTimes(4);
const saved = JSON.parse(readFileSync(config.CONFIG_FILE, 'utf-8'));
expect(saved.platforms.quantgov.activeOrganization).toBe('gov-org');
expect(saved.platforms.quantcdn.token).toBe('a');
expect(statSync(config.CONFIG_FILE).mode & 0o777).toBe(0o600);
expect(readdirSync(configDir).filter((f) => f.endsWith('.tmp'))).toEqual([]);
});

it('refuses to save when the file cannot be parsed, and leaves it unchanged', async () => {
writeFileSync(config.CONFIG_FILE, '{not json');

await expect(config.savePlatformUpdates('quantcdn', { activeOrganization: 'x' })).rejects.toThrow();

expect(readFileSync(config.CONFIG_FILE, 'utf-8')).toBe('{not json');
});

it('migrates an old single-platform file under the lock', async () => {
writeFileSync(config.CONFIG_FILE, JSON.stringify({ token: 'old', host: 'https://dashboard.quantcdn.io' }));

const multi = await config.loadMultiPlatformConfig();

expect(multi.activePlatform).toBe('quantcdn');
expect(JSON.parse(readFileSync(config.CONFIG_FILE, 'utf-8')).platforms.quantcdn.token).toBe('old');
expect(readdirSync(configDir)).toEqual(['credentials']);
});

it('logout removes the file under the lock', async () => {
writeFileSync(config.CONFIG_FILE, twoPlatforms());
await config.clearAuthConfig();
expect(readdirSync(configDir)).toEqual([]);
});
});
104 changes: 104 additions & 0 deletions __tests__/utils/file-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, jest, afterEach, afterAll } from '@jest/globals';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, promises as fsp } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { withFileLock, LOCK_STALE_MS } from '../../src/utils/file-lock.js';

const dir = mkdtempSync(join(tmpdir(), 'qc-file-lock-'));
let n = 0;
const nextLock = () => join(dir, `credentials.${++n}.lock`);
const record = (time: number, nonce = 'other') => JSON.stringify({ nonce, pid: 1, time });

afterEach(() => jest.restoreAllMocks());
afterAll(() => rmSync(dir, { recursive: true, force: true }));

describe('withFileLock', () => {
it('writes a nonce record while held and removes the file after', async () => {
const lockFile = nextLock();
const seen = await withFileLock(lockFile, async () => JSON.parse(readFileSync(lockFile, 'utf-8')));
expect(typeof seen.nonce).toBe('string');
expect(seen.pid).toBe(process.pid);
expect(existsSync(lockFile)).toBe(false);
});

it('runs one holder at a time', async () => {
const lockFile = nextLock();
const events: string[] = [];
const work = (name: string) => withFileLock(lockFile, async () => {
events.push(`${name}-start`);
await new Promise((resolve) => setTimeout(resolve, 50));
events.push(`${name}-end`);
});
await Promise.all([work('a'), work('b')]);
// Either holder can go first, but they must not overlap.
const first = events[0].split('-')[0];
const second = first === 'a' ? 'b' : 'a';
expect(events).toEqual([`${first}-start`, `${first}-end`, `${second}-start`, `${second}-end`]);
});

it('takes over a lock whose holder timestamp is stale', async () => {
const lockFile = nextLock();
writeFileSync(lockFile, record(Date.now() - LOCK_STALE_MS - 1000));
expect(await withFileLock(lockFile, async () => 'ran')).toBe('ran');
expect(existsSync(lockFile)).toBe(false);
});

it('waits for a lock whose holder timestamp is fresh, even with an old mtime', async () => {
const lockFile = nextLock();
writeFileSync(lockFile, record(Date.now()));
const old = new Date(Date.now() - 10 * LOCK_STALE_MS);
await fsp.utimes(lockFile, old, old);
let ran = false;
const pending = withFileLock(lockFile, async () => { ran = true; });
await new Promise((resolve) => setTimeout(resolve, 400));
expect(ran).toBe(false);
rmSync(lockFile);
await pending;
expect(ran).toBe(true);
});

it('does not delete a lock that another process now owns, and verify() fails', async () => {
const lockFile = nextLock();
const taken = record(Date.now(), 'someone-else');
let verifyError: unknown;
await withFileLock(lockFile, async (lock) => {
writeFileSync(lockFile, taken);
verifyError = await lock.verify().catch((error) => error);
});
expect(verifyError).toBeInstanceOf(Error);
expect(readFileSync(lockFile, 'utf-8')).toBe(taken);
});

it('removes the lock file when the record cannot be written', async () => {
const lockFile = nextLock();
const realOpen = fsp.open.bind(fsp);
jest.spyOn(fsp, 'open').mockImplementationOnce(async (...args: any[]) => {
const handle: any = await (realOpen as any)(...args);
handle.writeFile = async () => { throw new Error('disk full'); };
return handle;
});
await expect(withFileLock(lockFile, async () => 'ran')).rejects.toThrow('disk full');
expect(existsSync(lockFile)).toBe(false);
});

it('adds an exit handler while held and removes it on release', async () => {
const before = process.listenerCount('exit');
const during = await withFileLock(nextLock(), async () => process.listenerCount('exit'));
expect(during).toBe(before + 1);
expect(process.listenerCount('exit')).toBe(before);
});

it('touch() writes a new timestamp, and fails when the lock was taken over', async () => {
const lockFile = nextLock();
await withFileLock(lockFile, async (lock) => {
const before = JSON.parse(readFileSync(lockFile, 'utf-8'));
await new Promise((resolve) => setTimeout(resolve, 20));
await lock.touch();
const after = JSON.parse(readFileSync(lockFile, 'utf-8'));
expect(after.nonce).toBe(before.nonce);
expect(after.time).toBeGreaterThan(before.time);
writeFileSync(lockFile, record(Date.now(), 'someone-else'));
await expect(lock.touch()).rejects.toThrow(/Lost the lock/);
});
});
});
Loading
Loading