chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:28 +08:00
commit ba73d7ef36
160 changed files with 37714 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
import path from 'path';
import { describe, expect, test } from 'vitest';
import {
createTempHome,
repoRoot,
runCli,
writeAuthConfig,
} from '../helpers/cliRunner';
describe('pinme CLI', () => {
test('prints help for --help', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['--help'], { home: temp.home });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Usage: pinme');
expect(result.stdout).toContain('upload');
} finally {
await temp.cleanup();
}
});
test('prints package version for --version', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['--version'], { home: temp.home });
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
} finally {
await temp.cleanup();
}
});
test('shows banner and help with no arguments', async () => {
const temp = await createTempHome();
try {
const result = await runCli([], { home: temp.home });
const output = `${result.stdout}\n${result.stderr}`;
expect(result.exitCode).toBe(1);
expect(output).toContain('Usage: pinme');
expect(output).toContain('Examples:');
} finally {
await temp.cleanup();
}
});
test('list reports empty upload history in isolated HOME', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['list'], { home: temp.home });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('No upload history found.');
} finally {
await temp.cleanup();
}
});
test('upload exits before network work when auth is missing', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['upload', 'test/fixtures/site'], {
home: temp.home,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Please login first. Run: pinme login');
} finally {
await temp.cleanup();
}
});
test('bind exits before network work when auth is missing', async () => {
const temp = await createTempHome();
try {
const result = await runCli(
['bind', 'test/fixtures/site', '--domain', 'demo'],
{ home: temp.home },
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Please login first');
} finally {
await temp.cleanup();
}
});
test('bind rejects malformed DNS domains before API calls', async () => {
const temp = await createTempHome();
try {
await writeAuthConfig(temp.home);
const result = await runCli(
['bind', 'test/fixtures/site', '--domain', '-bad.com', '--dns'],
{ home: temp.home },
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Labels cannot start or end with hyphens');
} finally {
await temp.cleanup();
}
});
test('save exits before project work when auth is missing', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['save'], {
home: temp.home,
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Auth not set. Run: pinme login');
} finally {
await temp.cleanup();
}
});
});
+163
View File
@@ -0,0 +1,163 @@
import path from 'path';
import { writeFile } from 'fs/promises';
import { describe, expect, test } from 'vitest';
import {
createTempHome,
repoRoot,
runCli,
writeAuthConfig,
} from '../helpers/cliRunner';
function outputOf(result: { stdout: string; stderr: string }): string {
return `${result.stdout}\n${result.stderr}`;
}
describe('pinme command-level guards', () => {
test('create requires a local login before project creation', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['create', 'demo-project'], {
home: temp.home,
});
expect(result.exitCode).toBe(1);
expect(outputOf(result)).toContain('Auth not set. Run: pinme login');
} finally {
await temp.cleanup();
}
});
test('import requires a local login before reading CAR input', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['import', 'test/fixtures/site'], {
home: temp.home,
});
expect(result.exitCode).toBe(0);
expect(outputOf(result)).toContain('Please login first. Run: pinme login');
} finally {
await temp.cleanup();
}
});
test('import rejects nonexistent paths before upload', async () => {
const temp = await createTempHome();
try {
await writeAuthConfig(temp.home);
const result = await runCli(['import', 'does-not-exist.car'], {
home: temp.home,
});
expect(result.exitCode).toBe(0);
expect(outputOf(result)).toContain('path does-not-exist.car does not exist');
} finally {
await temp.cleanup();
}
});
test('export rejects invalid CID arguments before CAR API calls', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['export', 'not-a-cid', '--output', temp.home], {
home: temp.home,
});
expect(result.exitCode).toBe(0);
expect(outputOf(result)).toContain('Invalid CID format');
} finally {
await temp.cleanup();
}
});
test('export rejects output paths that are files before CAR API calls', async () => {
const temp = await createTempHome();
try {
const filePath = path.join(temp.home, 'not-a-directory');
await writeFile(filePath, 'file');
const result = await runCli(
['export', 'bafyvalidcid', '--output', filePath],
{ home: temp.home },
);
expect(result.exitCode).toBe(0);
expect(outputOf(result)).toContain('exists but is not a directory');
} finally {
await temp.cleanup();
}
});
test('delete requires a local login before resolving project deletion', async () => {
const temp = await createTempHome();
try {
const result = await runCli(['delete', 'demo-project', '--force'], {
home: temp.home,
});
expect(result.exitCode).toBe(1);
expect(outputOf(result)).toContain('Auth not set. Run: pinme login');
} finally {
await temp.cleanup();
}
});
test('delete requires a project name when no pinme.toml is present', async () => {
const temp = await createTempHome();
try {
await writeAuthConfig(temp.home);
const result = await runCli(['delete', '--force'], {
home: temp.home,
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
});
expect(result.exitCode).toBe(1);
expect(outputOf(result)).toContain('Cannot find project name');
} finally {
await temp.cleanup();
}
});
test.each([
['save', ['save'], 'Auth not set. Run: pinme login'],
['update-web', ['update-web'], 'Auth not set. Run: pinme login'],
['update-worker', ['update-worker'], 'Auth not set. Run: pinme login'],
['update-db', ['update-db'], 'Auth not set. Run: pinme login'],
])('%s requires login before project work', async (_name, args, message) => {
const temp = await createTempHome();
try {
const result = await runCli(args, {
home: temp.home,
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
});
expect(result.exitCode).toBe(1);
expect(outputOf(result)).toContain(message);
} finally {
await temp.cleanup();
}
});
test.each([
['save', ['save'], 'pinme.toml` not found'],
['update-web', ['update-web'], 'pinme.toml` not found'],
['update-worker', ['update-worker'], 'pinme.toml` not found'],
['update-db', ['update-db'], 'pinme.toml` not found'],
])(
'%s validates project config before build or deploy work',
async (_name, args, message) => {
const temp = await createTempHome();
try {
await writeAuthConfig(temp.home);
const result = await runCli(args, {
home: temp.home,
cwd: path.join(repoRoot, 'test', 'fixtures', 'site'),
});
expect(result.exitCode).toBe(1);
expect(outputOf(result)).toContain(message);
} finally {
await temp.cleanup();
}
},
);
});
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
Fixture project dist
</body>
</html>
+1
View File
@@ -0,0 +1 @@
project_name = "fixture-project"
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>PinMe fixture</title>
</head>
<body>
<h1>Fixture site</h1>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { mkdir, rm, writeFile } from 'fs/promises';
import { execaNode } from 'execa';
import { dir } from 'tmp-promise';
import { build } from 'esbuild';
import packageJson from '../../package.json';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(__dirname, '..', '..');
export const cliEntry = path.join(repoRoot, 'dist', 'index.js');
export interface TempHome {
home: string;
cleanup: () => Promise<void>;
}
export async function createTempHome(): Promise<TempHome> {
const temp = await dir({
prefix: 'pinme-cli-home-',
unsafeCleanup: true,
});
return {
home: temp.path,
cleanup: temp.cleanup,
};
}
export async function writeAuthConfig(home: string): Promise<void> {
const configDir = path.join(home, '.pinme');
await mkdir(configDir, { recursive: true });
await writeFile(
path.join(configDir, 'auth.json'),
JSON.stringify(
{
address: '0x1234567890abcdef',
token: 'test-token',
},
null,
2,
),
);
}
export function runCli(
args: string[],
options: {
home: string;
cwd?: string;
timeout?: number;
env?: Record<string, string>;
cliPath?: string;
},
): any {
return execaNode(options.cliPath || cliEntry, args, {
cwd: options.cwd || repoRoot,
reject: false,
timeout: options.timeout || 15000,
env: {
HOME: options.home,
USERPROFILE: options.home,
PINME_TRACKING_DISABLED: '1',
PINME_API_BASE: 'http://127.0.0.1:9',
IPFS_API_URL: 'http://127.0.0.1:9',
CAR_API_BASE: 'http://127.0.0.1:9',
IPFS_PREVIEW_URL: 'https://preview.pinme.test/#/preview/',
PROJECT_PREVIEW_URL: 'https://project.pinme.test/',
PINME_WEB_URL: 'https://app.pinme.test',
...options.env,
},
});
}
export async function buildCliWithEnv(
env: Record<string, string>,
): Promise<{ cliPath: string; cleanup: () => Promise<void> }> {
const outfile = path.join(
repoRoot,
'dist',
`.test-cli-${process.pid}-${Date.now()}.js`,
);
const define: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
define[`process.env.${key}`] = JSON.stringify(value);
}
await build({
entryPoints: [path.join(repoRoot, 'bin', 'index.ts')],
outfile,
bundle: true,
platform: 'node',
target: 'node14',
format: 'cjs',
external: Object.keys(packageJson.dependencies || {}).filter(
(dependency) => dependency !== 'axios',
),
banner: { js: '#!/usr/bin/env node' },
logLevel: 'silent',
define,
});
return {
cliPath: outfile,
cleanup: () => rm(outfile, { force: true }),
};
}
+75
View File
@@ -0,0 +1,75 @@
import http, { type IncomingMessage, type ServerResponse } from 'http';
export interface RecordedRequest {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: Buffer;
}
export interface LocalHttpServer {
baseUrl: string;
requests: RecordedRequest[];
close: () => Promise<void>;
}
export async function startLocalHttpServer(
handler: (
request: RecordedRequest,
response: ServerResponse,
) => void | Promise<void>,
): Promise<LocalHttpServer> {
const requests: RecordedRequest[] = [];
const server = http.createServer(
async (request: IncomingMessage, response: ServerResponse) => {
const chunks: Buffer[] = [];
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
request.on('end', async () => {
const recorded: RecordedRequest = {
method: request.method || 'GET',
url: request.url || '/',
headers: request.headers,
body: Buffer.concat(chunks),
};
requests.push(recorded);
try {
await handler(recorded, response);
} catch (error: any) {
response.writeHead(500, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
code: 500,
msg: error?.message || 'local test server error',
}),
);
}
});
},
);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to start local HTTP server');
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}
+233
View File
@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import nock from 'nock';
async function loadApiClient(env: Record<string, string> = {}) {
vi.resetModules();
vi.doUnmock('../../bin/utils/webLogin');
process.env.PINME_API_BASE = env.PINME_API_BASE || 'https://api.pinme.test';
return import('../../bin/utils/apiClient');
}
async function loadApiClientWithAuthMock() {
vi.resetModules();
process.env.PINME_API_BASE = 'https://api.pinme.test';
vi.doMock('../../bin/utils/webLogin', () => ({
getAuthHeaders: () => ({
'token-address': '0xabc',
'authentication-tokens': 'secret-token',
}),
}));
return import('../../bin/utils/apiClient');
}
async function loadApiClientWithThrowingAuthMock() {
vi.resetModules();
process.env.PINME_API_BASE = 'https://api.pinme.test';
vi.doMock('../../bin/utils/webLogin', () => ({
getAuthHeaders: () => {
throw new Error('auth config unreadable');
},
}));
return import('../../bin/utils/apiClient');
}
describe('apiClient', () => {
beforeEach(() => {
nock.cleanAll();
});
test('returns successful business responses', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.get('/root_domain')
.reply(200, { code: 200, data: { domain: 'pinme.test' } });
const response = await createPinmeApiClient().get('/root_domain');
expect(response.data.data.domain).toBe('pinme.test');
expect(nock.isDone()).toBe(true);
});
test('turns non-200 business codes into CliError', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.post('/bind_pinme_domain')
.reply(200, { code: 500, msg: 'Domain is taken' });
await expect(
createPinmeApiClient().post('/bind_pinme_domain', {
domain_name: 'demo',
hash: 'bafy',
}),
).rejects.toMatchObject({
name: 'CliError',
stage: 'API request',
message: 'Domain is taken',
details: ['Request: POST /bind_pinme_domain', 'Business code: 500'],
});
});
test('wraps HTTP failures with request context', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test')
.get('/my_domains')
.reply(503, { message: 'Temporarily unavailable' });
await expect(createPinmeApiClient().get('/my_domains')).rejects.toMatchObject(
{
name: 'CliError',
stage: 'API request',
message: 'Temporarily unavailable',
details: ['Request: GET /my_domains', 'HTTP status: 503'],
},
);
});
test('injects auth headers from local auth config by default', async () => {
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
let capturedHeaders: Record<string, string | string[] | undefined> = {};
nock('https://api.pinme.test')
.get('/my_domains')
.reply(function () {
capturedHeaders = this.req.headers;
return [200, { code: 200, data: [] }];
});
await expect(createPinmeApiClient().get('/my_domains')).resolves.toMatchObject(
{
data: { code: 200, data: [] },
},
);
expect(capturedHeaders['token-address']).toBe('0xabc');
expect(capturedHeaders['authentication-tokens']).toBe('secret-token');
});
test('omits auth headers when includeAuth is false', async () => {
const { createPinmeApiClient } = await loadApiClientWithAuthMock();
nock('https://api.pinme.test', {
badheaders: ['token-address', 'authentication-tokens'],
})
.get('/public')
.reply(200, { ok: true });
const response = await createPinmeApiClient({ includeAuth: false }).get(
'/public',
);
expect(response.data).toEqual({ ok: true });
});
test('merges custom headers over defaults', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test', {
reqheaders: {
'user-agent': 'Custom-Agent',
'x-test-suite': 'api-client',
},
})
.get('/headers')
.reply(200, { ok: true });
await createPinmeApiClient({
includeAuth: false,
headers: {
'User-Agent': 'Custom-Agent',
'X-Test-Suite': 'api-client',
},
}).get('/headers');
expect(nock.isDone()).toBe(true);
});
test('sends default JSON CLI headers', async () => {
const { createPinmeApiClient } = await loadApiClient();
let capturedHeaders: Record<string, string | string[] | undefined> = {};
nock('https://api.pinme.test')
.get('/headers')
.reply(function () {
capturedHeaders = this.req.headers;
return [200, { ok: true }];
});
await createPinmeApiClient({ includeAuth: false }).get('/headers');
expect(capturedHeaders.accept).toBe('*/*');
expect(capturedHeaders['content-type']).toBe('application/json');
expect(capturedHeaders['user-agent']).toBe('Pinme-CLI');
expect(capturedHeaders.connection).toBe('keep-alive');
});
test('continues without auth headers when local auth config is unreadable', async () => {
const { createPinmeApiClient } = await loadApiClientWithThrowingAuthMock();
nock('https://api.pinme.test', {
badheaders: ['token-address', 'authentication-tokens'],
})
.get('/public')
.reply(200, { ok: true });
await expect(createPinmeApiClient().get('/public')).resolves.toMatchObject({
data: { ok: true },
});
});
test('does not treat non-object response bodies as business errors', async () => {
const { createPinmeApiClient } = await loadApiClient();
nock('https://api.pinme.test').get('/plain').reply(200, 'code 500');
const response = await createPinmeApiClient({ includeAuth: false }).get(
'/plain',
);
expect(response.data).toBe('code 500');
});
test('omits request context when axios config has no URL', async () => {
const { createApiClient } = await loadApiClient();
const client = createApiClient({
includeAuth: false,
baseURL: 'https://api.pinme.test',
});
await expect(
client.request({
adapter: async (config) => ({
data: { code: 500, msg: 'Adapter business failure' },
status: 200,
statusText: 'OK',
headers: {},
config,
}),
}),
).rejects.toMatchObject({
message: 'Adapter business failure',
details: ['Business code: 500'],
});
});
test('uses GET as the default request descriptor method', async () => {
const { createApiClient } = await loadApiClient();
const client = createApiClient({
includeAuth: false,
baseURL: 'https://api.pinme.test',
});
await expect(
client.request({
url: '/adapter-default-method',
adapter: async (config) => ({
data: { code: 500, msg: 'Adapter business failure' },
status: 200,
statusText: 'OK',
headers: {},
config,
}),
}),
).rejects.toMatchObject({
message: 'Adapter business failure',
details: [
'Request: GET /adapter-default-method',
'Business code: 500',
],
});
});
});
+491
View File
@@ -0,0 +1,491 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import nock from 'nock';
async function loadPinmeApi(env: Record<string, string>) {
vi.resetModules();
Object.assign(process.env, env);
return import('../../bin/utils/pinmeApi');
}
describe('pinmeApi', () => {
beforeEach(() => {
nock.cleanAll();
});
test('returns DNS domains as available without an API call', async () => {
const { checkDomainAvailable } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
await expect(checkDomainAvailable('example.com')).resolves.toEqual({
is_valid: true,
});
expect(nock.isDone()).toBe(true);
});
test('checks PinMe subdomain availability through configured endpoint', async () => {
const { checkDomainAvailable } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
PINME_CHECK_DOMAIN_PATH: '/check_domain',
});
nock('https://api.pinme.test')
.post('/check_domain', { domain_name: 'demo' })
.reply(200, { data: { is_valid: false, error: 'taken' } });
await expect(checkDomainAvailable('demo')).resolves.toEqual({
is_valid: false,
error: 'taken',
});
});
test('defaults subdomain availability to true for unexpected successful shapes', async () => {
const { checkDomainAvailable } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
PINME_CHECK_DOMAIN_PATH: '/check_domain',
});
nock('https://api.pinme.test')
.post('/check_domain', { domain_name: 'demo' })
.reply(200, { code: 200, data: { unexpected: true } });
await expect(checkDomainAvailable('demo')).resolves.toEqual({
is_valid: true,
});
});
test('checks top-level domain availability and surfaces recoverable HTTP failures', async () => {
const { checkDomainAvailable } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
PINME_CHECK_DOMAIN_PATH: '/check_domain',
});
nock('https://api.pinme.test')
.post('/check_domain', { domain_name: 'direct' })
.reply(200, { is_valid: false, error: 'reserved' })
.post('/check_domain', { domain_name: 'missing' })
.reply(404, { message: 'not found' });
await expect(checkDomainAvailable('direct')).resolves.toEqual({
is_valid: false,
error: 'reserved',
});
await expect(checkDomainAvailable('missing')).rejects.toThrow('not found');
});
test('caches root domain until force refresh', async () => {
const { getRootDomain } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/root_domain')
.reply(200, { code: 200, data: { domain: 'first.pinme.test' } })
.get('/root_domain')
.reply(200, { code: 200, data: { domain: 'second.pinme.test' } });
await expect(getRootDomain()).resolves.toBe('first.pinme.test');
await expect(getRootDomain()).resolves.toBe('first.pinme.test');
await expect(getRootDomain(true)).resolves.toBe('second.pinme.test');
});
test('getRootDomain rejects successful responses without a domain', async () => {
const { getRootDomain } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/root_domain')
.reply(200, { code: 200, msg: 'domain missing', data: {} });
await expect(getRootDomain(true)).rejects.toThrow('domain missing');
});
test('binds anonymous devices and returns false on token expiration', async () => {
const { bindAnonymousDevice } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.post('/bind_anonymous', { anonymous_uid: 'anon-1' })
.reply(200, { code: 200 })
.post('/bind_anonymous', { anonymous_uid: 'anon-2' })
.reply(401, { message: 'token expired' });
await expect(bindAnonymousDevice('anon-1')).resolves.toBe(true);
await expect(bindAnonymousDevice('anon-2')).resolves.toBe(false);
});
test('bindAnonymousDevice returns false for non-token failures', async () => {
const { bindAnonymousDevice } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.post('/bind_anonymous', { anonymous_uid: 'anon-fail' })
.reply(500, { message: 'server exploded' });
await expect(bindAnonymousDevice('anon-fail')).resolves.toBe(false);
});
test('throws token expired for auth failures', async () => {
const { getMyDomains } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/my_domains')
.reply(401, { message: 'token expired' });
await expect(getMyDomains()).rejects.toThrow('Token expired');
});
test('detects token expiration from business codes and localized messages', async () => {
const { checkDomainAvailable, getMyDomains } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
PINME_CHECK_DOMAIN_PATH: '/check_domain',
});
nock('https://api.pinme.test')
.post('/check_domain', { domain_name: 'demo' })
.reply(200, { code: 10001, msg: '登录已过期' })
.get('/my_domains')
.reply(200, { code: 'TOKEN_EXPIRED', msg: 'token expired' });
await expect(checkDomainAvailable('demo')).rejects.toThrow(
'Token expired',
);
await expect(getMyDomains()).rejects.toThrow('Token expired');
});
test('reads domain list variants and returns empty arrays for business failures', async () => {
const { getMyDomains } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/my_domains')
.reply(200, {
code: 200,
data: {
list: [
{
domain_name: 'demo',
domain_type: 1,
bind_time: 1,
expire_time: 2,
},
],
},
})
.get('/my_domains')
.reply(200, { msg: 'missing code' });
await expect(getMyDomains()).resolves.toHaveLength(1);
await expect(getMyDomains()).resolves.toEqual([]);
});
test('reads array domain lists and treats business auth codes as expired', async () => {
const { getMyDomains } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/my_domains')
.reply(200, {
code: 200,
data: [
{
domain_name: 'array-demo',
domain_type: 1,
bind_time: 1,
expire_time: 2,
},
],
})
.get('/my_domains')
.reply(200, { code: 403, msg: 'auth failed' });
await expect(getMyDomains()).resolves.toEqual([
expect.objectContaining({ domain_name: 'array-demo' }),
]);
await expect(getMyDomains()).rejects.toThrow('Token expired');
});
test('getMyDomains handles unsupported payloads and both auth business codes', async () => {
const { getMyDomains } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.get('/my_domains')
.reply(200, { code: 200, data: { list: 'not an array' } })
.get('/my_domains')
.reply(200, { code: 401, msg: 'auth failed' });
await expect(getMyDomains()).resolves.toEqual([]);
await expect(getMyDomains()).rejects.toThrow('Token expired');
});
test('binds DNS domains with auth headers', async () => {
const { bindDnsDomainV4 } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test', {
reqheaders: {
'x-auth-token': 'token',
'x-token-address': '0xabc',
},
})
.post('/bind_dns', { domain_name: 'example.com', hash: 'bafy' })
.reply(200, {
code: 200,
msg: 'ok',
data: { domain_name: 'example.com', hash: 'bafy' },
});
await expect(
bindDnsDomainV4('example.com', 'bafy', '0xabc', 'token'),
).resolves.toMatchObject({
code: 200,
data: { domain_name: 'example.com' },
});
});
test('binds PinMe subdomains and reports wallet balance and VIP status', async () => {
const { bindPinmeDomain, getWalletBalance, isVip } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.post('/bind_pinme_domain', {
domain_name: 'demo',
hash: 'bafy',
project_name: 'project',
})
.reply(200, { code: 200 })
.get('/pay/wallet/balance')
.reply(200, {
code: 200,
msg: 'ok',
data: { wallet_balance_usd: 12.5 },
})
.get('/is_vip')
.reply(200, {
code: 200,
msg: 'ok',
data: { is_vip: true },
});
await expect(bindPinmeDomain('demo', 'bafy', 'project')).resolves.toBe(true);
await expect(getWalletBalance('0xabc', 'token')).resolves.toMatchObject({
data: { wallet_balance_usd: 12.5 },
});
await expect(isVip('0xabc', 'token')).resolves.toMatchObject({
data: { is_vip: true },
});
});
test('bindPinmeDomain returns false for successful responses without code 200', async () => {
const { bindPinmeDomain } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.post('/bind_pinme_domain', {
domain_name: 'demo',
hash: 'bafy',
})
.reply(200, { msg: 'missing code' })
.post('/bind_pinme_domain', {
domain_name: 'project-demo',
hash: 'bafy-project',
project_name: 'project',
})
.reply(200, { code: 200, msg: 'ok' });
await expect(bindPinmeDomain('demo', 'bafy')).resolves.toBe(false);
await expect(
bindPinmeDomain('project-demo', 'bafy-project', 'project'),
).resolves.toBe(true);
});
test('sends account auth headers for wallet and VIP requests', async () => {
const { getWalletBalance, isVip } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test', {
reqheaders: {
'authentication-tokens': 'wallet-token',
'token-address': '0xwallet',
},
})
.get('/pay/wallet/balance')
.reply(200, { code: 200, msg: 'ok', data: { wallet_balance_usd: 1 } });
nock('https://api.pinme.test', {
reqheaders: {
'x-auth-token': 'vip-token',
'x-token-address': '0xvip',
},
})
.get('/is_vip')
.reply(200, { code: 200, msg: 'ok', data: { is_vip: false } });
await expect(getWalletBalance('0xwallet', 'wallet-token')).resolves.toEqual(
{
code: 200,
msg: 'ok',
data: { wallet_balance_usd: 1 },
},
);
await expect(isVip('0xvip', 'vip-token')).resolves.toEqual({
code: 200,
msg: 'ok',
data: { is_vip: false },
});
});
test('surfaces token expiration from bind and account APIs', async () => {
const { bindPinmeDomain, bindDnsDomainV4, getWalletBalance, isVip } =
await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
});
nock('https://api.pinme.test')
.post('/bind_pinme_domain')
.reply(403, { message: 'invalid token' })
.post('/bind_dns')
.reply(401, { message: 'token expired' })
.get('/pay/wallet/balance')
.reply(401, { message: 'unauthorized' })
.get('/is_vip')
.reply(401, { message: 'auth failed' });
await expect(bindPinmeDomain('demo', 'bafy')).rejects.toThrow(
'Token expired',
);
await expect(
bindDnsDomainV4('example.com', 'bafy', '0xabc', 'token'),
).rejects.toThrow('Token expired');
await expect(getWalletBalance('0xabc', 'token')).rejects.toThrow(
'Token expired',
);
await expect(isVip('0xabc', 'token')).rejects.toThrow('Token expired');
});
test('requests CAR export through the CAR API client', async () => {
const { requestCarExport } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
CAR_API_BASE: 'https://car.pinme.test',
});
nock('https://car.pinme.test')
.post('/car/export')
.query({ cid: 'bafy', uid: 'uid-1' })
.reply(200, {
code: 200,
msg: 'ok',
data: {
cid: 'bafy',
status: 'processing',
task_id: 'task-1',
},
});
await expect(requestCarExport('bafy', 'uid-1')).resolves.toEqual({
cid: 'bafy',
status: 'processing',
task_id: 'task-1',
});
});
test('checks CAR export status and normalizes API failures', async () => {
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
CAR_API_BASE: 'https://car.pinme.test',
});
nock('https://car.pinme.test')
.get('/car/export/status')
.query({ task_id: 'task-1' })
.reply(200, {
code: 200,
msg: 'ok',
data: {
task_id: 'task-1',
cid: 'bafy',
status: 'completed',
download_url: 'https://download.pinme.test/file.car',
},
})
.post('/car/export')
.query({ cid: 'bad', uid: 'uid-1' })
.reply(200, { code: 500, msg: 'Export failed' });
await expect(checkCarExportStatus('task-1')).resolves.toMatchObject({
status: 'completed',
download_url: 'https://download.pinme.test/file.car',
});
await expect(requestCarExport('bad', 'uid-1')).rejects.toThrow(
/Failed to request CAR export: Export failed|Export failed/,
);
});
test('rejects CAR success codes that omit payload data', async () => {
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
CAR_API_BASE: 'https://car.pinme.test',
});
nock('https://car.pinme.test')
.post('/car/export')
.query({ cid: 'empty', uid: 'uid-1' })
.reply(200, { code: 200, msg: 'missing export data' })
.get('/car/export/status')
.query({ task_id: 'empty-task' })
.reply(200, { code: 200, msg: 'missing status data' });
await expect(requestCarExport('empty', 'uid-1')).rejects.toThrow(
/missing export data/,
);
await expect(checkCarExportStatus('empty-task')).rejects.toThrow(
/missing status data/,
);
});
test('normalizes CAR status token expiration and response messages', async () => {
const { checkCarExportStatus } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
CAR_API_BASE: 'https://car.pinme.test',
});
nock('https://car.pinme.test')
.get('/car/export/status')
.query({ task_id: 'expired' })
.reply(401, { message: 'token expired' })
.get('/car/export/status')
.query({ task_id: 'failed' })
.reply(200, { code: 500, msg: 'Task failed' });
await expect(checkCarExportStatus('expired')).rejects.toThrow(
'Token expired',
);
await expect(checkCarExportStatus('failed')).rejects.toThrow(
/Failed to check export status: Task failed|Task failed/,
);
});
test('normalizes CAR HTTP response messages and generic failures', async () => {
const { checkCarExportStatus, requestCarExport } = await loadPinmeApi({
PINME_API_BASE: 'https://api.pinme.test',
CAR_API_BASE: 'https://car.pinme.test',
});
nock('https://car.pinme.test')
.post('/car/export')
.query({ cid: 'http-fail', uid: 'uid-1' })
.reply(503, { msg: 'CAR export unavailable' })
.post('/car/export')
.query({ cid: 'network-fail', uid: 'uid-1' })
.replyWithError('socket closed')
.get('/car/export/status')
.query({ task_id: 'http-fail' })
.reply(500, { msg: 'status unavailable' })
.get('/car/export/status')
.query({ task_id: 'network-fail' })
.replyWithError('status socket closed');
await expect(requestCarExport('http-fail', 'uid-1')).rejects.toThrow(
'CAR export unavailable',
);
await expect(requestCarExport('network-fail', 'uid-1')).rejects.toThrow(
/Failed to request CAR export: socket closed/,
);
await expect(checkCarExportStatus('http-fail')).rejects.toThrow(
'status unavailable',
);
await expect(checkCarExportStatus('network-fail')).rejects.toThrow(
/Failed to check export status: status socket closed/,
);
});
});
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
const loginSource = readFileSync(new URL("../bin/login.ts", import.meta.url), "utf8");
test("pinme login tracking sends login method through source", () => {
assert.match(loginSource, /source:\s*['"]cli['"]/);
});
+75
View File
@@ -0,0 +1,75 @@
import path from 'path';
import { mkdir, readFile } from 'fs/promises';
import { describe, expect, test } from 'vitest';
import { execa } from 'execa';
import { dir } from 'tmp-promise';
import { cliEntry, repoRoot } from '../helpers/cliRunner';
describe('npm package', () => {
test('build output has a shebang and package bin points to it', async () => {
const [entry, packageJsonRaw] = await Promise.all([
readFile(cliEntry, 'utf8'),
readFile(path.join(repoRoot, 'package.json'), 'utf8'),
]);
const packageJson = JSON.parse(packageJsonRaw);
expect(entry.split('\n')[0]).toBe('#!/usr/bin/env node');
expect(packageJson.bin.pinme).toBe('./dist/index.js');
});
test(
'npm pack includes the CLI bundle and installable bin',
async () => {
const temp = await dir({
prefix: 'pinme-pack-',
unsafeCleanup: true,
});
try {
const npmCache = path.join(temp.path, 'npm-cache');
const pack = await execa(
'npm',
['pack', '--json', '--pack-destination', temp.path],
{
cwd: repoRoot,
env: {
npm_config_cache: npmCache,
},
},
);
const [packed] = JSON.parse(pack.stdout);
const files = packed.files.map((file: { path: string }) => file.path);
const tarball = path.join(temp.path, packed.filename);
expect(files.sort()).toEqual([
'LICENSE',
'README.md',
'dist/index.js',
'package.json',
]);
const extractDir = path.join(temp.path, 'extract');
await mkdir(extractDir);
await execa('tar', ['-xzf', tarball, '-C', extractDir]);
const extractedPackageJson = JSON.parse(
await readFile(path.join(extractDir, 'package', 'package.json'), 'utf8'),
);
const extractedEntry = await readFile(
path.join(extractDir, 'package', 'dist', 'index.js'),
'utf8',
);
expect(extractedPackageJson.bin.pinme).toBe('./dist/index.js');
expect(extractedEntry.split('\n')[0]).toBe('#!/usr/bin/env node');
await execa('node', [
'--check',
path.join(extractDir, 'package', 'dist', 'index.js'),
]);
} finally {
await temp.cleanup();
}
},
30000,
);
});
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, afterEach, beforeAll } from 'vitest';
import nock from 'nock';
beforeAll(() => {
nock.disableNetConnect();
nock.enableNetConnect((host) => host.startsWith('127.0.0.1'));
});
afterEach(() => {
nock.cleanAll();
});
afterAll(() => {
nock.enableNetConnect();
});
+29
View File
@@ -0,0 +1,29 @@
const nodeCrypto = require('crypto');
const { webcrypto } = nodeCrypto;
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: webcrypto,
});
}
if (typeof nodeCrypto.getRandomValues !== 'function') {
nodeCrypto.getRandomValues = webcrypto.getRandomValues.bind(webcrypto);
}
if (typeof globalThis.ReadableStream !== 'function') {
const webStreams = require('stream/web');
for (const name of ['ReadableStream', 'WritableStream', 'TransformStream']) {
if (
typeof globalThis[name] !== 'function' &&
typeof webStreams[name] === 'function'
) {
Object.defineProperty(globalThis, name, {
configurable: true,
value: webStreams[name],
});
}
}
}
+23
View File
@@ -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();
});
});
+347
View File
@@ -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:');
});
});
+81
View File
@@ -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');
});
});
+77
View File
@@ -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,
);
},
),
);
});
});
+323
View File
@@ -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');
});
});
+99
View File
@@ -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');
});
});
+340
View File
@@ -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/);
});
});
+153
View File
@@ -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');
});
});