chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
type DefineMap = Record<string, string | undefined>;
|
||||
|
||||
const { createDefineMap } = require('../../build-env') as {
|
||||
createDefineMap: (env: Record<string, string | undefined>) => DefineMap;
|
||||
};
|
||||
|
||||
describe('build env defines', () => {
|
||||
test('filters env keys that are not valid define identifiers', () => {
|
||||
const define = createDefineMap({
|
||||
PINME_API_BASE: 'https://pinme.dev/api/v4',
|
||||
'npm_package_bin_pinme-agent': './dist/index.js',
|
||||
SECRET_KEY: 'dummy',
|
||||
});
|
||||
|
||||
expect(define['process.env.PINME_API_BASE']).toBe(
|
||||
JSON.stringify('https://pinme.dev/api/v4'),
|
||||
);
|
||||
expect(define['process.env.SECRET_KEY']).toBe(JSON.stringify('dummy'));
|
||||
expect(define['process.env.npm_package_bin_pinme-agent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
CliError,
|
||||
createApiError,
|
||||
createConfigError,
|
||||
createCommandError,
|
||||
normalizeCliError,
|
||||
printCliError,
|
||||
printRechargeUrl,
|
||||
} from '../../bin/utils/cliError';
|
||||
|
||||
describe('cliError', () => {
|
||||
test('CliError constructor defaults details and suggestions', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new CliError({
|
||||
summary: 'Plain failure.',
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Plain failure.');
|
||||
expect(error.name).toBe('CliError');
|
||||
expect(error.stage).toBeUndefined();
|
||||
expect(error.details).toEqual([]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test('normalizes API business errors with request context', () => {
|
||||
const error = createApiError(
|
||||
'API request',
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
code: 40001,
|
||||
msg: 'Insufficient wallet balance',
|
||||
},
|
||||
},
|
||||
},
|
||||
['Request: POST /bind_dns'],
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(CliError);
|
||||
expect(error.message).toBe('Insufficient wallet balance');
|
||||
expect(error.details).toContain('Request: POST /bind_dns');
|
||||
expect(error.details).toContain('Business code: 40001');
|
||||
expect(error.details.join('\n')).toMatch(/Recharge URL:/);
|
||||
});
|
||||
|
||||
test('prefers nested API messages in documented order', () => {
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
message: 'Nested message',
|
||||
error: 'Nested error',
|
||||
},
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Nested message');
|
||||
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Array message');
|
||||
});
|
||||
|
||||
test('normalizes HTTP errors without business code', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 503,
|
||||
data: { message: 'Service unavailable' },
|
||||
},
|
||||
message: 'Request failed with status code 503',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Service unavailable');
|
||||
expect(error.details).toContain('HTTP status: 503');
|
||||
expect(error.details.join('\n')).not.toMatch(/Reason:/);
|
||||
});
|
||||
|
||||
test('creates command errors with exit metadata', () => {
|
||||
const error = createCommandError(
|
||||
'frontend build',
|
||||
'npm run build:web',
|
||||
{
|
||||
status: 127,
|
||||
message: 'vite: command not found',
|
||||
},
|
||||
['Run npm install'],
|
||||
);
|
||||
|
||||
expect(error.details).toEqual([
|
||||
'Command: npm run build:web',
|
||||
'Exit code: 127',
|
||||
'Reason: vite: command not found',
|
||||
]);
|
||||
expect(error.suggestions).toEqual(['Run npm install']);
|
||||
});
|
||||
|
||||
test('creates command errors with code and signal metadata without suggestions', () => {
|
||||
const error = createCommandError('worker deploy', 'wrangler deploy', {
|
||||
code: 1,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('worker deploy failed.');
|
||||
expect(error.details).toEqual([
|
||||
'Command: wrangler deploy',
|
||||
'Exit code: 1',
|
||||
'Signal: SIGTERM',
|
||||
]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
test('normalizes unknown thrown values', () => {
|
||||
const error = normalizeCliError({ raw: true }, 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
expect(error.details).toEqual(['Raw error: {"raw":true}']);
|
||||
});
|
||||
|
||||
test('normalizes null and primitive thrown values', () => {
|
||||
expect(normalizeCliError(undefined, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError(null, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError('plain failure', 'Fallback failed.').details).toEqual(
|
||||
['Raw error: plain failure'],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes circular thrown values through string fallback', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const error = normalizeCliError(circular, 'Fallback failed.');
|
||||
|
||||
expect(error.details).toEqual(['Raw error: [object Object]']);
|
||||
});
|
||||
|
||||
test('returns existing CliError instances unchanged', () => {
|
||||
const original = createConfigError('Missing config.', ['Create pinme.toml']);
|
||||
|
||||
expect(normalizeCliError(original, 'Fallback failed.')).toBe(original);
|
||||
});
|
||||
|
||||
test('normalizes regular Error instances with deduped suggestions', () => {
|
||||
const error = normalizeCliError(new Error('Boom'), 'Fallback failed.', [
|
||||
'Retry',
|
||||
'Retry',
|
||||
]);
|
||||
|
||||
expect(error.message).toBe('Boom');
|
||||
expect(error.suggestions).toEqual(['Retry']);
|
||||
});
|
||||
|
||||
test('normalizes Error instances with empty messages to the fallback summary', () => {
|
||||
const error = normalizeCliError(new Error(''), 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
});
|
||||
|
||||
test('normalizes API-shaped thrown objects', () => {
|
||||
const error = normalizeCliError(
|
||||
{
|
||||
config: { method: 'get', url: '/wallet' },
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
error: 'Nested API failure',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback failed.',
|
||||
);
|
||||
|
||||
expect(error.message).toBe('Nested API failure');
|
||||
expect(error.stage).toBe('API request');
|
||||
});
|
||||
|
||||
test('includes non-Axios error code when response data is absent', () => {
|
||||
const error = createApiError('API request', {
|
||||
code: 'ECONNRESET',
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.details).toContain('Error code: ECONNRESET');
|
||||
expect(error.details).not.toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('uses string response data as summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: 'Bad gateway',
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Bad gateway');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
});
|
||||
|
||||
test('includes nested API detail when it differs from the summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: 'Top-level summary',
|
||||
data: {
|
||||
error: 'Nested detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Top-level summary');
|
||||
expect(error.details).toContain('HTTP status: 400');
|
||||
expect(error.details).toContain('Error detail: Nested detail');
|
||||
expect(error.details).not.toContain(
|
||||
'Reason: Request failed with status code 400',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the API stage when no response message exists', () => {
|
||||
const error = createApiError('wallet lookup', {});
|
||||
|
||||
expect(error.message).toBe('wallet lookup failed.');
|
||||
expect(error.stage).toBe('wallet lookup');
|
||||
expect(error.details).toEqual([]);
|
||||
});
|
||||
|
||||
test('includes raw reasons for non-generic API failures', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: { error: 'Gateway wrapper failed' },
|
||||
},
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Gateway wrapper failed');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
expect(error.details).toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('includes stringified business messages when they differ from summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
code: 409,
|
||||
msg: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('123');
|
||||
expect(error.details).toContain('Business code: 409');
|
||||
expect(error.details).toContain('Business message: 123');
|
||||
});
|
||||
|
||||
test('printRechargeUrl writes to stdout by default', () => {
|
||||
const messages: string[] = [];
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printRechargeUrl('https://wallet.pinme.test');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('printCliError writes structured details and suggestions', () => {
|
||||
const originalError = createConfigError('Missing config.', [
|
||||
'Run pinme create app',
|
||||
]);
|
||||
originalError.details = ['Project root: /tmp/app'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(originalError, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Error: Missing config.');
|
||||
expect(messages.join('\n')).toContain('Stage: configuration');
|
||||
expect(messages.join('\n')).toContain('Project root: /tmp/app');
|
||||
expect(messages.join('\n')).toContain('Run pinme create app');
|
||||
});
|
||||
|
||||
test('printCliError prints recharge URLs through the highlighted branch', () => {
|
||||
const error = createConfigError('Needs funds.');
|
||||
error.details = ['Recharge URL: https://wallet.pinme.test'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(error, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
});
|
||||
|
||||
test('printCliError skips next steps when suggestions are empty', () => {
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(createConfigError('No suggestions.'), 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).not.toContain('Next steps:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'PINME_API_BASE',
|
||||
'IPFS_API_URL',
|
||||
'CAR_API_BASE',
|
||||
'PINME_WEB_URL',
|
||||
'MAX_RETRIES',
|
||||
'RETRY_DELAY_MS',
|
||||
'TIMEOUT_MS',
|
||||
'MAX_POLL_TIME_MINUTES',
|
||||
'POLL_INTERVAL_SECONDS',
|
||||
'POLL_TIMEOUT_SECONDS',
|
||||
];
|
||||
|
||||
async function loadConfig(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules();
|
||||
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, env);
|
||||
|
||||
return import('../../bin/utils/config');
|
||||
}
|
||||
|
||||
describe('config', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test('trims trailing slashes from configured base URLs', async () => {
|
||||
const { APP_CONFIG, getPinmeApiUrl, getIpfsApiUrl, getCarApiUrl } =
|
||||
await loadConfig({
|
||||
PINME_API_BASE: 'https://api.pinme.test///',
|
||||
IPFS_API_URL: 'https://ipfs.pinme.test/',
|
||||
CAR_API_BASE: 'https://car.pinme.test/',
|
||||
});
|
||||
|
||||
expect(APP_CONFIG.pinmeApiBase).toBe('https://api.pinme.test');
|
||||
expect(getPinmeApiUrl('root_domain')).toBe(
|
||||
'https://api.pinme.test/root_domain',
|
||||
);
|
||||
expect(getIpfsApiUrl('/upload')).toBe('https://ipfs.pinme.test/upload');
|
||||
expect(getCarApiUrl('car/export')).toBe(
|
||||
'https://car.pinme.test/car/export',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back when numeric environment values are invalid', async () => {
|
||||
const { APP_CONFIG } = await loadConfig({
|
||||
MAX_RETRIES: 'not-a-number',
|
||||
RETRY_DELAY_MS: '25',
|
||||
MAX_POLL_TIME_MINUTES: '2',
|
||||
POLL_INTERVAL_SECONDS: '3',
|
||||
POLL_TIMEOUT_SECONDS: '4',
|
||||
});
|
||||
|
||||
expect(APP_CONFIG.upload.maxRetries).toBe(2);
|
||||
expect(APP_CONFIG.upload.retryDelayMs).toBe(25);
|
||||
expect(APP_CONFIG.upload.maxPollTimeMs).toBe(120000);
|
||||
expect(APP_CONFIG.upload.pollIntervalMs).toBe(3000);
|
||||
expect(APP_CONFIG.upload.pollTimeoutMs).toBe(4000);
|
||||
});
|
||||
|
||||
test('selects test wallet recharge URL for test-like API bases', async () => {
|
||||
const { getWalletRechargeUrl } = await loadConfig({
|
||||
IPFS_API_URL: 'https://test-pinme.example/api',
|
||||
});
|
||||
|
||||
expect(getWalletRechargeUrl()).toContain('test-pinme');
|
||||
});
|
||||
|
||||
test('selects production wallet recharge URL by default', async () => {
|
||||
const { getWalletRechargeUrl } = await loadConfig({
|
||||
IPFS_API_URL: 'https://prod.example/api',
|
||||
});
|
||||
|
||||
expect(getWalletRechargeUrl()).toContain('pinme.eth.limo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
isDnsDomain,
|
||||
normalizeDomain,
|
||||
validateDnsDomain,
|
||||
} from '../../bin/utils/domainValidator';
|
||||
|
||||
describe('domainValidator', () => {
|
||||
test('normalizes protocol and a single trailing slash', () => {
|
||||
expect(normalizeDomain('https://example.com/')).toBe('example.com');
|
||||
expect(normalizeDomain('http://demo.pinme/')).toBe('demo.pinme');
|
||||
expect(normalizeDomain('xhttps://example.com/')).toBe(
|
||||
'xhttps://example.com',
|
||||
);
|
||||
expect(normalizeDomain('https://example.com/path/')).toBe(
|
||||
'example.com/path',
|
||||
);
|
||||
});
|
||||
|
||||
test('detects DNS domains after normalization', () => {
|
||||
expect(isDnsDomain('https://example.com/')).toBe(true);
|
||||
expect(isDnsDomain('my-site')).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts complete DNS domains', () => {
|
||||
expect(validateDnsDomain('example.com')).toEqual({ valid: true });
|
||||
expect(validateDnsDomain('sub.example.co')).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test('rejects incomplete and malformed DNS domains', () => {
|
||||
expect(validateDnsDomain('localhost').valid).toBe(false);
|
||||
expect(validateDnsDomain('example..com').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('-example.com').message).toMatch(/hyphens/);
|
||||
expect(validateDnsDomain('example-.com').message).toMatch(/hyphens/);
|
||||
expect(validateDnsDomain('exa_mple.com').message).toMatch(
|
||||
/letters, numbers, and hyphens/,
|
||||
);
|
||||
expect(validateDnsDomain('example.com.evil1').valid).toBe(false);
|
||||
expect(validateDnsDomain('prefix example.com').valid).toBe(false);
|
||||
expect(validateDnsDomain('example.com/path').valid).toBe(false);
|
||||
expect(validateDnsDomain('example.com?x=1').valid).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects labels longer than 63 characters', () => {
|
||||
const maxLabel = 'a'.repeat(63);
|
||||
const longLabel = 'a'.repeat(64);
|
||||
expect(validateDnsDomain(`${maxLabel}.com`).valid).toBe(true);
|
||||
expect(validateDnsDomain(`${longLabel}.com`).message).toMatch(
|
||||
/63 characters/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects empty labels in multiple positions', () => {
|
||||
expect(validateDnsDomain('.example.com').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('example.com.').message).toMatch(/Consecutive/);
|
||||
expect(validateDnsDomain('example...com').message).toMatch(/Consecutive/);
|
||||
});
|
||||
|
||||
test('property: valid simple domains are accepted', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.stringMatching(/^[a-z0-9]([a-z0-9-]{0,10}[a-z0-9])?$/), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.stringMatching(/^[a-z]{2,10}$/),
|
||||
(labels, tld) => {
|
||||
fc.pre(labels.every((label) => label.length > 0));
|
||||
expect(validateDnsDomain([...labels, tld].join('.')).valid).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
let tempHome: string | undefined;
|
||||
let originalHome: string | undefined;
|
||||
const trackEvent = vi.fn();
|
||||
const getRootDomain = vi.fn(async () => 'pinme.test');
|
||||
|
||||
async function loadHistory(fsMock?: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
tempHome = mkdtempSync(path.join(tmpdir(), 'pinme-history-home-'));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHome;
|
||||
if (fsMock) {
|
||||
vi.doMock('fs-extra', () => ({
|
||||
...fsMock,
|
||||
default: fsMock,
|
||||
}));
|
||||
}
|
||||
vi.doMock('os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('node:os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../bin/utils/tracker', () => ({
|
||||
default: { trackEvent },
|
||||
getTrackErrorReason: (error: unknown) =>
|
||||
error instanceof Error ? error.message : 'unknown_error',
|
||||
}));
|
||||
vi.doMock('../../bin/utils/pinmeApi', () => ({
|
||||
getRootDomain,
|
||||
}));
|
||||
return import('../../bin/utils/history');
|
||||
}
|
||||
|
||||
describe('history', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('os');
|
||||
vi.doUnmock('node:os');
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
originalHome = undefined;
|
||||
vi.doUnmock('fs-extra');
|
||||
vi.doUnmock('../../bin/utils/tracker');
|
||||
vi.doUnmock('../../bin/utils/pinmeApi');
|
||||
vi.clearAllMocks();
|
||||
if (tempHome) {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('saves and reads upload history newest first', async () => {
|
||||
const { saveUploadHistory, getUploadHistory } = await loadHistory();
|
||||
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/one',
|
||||
contentHash: 'bafy-one',
|
||||
previewHash: null,
|
||||
size: 10,
|
||||
fileCount: 1,
|
||||
isDirectory: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/two',
|
||||
filename: 'two',
|
||||
contentHash: 'bafy-two',
|
||||
previewHash: null,
|
||||
size: 20,
|
||||
fileCount: 2,
|
||||
isDirectory: true,
|
||||
pinmeUrl: 'demo',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(getUploadHistory(1)).toMatchObject([
|
||||
{
|
||||
filename: 'two',
|
||||
contentHash: 'bafy-two',
|
||||
fileCount: 2,
|
||||
type: 'directory',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('displays preferred URLs and totals', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 2048,
|
||||
fileCount: 3,
|
||||
isDirectory: true,
|
||||
pinmeUrl: 'demo',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const output = messages.join('\n');
|
||||
expect(output).toContain('Upload History:');
|
||||
expect(output).toContain('1. site');
|
||||
expect(output).toContain('Path: /tmp/site');
|
||||
expect(output).toContain('IPFS CID: bafy-site');
|
||||
expect(output).toContain('https://demo.pinme.test');
|
||||
expect(output).toContain('Total Uploads: 1');
|
||||
expect(output).toContain('Total Files: 3');
|
||||
expect(output).toContain('Total Size: 2.00 KB');
|
||||
expect(output).toContain('Type: Directory');
|
||||
expect(trackEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearUploadHistory empties records', async () => {
|
||||
const { saveUploadHistory, getUploadHistory, clearUploadHistory } =
|
||||
await loadHistory();
|
||||
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
expect(clearUploadHistory()).toBe(true);
|
||||
expect(getUploadHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
test('displayUploadHistory handles missing root domain for bare URLs', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
getRootDomain.mockRejectedValueOnce(new Error('root domain unavailable'));
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
shortUrl: 'short',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('https://short');
|
||||
});
|
||||
|
||||
test('displayUploadHistory reports an empty isolated history', async () => {
|
||||
const { displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
await displayUploadHistory(5);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('No upload history found.');
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
record_count: 0,
|
||||
limit: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('displayUploadHistory prefers DNS URLs over PinMe and short URLs', async () => {
|
||||
const { saveUploadHistory, displayUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
filename: 'site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1024,
|
||||
pinmeUrl: 'demo',
|
||||
shortUrl: 'short',
|
||||
dnsUrl: 'https://docs.example.com/',
|
||||
});
|
||||
await displayUploadHistory(10);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const output = messages.join('\n');
|
||||
expect(output).toContain('URL: https://docs.example.com');
|
||||
expect(output).not.toContain('https://demo.pinme.test');
|
||||
expect(output).not.toContain('https://short.pinme.test');
|
||||
});
|
||||
|
||||
test('getUploadHistory returns an empty list for malformed history files', async () => {
|
||||
const { getUploadHistory } = await loadHistory();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
mkdirSync(path.join(tempHome!, '.pinme'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tempHome!, '.pinme', 'upload-history.json'),
|
||||
'{not json',
|
||||
);
|
||||
|
||||
try {
|
||||
expect(getUploadHistory()).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Error reading upload history:');
|
||||
});
|
||||
|
||||
test('save and clear report false when history storage cannot be written', async () => {
|
||||
const fsMock = {
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
readJsonSync: vi.fn(() => ({ uploads: [] })),
|
||||
writeJsonSync: vi.fn(() => {
|
||||
throw new Error('disk denied');
|
||||
}),
|
||||
};
|
||||
const { saveUploadHistory, clearUploadHistory } = await loadHistory(fsMock);
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
expect(
|
||||
saveUploadHistory({
|
||||
path: '/tmp/site',
|
||||
contentHash: 'bafy-site',
|
||||
previewHash: null,
|
||||
size: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(clearUploadHistory()).toBe(false);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fsMock.mkdirSync).toHaveBeenCalledWith(expect.any(String), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(messages.join('\n')).toContain('Error saving upload history:');
|
||||
expect(messages.join('\n')).toContain('Error clearing upload history:');
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ action: 'clear', reason: 'disk denied' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('formatHistoryUrl normalizes blank, absolute, dotted, and bare values', async () => {
|
||||
const { formatHistoryUrl } = await loadHistory();
|
||||
|
||||
await expect(formatHistoryUrl()).resolves.toBeNull();
|
||||
await expect(formatHistoryUrl(' ')).resolves.toBeNull();
|
||||
await expect(formatHistoryUrl('https://example.com/path/')).resolves.toBe(
|
||||
'https://example.com/path',
|
||||
);
|
||||
await expect(formatHistoryUrl('demo.example')).resolves.toBe(
|
||||
'https://demo.example',
|
||||
);
|
||||
await expect(
|
||||
formatHistoryUrl('demo', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('https://demo.pinme.test');
|
||||
await expect(
|
||||
formatHistoryUrl('http://demo/', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('http://demo.pinme.test');
|
||||
await expect(
|
||||
formatHistoryUrl('http://[bad', {
|
||||
appendRootDomain: true,
|
||||
rootDomain: 'pinme.test',
|
||||
}),
|
||||
).resolves.toBe('http://[bad');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
calculateDirectorySize,
|
||||
checkDirectorySizeLimit,
|
||||
checkFileSizeLimit,
|
||||
formatSize,
|
||||
} from '../../bin/utils/uploadLimits';
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
function makeTempDir(): string {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), 'pinme-upload-limits-'));
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
describe('uploadLimits', () => {
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('checks file size against the default limit', () => {
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'index.html');
|
||||
writeFileSync(filePath, Buffer.alloc(128));
|
||||
|
||||
expect(checkFileSizeLimit(filePath)).toMatchObject({
|
||||
size: 128,
|
||||
exceeds: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('detects file and directory sizes above configured limits', async () => {
|
||||
vi.resetModules();
|
||||
process.env.FILE_SIZE_LIMIT = '0';
|
||||
process.env.DIRECTORY_SIZE_LIMIT = '0';
|
||||
const limits = await import('../../bin/utils/uploadLimits');
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'index.html');
|
||||
writeFileSync(filePath, Buffer.alloc(1));
|
||||
|
||||
expect(limits.checkFileSizeLimit(filePath)).toMatchObject({
|
||||
size: 1,
|
||||
limit: 0,
|
||||
exceeds: true,
|
||||
});
|
||||
expect(limits.checkDirectorySizeLimit(root)).toMatchObject({
|
||||
size: 1,
|
||||
limit: 0,
|
||||
exceeds: true,
|
||||
});
|
||||
delete process.env.FILE_SIZE_LIMIT;
|
||||
delete process.env.DIRECTORY_SIZE_LIMIT;
|
||||
});
|
||||
|
||||
test('treats sizes equal to the configured limit as not exceeding', async () => {
|
||||
vi.resetModules();
|
||||
process.env.FILE_SIZE_LIMIT = '1';
|
||||
process.env.DIRECTORY_SIZE_LIMIT = '1';
|
||||
const limits = await import('../../bin/utils/uploadLimits');
|
||||
const root = makeTempDir();
|
||||
const filePath = path.join(root, 'one-mb.bin');
|
||||
writeFileSync(filePath, Buffer.alloc(1024 * 1024));
|
||||
|
||||
expect(limits.checkFileSizeLimit(filePath).exceeds).toBe(false);
|
||||
expect(limits.checkDirectorySizeLimit(root).exceeds).toBe(false);
|
||||
delete process.env.FILE_SIZE_LIMIT;
|
||||
delete process.env.DIRECTORY_SIZE_LIMIT;
|
||||
});
|
||||
|
||||
test('calculates nested directory size', () => {
|
||||
const root = makeTempDir();
|
||||
mkdirSync(path.join(root, 'assets'), { recursive: true });
|
||||
writeFileSync(path.join(root, 'index.html'), Buffer.alloc(10));
|
||||
writeFileSync(path.join(root, 'assets', 'app.js'), Buffer.alloc(15));
|
||||
|
||||
expect(calculateDirectorySize(root)).toBe(25);
|
||||
expect(checkDirectorySizeLimit(root)).toMatchObject({
|
||||
size: 25,
|
||||
exceeds: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('formats human readable sizes', () => {
|
||||
expect(formatSize(12)).toBe('12 bytes');
|
||||
expect(formatSize(1024)).toBe('1.00 KB');
|
||||
expect(formatSize(2048)).toBe('2.00 KB');
|
||||
expect(formatSize(1024 * 1024)).toBe('1.00 MB');
|
||||
expect(formatSize(3 * 1024 * 1024)).toBe('3.00 MB');
|
||||
expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GB');
|
||||
expect(formatSize(2 * 1024 * 1024 * 1024)).toBe('2.00 GB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const uploadToIpfsSplit = vi.fn();
|
||||
const getAuthConfig = vi.fn();
|
||||
const getRootDomain = vi.fn(async () => 'pinme.test');
|
||||
const getUid = vi.fn(() => 'device-uid');
|
||||
|
||||
vi.mock('../../bin/utils/uploadToIpfsSplit', () => ({
|
||||
default: uploadToIpfsSplit,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/webLogin', () => ({
|
||||
getAuthConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/pinmeApi', () => ({
|
||||
getRootDomain,
|
||||
}));
|
||||
|
||||
vi.mock('../../bin/utils/getDeviceId', () => ({
|
||||
getUid,
|
||||
}));
|
||||
|
||||
async function loadService(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules();
|
||||
process.env.IPFS_PREVIEW_URL =
|
||||
env.IPFS_PREVIEW_URL || 'https://preview.pinme.test/#/preview/';
|
||||
process.env.PROJECT_PREVIEW_URL =
|
||||
env.PROJECT_PREVIEW_URL || 'https://project.pinme.test/';
|
||||
if ('SECRET_KEY' in env) {
|
||||
if (env.SECRET_KEY === undefined) {
|
||||
delete process.env.SECRET_KEY;
|
||||
} else {
|
||||
process.env.SECRET_KEY = env.SECRET_KEY;
|
||||
}
|
||||
} else {
|
||||
delete process.env.SECRET_KEY;
|
||||
}
|
||||
return import('../../bin/services/uploadService');
|
||||
}
|
||||
|
||||
describe('uploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.doUnmock('crypto-js');
|
||||
getRootDomain.mockResolvedValue('pinme.test');
|
||||
getUid.mockReturnValue('device-uid');
|
||||
});
|
||||
|
||||
test('prefers DNS URL over PinMe, short, and management URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{
|
||||
dnsUrl: 'example.com/',
|
||||
pinmeUrl: 'my-site',
|
||||
shortUrl: 'short',
|
||||
},
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
publicUrl: 'https://example.com',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
});
|
||||
|
||||
test('appends root domain for bare PinMe subdomains', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', { pinmeUrl: 'demo' }, undefined, 'uid-1'),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://demo.pinme.test',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps absolute and dotted short URLs without root-domain lookup', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'https://already.example/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://already.example/path/',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'http://already.example/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'http://already.example/path/',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'short.example' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://short.example',
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ shortUrl: 'xhttp://short.example/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://xhttp://short.example/',
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts http PinMe URLs and preserves explicit protocol', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ pinmeUrl: 'http://demo.pinme.test/path/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'http://demo.pinme.test/path',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to protocol-prefixed text for invalid preferred URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: 'bad host/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://bad host',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not treat embedded protocol text as an absolute preferred URL', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: 'xhttp://example.com/' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://xhttp//example.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to bare subdomain when root domain lookup fails', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
getRootDomain.mockRejectedValueOnce(new Error('root domain failed'));
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', { pinmeUrl: 'demo' }, undefined, 'uid-1'),
|
||||
).resolves.toMatchObject({
|
||||
publicUrl: 'https://demo',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores blank preferred URLs and falls back to management URL', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
{ dnsUrl: ' ', pinmeUrl: '', shortUrl: ' ' },
|
||||
undefined,
|
||||
'uid-1',
|
||||
),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to project management URL when project name is present', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', undefined, 'demo-project', 'uid-1'),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://project.pinme.test/demo-project',
|
||||
managementUrl: 'https://project.pinme.test/demo-project',
|
||||
});
|
||||
});
|
||||
|
||||
test('trims project names and falls back to device uid when uid is blank', async () => {
|
||||
const { resolveUploadUrls } = await loadService();
|
||||
|
||||
await expect(
|
||||
resolveUploadUrls('bafybeicid', undefined, ' demo-project ', ' '),
|
||||
).resolves.toEqual({
|
||||
publicUrl: 'https://project.pinme.test/demo-project',
|
||||
managementUrl: 'https://project.pinme.test/demo-project',
|
||||
});
|
||||
expect(getUid).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses secretKey to hide raw CID in preview management URLs', async () => {
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(result.managementUrl).toMatch(
|
||||
/^https:\/\/preview\.pinme\.test\/#\/preview\/.+/,
|
||||
);
|
||||
expect(result.managementUrl).not.toContain('bafybeicid');
|
||||
expect(result.publicUrl).toBe(result.managementUrl);
|
||||
});
|
||||
|
||||
test('secretKey encryption produces URL-safe CID tokens that include uid input', async () => {
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const first = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
const second = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-2',
|
||||
);
|
||||
const firstToken = first.managementUrl.split('/preview/').at(-1)!;
|
||||
const secondToken = second.managementUrl.split('/preview/').at(-1)!;
|
||||
|
||||
expect(firstToken).not.toBe(secondToken);
|
||||
expect(firstToken).not.toContain('bafybeicid');
|
||||
expect(firstToken).not.toMatch(/[+/=]/);
|
||||
expect(secondToken).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
test('secretKey encryption sanitizes plus slash and padding deterministically', async () => {
|
||||
const encrypt = vi.fn((message: string) => ({
|
||||
toString: () => `+/${message}==`,
|
||||
}));
|
||||
vi.doMock('crypto-js', () => ({
|
||||
default: {
|
||||
RC4: { encrypt },
|
||||
},
|
||||
}));
|
||||
const { resolveUploadUrls } = await loadService({ SECRET_KEY: 'secret' });
|
||||
|
||||
const result = await resolveUploadUrls(
|
||||
'bafybeicid',
|
||||
undefined,
|
||||
undefined,
|
||||
'uid-1',
|
||||
);
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith('bafybeicid-uid-1', 'secret');
|
||||
expect(result.managementUrl).toBe(
|
||||
'https://preview.pinme.test/#/preview/-_bafybeicid-uid-1',
|
||||
);
|
||||
expect(result.managementUrl.split('/preview/').at(-1)).not.toMatch(
|
||||
/[+/=]/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('uploadPath rejects when auth config is absent', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue(null);
|
||||
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/Please login first/);
|
||||
expect(uploadToIpfsSplit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uploadPath returns normalized upload result URLs', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue({
|
||||
address: '0xabc',
|
||||
token: 'token',
|
||||
});
|
||||
uploadToIpfsSplit.mockResolvedValue({
|
||||
contentHash: 'bafybeicid',
|
||||
shortUrl: 'short',
|
||||
});
|
||||
|
||||
await expect(uploadPath('/tmp/site', { action: 'upload' })).resolves.toEqual({
|
||||
contentHash: 'bafybeicid',
|
||||
shortUrl: 'short',
|
||||
pinmeUrl: undefined,
|
||||
dnsUrl: undefined,
|
||||
publicUrl: 'https://short.pinme.test',
|
||||
managementUrl: 'https://preview.pinme.test/#/preview/bafybeicid',
|
||||
});
|
||||
expect(uploadToIpfsSplit).toHaveBeenCalledWith('/tmp/site', {
|
||||
action: 'upload',
|
||||
importAsCar: undefined,
|
||||
projectName: undefined,
|
||||
uid: '0xabc',
|
||||
});
|
||||
});
|
||||
|
||||
test('uploadPath rejects upload responses without a content hash', async () => {
|
||||
const { uploadPath } = await loadService();
|
||||
getAuthConfig.mockReturnValue({
|
||||
address: '0xabc',
|
||||
token: 'token',
|
||||
});
|
||||
uploadToIpfsSplit.mockResolvedValue({});
|
||||
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/no content hash/);
|
||||
|
||||
uploadToIpfsSplit.mockResolvedValueOnce(null);
|
||||
await expect(uploadPath('/tmp/site')).rejects.toThrow(/no content hash/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from 'fs-extra';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
let tempHome: string | undefined;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
async function loadWebLogin() {
|
||||
vi.resetModules();
|
||||
tempHome = mkdtempSync(path.join(tmpdir(), 'pinme-web-login-home-'));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHome;
|
||||
vi.doMock('os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
vi.doMock('node:os', () => ({
|
||||
homedir: () => tempHome,
|
||||
default: {
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
}));
|
||||
return import('../../bin/utils/webLogin');
|
||||
}
|
||||
|
||||
describe('webLogin', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('os');
|
||||
vi.doUnmock('node:os');
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
originalHome = undefined;
|
||||
if (tempHome) {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('stores and reads auth tokens with auth headers', async () => {
|
||||
const { setAuthToken, getAuthConfig, getAuthHeaders } = await loadWebLogin();
|
||||
|
||||
expect(setAuthToken('0xabc-jwt-token')).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthConfig()).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthHeaders()).toEqual({
|
||||
'token-address': '0xabc',
|
||||
'authentication-tokens': 'jwt-token',
|
||||
});
|
||||
|
||||
expect(getAuthConfig()).toMatchObject({ address: '0xabc' });
|
||||
});
|
||||
|
||||
test('trims address and token content before storing auth', async () => {
|
||||
const { setAuthToken, getAuthConfig, getAuthHeaders } = await loadWebLogin();
|
||||
|
||||
expect(setAuthToken(' 0xabc - jwt-token ')).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthConfig()).toEqual({
|
||||
address: '0xabc',
|
||||
token: 'jwt-token',
|
||||
});
|
||||
expect(getAuthHeaders()).toEqual({
|
||||
'token-address': '0xabc',
|
||||
'authentication-tokens': 'jwt-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed combined auth tokens', async () => {
|
||||
const { setAuthToken } = await loadWebLogin();
|
||||
|
||||
expect(() => setAuthToken('-jwt-token')).toThrow(
|
||||
/Address or token is empty|Invalid token/,
|
||||
);
|
||||
expect(() => setAuthToken('0xabc-')).toThrow(/Invalid token format/);
|
||||
expect(() => setAuthToken('missingdash')).toThrow(/Invalid token format/);
|
||||
expect(() => setAuthToken(' -jwt-token')).toThrow(
|
||||
'Invalid token content. Address or token is empty.',
|
||||
);
|
||||
expect(() => setAuthToken('0xabc- ')).toThrow(
|
||||
'Invalid token content. Address or token is empty.',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears auth tokens and makes headers unavailable', async () => {
|
||||
const { setAuthToken, clearAuthToken, getAuthConfig, getAuthHeaders } =
|
||||
await loadWebLogin();
|
||||
|
||||
setAuthToken('0xabc-jwt-token');
|
||||
clearAuthToken();
|
||||
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
expect(() => getAuthHeaders()).toThrow('Auth not set. Run: pinme login');
|
||||
});
|
||||
|
||||
test('getAuthConfig returns null for malformed or incomplete auth files', async () => {
|
||||
const { getAuthConfig } = await loadWebLogin();
|
||||
const authDir = path.join(tempHome!, '.pinme');
|
||||
const authFile = path.join(authDir, 'auth.json');
|
||||
fs.ensureDirSync(authDir);
|
||||
|
||||
fs.writeJsonSync(authFile, { address: '0xabc' });
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
|
||||
fs.writeFileSync(authFile, '{bad');
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
});
|
||||
|
||||
test('login delegates to the singleton web login manager', async () => {
|
||||
const { login, webLoginManager } = await loadWebLogin();
|
||||
const authConfig = { address: '0xabc', token: 'jwt-token' };
|
||||
const spy = vi
|
||||
.spyOn(webLoginManager, 'login')
|
||||
.mockResolvedValue(authConfig);
|
||||
|
||||
try {
|
||||
await expect(login()).resolves.toBe(authConfig);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('logout clears auth and reports success', async () => {
|
||||
const { setAuthToken, logout, getAuthConfig } = await loadWebLogin();
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
setAuthToken('0xabc-jwt-token');
|
||||
await logout();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(getAuthConfig()).toBeNull();
|
||||
expect(messages.join('\n')).toContain('Logged out successfully');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user