Skip to content

Commit

Permalink
feat: support npm access command (#436)
Browse files Browse the repository at this point in the history
> Supports partial npm access query commands.
#64

* The following commands are supported:
  * `npm access list packages [<user>|<scope>|<scope:team> [<package>]`
  * `npm access list collaborators [<package> [<user>]]`
* Added `/-/package/:fullname/collaborators` and
`/-/org/:username/package` interfaces.
* Error code logic is consistent with the npm registry.

--------------

> 支持部分 npm access 查询命令 #64
* 支持如下命令:
  * `npm access list packages [<user>|<scope>|<scope:team> [<package>]`
  * `npm access list collaborators [<package> [<user>]]`
* 新增 `/-/package/:fullname/collaborators` 及 `/-/org/:username/package`
接口
* 错误码逻辑和 npm registry 保持一致
  • Loading branch information
elrrrrrrr authored Apr 7, 2023
1 parent eaf88bd commit 0ffb614
Show file tree
Hide file tree
Showing 7 changed files with 304 additions and 11 deletions.
5 changes: 5 additions & 0 deletions app/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ export enum PresetRegistryName {
default = 'default',
self = 'self',
}

export enum PackageAccessLevel {
write = 'write',
read = 'read',
}
55 changes: 55 additions & 0 deletions app/port/controller/AccessController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
HTTPController,
HTTPMethod,
HTTPMethodEnum,
HTTPParam,
} from '@eggjs/tegg';
import { AbstractController } from './AbstractController';
import { FULLNAME_REG_STRING, getFullname, getScopeAndName } from '../../common/PackageUtil';
import { PackageAccessLevel } from '../../common/constants';
import { ForbiddenError, NotFoundError } from 'egg-errors';

@HTTPController()
export class AccessController extends AbstractController {
@HTTPMethod({
path: `/-/package/:fullname(${FULLNAME_REG_STRING})/collaborators`,
method: HTTPMethodEnum.GET,
})
async listCollaborators(@HTTPParam() fullname: string) {
const [ scope, name ] = getScopeAndName(fullname);
const pkg = await this.packageRepository.findPackage(scope, name);
// return 403 if pkg not exists
if (!pkg) {
throw new ForbiddenError('Forbidden');
}

const maintainers = await this.packageRepository.listPackageMaintainers(pkg!.packageId);
const res: Record<string, string> = {};
maintainers.forEach(maintainer => {
res[maintainer.displayName] = PackageAccessLevel.write;
});

return res;
}

@HTTPMethod({
path: '/-/org/:username/package',
method: HTTPMethodEnum.GET,
})
async listPackagesByUser(@HTTPParam() username: string) {
const user = await this.userRepository.findUserByName(username);
if (!user) {
throw new NotFoundError(`User "${username}" not found`);
}

const pkgs = await this.packageRepository.listPackagesByUserId(user.userId);
const res: Record<string, string> = {};
pkgs.forEach(pkg => {
res[getFullname(pkg.scope, pkg.name)] = PackageAccessLevel.write;
});

return res;
}


}
22 changes: 19 additions & 3 deletions test/TestUtil.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import fs from 'fs/promises';
// 统一通过 coffee 执行 child_process,获取运行时的一些环境信息
import coffee from 'coffee';
import { tmpdir } from 'os';
import { rmSync, mkdtempSync } from 'fs';
import { mkdtempSync } from 'fs';
import { Readable } from 'stream';
import mysql from 'mysql';
import path from 'path';
Expand Down Expand Up @@ -54,6 +56,16 @@ export class TestUtil {
return process.env.MYSQL_DATABASE || 'cnpmcore_unittest';
}

// 不同的 npm 版本 cli 命令不同
// 通过 coffee 运行时获取对应版本号
static async getNpmVersion() {
const res = await coffee.spawn('npm', [ '-v' ]).end();
return semver.clean(res.stdout);
}

// 获取当前所有 sql 脚本内容
// 目前统一放置在 ../sql 文件夹中
// 默认根据版本号排序,确保向后兼容
static async getTableSqls(): Promise<string> {
const dirents = await fs.readdir(path.join(__dirname, '../sql'));
let versions = dirents.filter(t => path.extname(t) === '.sql').map(t => path.basename(t, '.sql'));
Expand Down Expand Up @@ -120,8 +132,12 @@ export class TestUtil {
return this._app;
}

static rm(filepath) {
rmSync(filepath, { recursive: true, force: true });
static async rm(filepath: string) {
try {
await fs.unlink(filepath);
} catch (e) {
// ignore
}
}

static mkdtemp() {
Expand Down
130 changes: 130 additions & 0 deletions test/cli/npm/access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// import assert from 'assert';
import path from 'path';
import { app } from 'egg-mock/bootstrap';
import coffee from 'coffee';
import semver from 'semver';
import { TestUtil } from 'test/TestUtil';
import { npmLogin } from '../CliUtil';

describe('test/cli/npm/access.test.ts', () => {
let server;
let registry;
let fooPkgDir;
let demoDir;
let userconfig;
let cacheDir;
let useLegacyCommands;
before(async () => {
cacheDir = TestUtil.mkdtemp();
fooPkgDir = TestUtil.getFixtures('@cnpm/foo');
demoDir = TestUtil.getFixtures('demo');
userconfig = path.join(fooPkgDir, '.npmrc');
await TestUtil.rm(userconfig);
await TestUtil.rm(path.join(demoDir, 'node_modules'));
const npmVersion = await TestUtil.getNpmVersion();
useLegacyCommands = semver.lt(String(npmVersion), '9.0.0');
await new Promise(resolve => {
server = app.listen(0, () => {
registry = `http://localhost:${server.address().port}`;
console.log(`registry ${registry} ready`);
resolve(void 0);
});
});
});

after(async () => {
await TestUtil.rm(userconfig);
await TestUtil.rm(cacheDir);
await TestUtil.rm(path.join(demoDir, 'node_modules'));
server && server.close();
});

beforeEach(async () => {
await npmLogin(registry, userconfig);
await coffee
.spawn('npm', [
'publish',
`--registry=${registry}`,
`--userconfig=${userconfig}`,
`--cache=${cacheDir}`,
], {
cwd: fooPkgDir,
})
.debug()
.expect('code', 0)
.end();
});

describe('npm access', () => {

it('should work for list collaborators', async () => {
const subCommands = useLegacyCommands ? [ 'ls-collaborators' ] : [ 'list', 'collaborators' ];
await coffee
.spawn('npm', [
'access',
...subCommands,
'@cnpm/foo',
`--registry=${registry}`,
`--userconfig=${userconfig}`,
`--cache=${cacheDir}`,
// '--json',
], {
cwd: demoDir,
})
.debug()
.expect('stdout', /testuser:\sread-write|\"testuser\":\s\"read-write\"/)
.expect('code', 0)
.end();

});

it('should work for list all packages', async () => {
const subCommands = useLegacyCommands ? [ 'ls-packages' ] : [ 'list', 'packages' ];
await coffee
.spawn('npm', [
'access',
...subCommands,
'testuser',
`--registry=${registry}`,
`--userconfig=${userconfig}`,
`--cache=${cacheDir}`,
// '--json',
], {
cwd: demoDir,
})
.debug()
.expect('stdout', /@cnpm\/foo: read-write|\"@cnpm\/foo\":\s\"read-write"/)
.expect('code', 0)
.end();

});

it('should work for list single package', async () => {

// not support in npm7 * 8
if (useLegacyCommands) {
console.log('npm list packages user package not implement lt 9.0.0, just skip');
return;
}
await coffee
.spawn('npm', [
'access',
'list',
'packages',
'testuser',
'@cnpm/foo',
`--registry=${registry}`,
`--userconfig=${userconfig}`,
`--cache=${cacheDir}`,
// '--json',
], {
cwd: demoDir,
})
.debug()
.expect('stdout', /@cnpm\/foo: read-write/)
.expect('code', 0)
.end();

});
});
});
16 changes: 8 additions & 8 deletions test/cli/npm/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,22 @@ describe('test/cli/npm/install.test.ts', () => {
fooPkgDir = TestUtil.getFixtures('@cnpm/foo');
demoDir = TestUtil.getFixtures('demo');
userconfig = path.join(fooPkgDir, '.npmrc');
TestUtil.rm(userconfig);
TestUtil.rm(path.join(demoDir, 'node_modules'));
await TestUtil.rm(userconfig);
await TestUtil.rm(path.join(demoDir, 'node_modules'));

return new Promise(resolve => {
await new Promise(resolve => {
server = app.listen(0, () => {
registry = `http://localhost:${server.address().port}`;
console.log(`registry ${registry} ready`);
resolve();
resolve(void 0);
});
});
});

after(() => {
TestUtil.rm(userconfig);
TestUtil.rm(cacheDir);
TestUtil.rm(path.join(demoDir, 'node_modules'));
after(async () => {
await TestUtil.rm(userconfig);
await TestUtil.rm(cacheDir);
await TestUtil.rm(path.join(demoDir, 'node_modules'));
server && server.close();
});

Expand Down
62 changes: 62 additions & 0 deletions test/port/controller/AccessController/listCollaborators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from 'assert';
import { app } from 'egg-mock/bootstrap';
import { TestUtil } from 'test/TestUtil';

describe('test/port/controller/AccessController/listCollaborators.test.ts', () => {
describe('[GET /-/package/:fullname/collaborators] listCollaborators()', () => {

it('should work', async () => {
const { pkg } = await TestUtil.createPackage({ version: '1.0.0' }, { name: 'banana-owner' });
const res = await app.httpRequest()
.get(`/-/package/${pkg.name}/collaborators`)
.expect(200);

assert(res.body['banana-owner'] === 'write');
});

it('should 403 when pkg not exists', async () => {
const res = await app.httpRequest()
.get('/-/package/banana/collaborators')
.expect(403);
assert.equal(res.body.error, '[FORBIDDEN] Forbidden');
});

it('should refresh when maintainer updated', async () => {
const owner = await TestUtil.createUser({ name: 'banana-owner' });
const maintainer = await TestUtil.createUser({ name: 'banana-maintainer' });


// create pkg
const pkg = await TestUtil.getFullPackage({ name: '@cnpm/banana' });
const createRes = await app.httpRequest()
.put(`/${pkg.name}`)
.set('authorization', owner.authorization)
.set('user-agent', owner.ua)
.send(pkg)
.expect(201);
assert.equal(createRes.body.ok, true);
assert.match(createRes.body.rev, /^\d+\-\w{24}$/);
const rev = createRes.body.rev;

// updateMaintainers
await app.httpRequest()
.put(`/${pkg.name}/-rev/${rev}`)
.set('authorization', owner.authorization)
.set('user-agent', owner.ua)
.set('npm-command', 'owner')
.send({
...pkg,
maintainers: [{ name: maintainer.name, email: maintainer.email }, { name: owner.name, email: owner.email }],
})
.expect(200);

const res = await app.httpRequest()
.get(`/-/package/${pkg.name}/collaborators`)
.expect(200);

assert(res.body['banana-owner'] === 'write');
assert(res.body['banana-maintainer'] === 'write');
});

});
});
25 changes: 25 additions & 0 deletions test/port/controller/AccessController/listPackagesByUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'assert';
import { app } from 'egg-mock/bootstrap';
import { TestUtil } from 'test/TestUtil';

describe('test/port/controller/AccessController/listPackagesByUser.test.ts', () => {
describe('[GET /-/org/:username/package] listPackagesByUser()', () => {

it('should work', async () => {
const { pkg } = await TestUtil.createPackage({ version: '1.0.0' }, { name: 'banana' });
const res = await app.httpRequest()
.get('/-/org/banana/package')
.expect(200);

assert.equal(res.body[pkg.name], 'write');
});

it('should 404 when user not exists', async () => {
const res = await app.httpRequest()
.get('/-/org/banana-disappear/package')
.expect(404);
assert.equal(res.body.error, '[NOT_FOUND] User "banana-disappear" not found');
});

});
});

0 comments on commit 0ffb614

Please sign in to comment.