0
Fork 0
mirror of https://github.com/verdaccio/verdaccio.git synced 2025-03-18 02:22:46 -05:00

feat: upgrade load plugin and auth (#5130)

* feat: upgrade load plugin and auth

* fix types

* format

* Update e2e-jest-workflow.yml

* Update e2e-jest-workflow.yml

* Update e2e-jest-workflow.yml

* add filter plugin loader
This commit is contained in:
Juan Picado 2025-03-16 15:10:00 +01:00 committed by GitHub
parent f2cc71cd2c
commit 4c5c509566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 141 additions and 1642 deletions

View file

@ -4,7 +4,7 @@ on:
concurrency:
group: e2e-jest-${{ github.ref }}-6.x
cancel-in-progress: true
cancel-in-progress: true
name: 'E2E Jest with verdaccio'
jobs:

2
.pnp.cjs generated
View file

@ -73,6 +73,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@verdaccio/auth", "npm:8.0.0-next-8.10"],\
["@verdaccio/config", "npm:8.0.0-next-8.10"],\
["@verdaccio/core", "npm:8.0.0-next-8.10"],\
["@verdaccio/loaders", "npm:8.0.0-next-8.4"],\
["@verdaccio/local-storage-legacy", "npm:11.0.2"],\
["@verdaccio/logger", "npm:8.0.0-next-8.10"],\
["@verdaccio/middleware", "npm:8.0.0-next-8.10"],\
@ -15333,6 +15334,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@verdaccio/auth", "npm:8.0.0-next-8.10"],\
["@verdaccio/config", "npm:8.0.0-next-8.10"],\
["@verdaccio/core", "npm:8.0.0-next-8.10"],\
["@verdaccio/loaders", "npm:8.0.0-next-8.4"],\
["@verdaccio/local-storage-legacy", "npm:11.0.2"],\
["@verdaccio/logger", "npm:8.0.0-next-8.10"],\
["@verdaccio/middleware", "npm:8.0.0-next-8.10"],\

View file

@ -43,7 +43,7 @@ module.exports = {
global: {
lines: 70,
functions: 75,
branches: 63,
branches: 60,
statements: 70,
},
},

View file

@ -23,6 +23,7 @@
"@verdaccio/auth": "8.0.0-next-8.10",
"@verdaccio/config": "8.0.0-next-8.10",
"@verdaccio/core": "8.0.0-next-8.10",
"@verdaccio/loaders": "8.0.0-next-8.4",
"@verdaccio/local-storage-legacy": "11.0.2",
"@verdaccio/logger": "8.0.0-next-8.10",
"@verdaccio/middleware": "8.0.0-next-8.10",

View file

@ -51,7 +51,7 @@ export default function (route: Router, auth: Auth, config: Config): void {
);
}
const restoredRemoteUser: RemoteUser = createRemoteUser(name, user.groups || []);
const restoredRemoteUser: RemoteUser = createRemoteUser(name, user?.groups ?? []);
const token = await getApiToken(auth, config, restoredRemoteUser, password);
res.status(HTTP_STATUS.CREATED);
@ -82,7 +82,9 @@ export default function (route: Router, auth: Auth, config: Config): void {
}
const token =
name && password ? await getApiToken(auth, config, user, password) : undefined;
name && password
? await getApiToken(auth, config, user as RemoteUser, password)
: undefined;
req.remote_user = user;
res.status(HTTP_STATUS.CREATED);

View file

@ -66,7 +66,7 @@ export default function (router: Router, auth: Auth, storage: Storage, config: C
return next(ErrorCode.getCode(HTTP_STATUS.BAD_DATA, SUPPORT_ERRORS.PARAMETERS_NOT_VALID));
}
auth.authenticate(name, password, async (err, user: RemoteUser) => {
auth.authenticate(name, password, async (err, user?: RemoteUser) => {
if (err) {
const errorCode = err.message ? HTTP_STATUS.UNAUTHORIZED : HTTP_STATUS.INTERNAL_ERROR;
return next(ErrorCode.getCode(errorCode, err.message));
@ -81,7 +81,7 @@ export default function (router: Router, auth: Auth, storage: Storage, config: C
}
try {
const token = (await getApiToken(auth, config, user, password)) as string;
const token = (await getApiToken(auth, config, user as RemoteUser, password)) as string;
const key = stringToMD5(token as string);
// TODO: use a utility here
const maskedToken = mask(token as string, 5);

View file

@ -3,18 +3,18 @@ import cors from 'cors';
import express, { Application } from 'express';
import _ from 'lodash';
import { Auth } from '@verdaccio/auth';
import { getUserAgent } from '@verdaccio/config';
import { pluginUtils } from '@verdaccio/core';
import { PLUGIN_CATEGORY, pluginUtils } from '@verdaccio/core';
import { asyncLoadPlugin } from '@verdaccio/loaders';
import { errorReportingMiddleware, final, handleError } from '@verdaccio/middleware';
import { log } from '@verdaccio/middleware';
import { SearchMemoryIndexer } from '@verdaccio/search-indexer';
import { Config as IConfig } from '@verdaccio/types';
import Auth from '../lib/auth';
import AppConfig from '../lib/config';
import { API_ERROR } from '../lib/constants';
import { logger, setup } from '../lib/logger';
import loadPlugin from '../lib/plugin-loader';
import Storage from '../lib/storage';
import { ErrorCode } from '../lib/utils';
import { $NextFunctionVer, $RequestExtend, $ResponseExtend } from '../types';
@ -25,24 +25,9 @@ import webMiddleware from './web';
const { version } = require('../../package.json');
export function loadTheme(config) {
if (_.isNil(config.theme) === false) {
return _.head(
loadPlugin(
config,
config.theme,
{},
function (plugin) {
return plugin.staticPath && plugin.manifest && plugin.manifestFiles;
},
'verdaccio-theme'
)
);
}
}
const defineAPI = async function (config: IConfig, storage: Storage): Promise<express.Application> {
const auth = new Auth(config);
const auth = new Auth(config, logger);
await auth.init();
const app: Application = express();
SearchMemoryIndexer.configureStorage(storage);
await SearchMemoryIndexer.init(logger);
@ -85,14 +70,17 @@ const defineAPI = async function (config: IConfig, storage: Storage): Promise<ex
logger: logger,
};
const plugins: pluginUtils.Auth<IConfig>[] = loadPlugin(
config,
const plugins: pluginUtils.ExpressMiddleware<IConfig, {}, Auth>[] = await asyncLoadPlugin(
config.middlewares,
plugin_params,
function (plugin: pluginUtils.ManifestFilter<IConfig>) {
// @ts-ignore
return plugin.register_middlewares;
}
{
config,
logger,
},
function (plugin) {
return typeof plugin.register_middlewares !== 'undefined';
},
config?.serverSettings?.pluginPrefix ?? 'verdaccio',
PLUGIN_CATEGORY.MIDDLEWARE
);
plugins.forEach((plugin: any) => {
@ -108,7 +96,8 @@ const defineAPI = async function (config: IConfig, storage: Storage): Promise<ex
res.locals.app_version = version ?? '';
next();
});
app.use(webMiddleware(config, auth, storage));
const middleware = await webMiddleware(config, auth, storage, logger);
app.use(middleware);
} else {
app.get('/', function (_, __, next: $NextFunctionVer) {
next(ErrorCode.getNotFound(API_ERROR.WEB_DISABLED));
@ -127,20 +116,8 @@ const defineAPI = async function (config: IConfig, storage: Storage): Promise<ex
export default (async function (configHash: any) {
setup(configHash.logs);
const config: IConfig = new AppConfig(_.cloneDeep(configHash));
// register middleware plugins
const plugin_params = {
config: config,
logger: logger,
};
const filters = loadPlugin(
config,
config.filters || {},
plugin_params,
// @ts-ignore
(plugin: pluginUtils.ManifestFilter<IConfig>) => plugin.filter_metadata
);
const storage = new Storage(config);
// waits until init calls have been initialized
await storage.init(config, filters);
await storage.init(config, []);
return await defineAPI(config, storage);
});

View file

@ -49,7 +49,7 @@ function addPackageWebApi(storage: Storage, auth: Auth, config: Config): Router
if (err) {
resolve(false);
}
resolve(allowed);
resolve(allowed as boolean);
});
} catch (err) {
reject(err);

View file

@ -21,16 +21,16 @@ function addUserAuthApi(auth: Auth, config: Config): Router {
function (req: Request, res: Response, next: $NextFunctionVer): void {
const { username, password } = req.body;
auth.authenticate(username, password, async (err, user: RemoteUser): Promise<void> => {
auth.authenticate(username, password, async (err, user?: RemoteUser): Promise<void> => {
if (err) {
const errorCode = err.message ? HTTP_STATUS.UNAUTHORIZED : HTTP_STATUS.INTERNAL_ERROR;
next(ErrorCode.getCode(errorCode, err.message));
} else {
req.remote_user = user;
req.remote_user = user as RemoteUser;
const jWTSignOptions: JWTSignOptions = getSecurity(config).web.sign;
res.set(HEADERS.CACHE_CONTROL, 'no-cache, no-store');
next({
token: await auth.jwtEncrypt(user, jWTSignOptions),
token: await auth.jwtEncrypt(user as RemoteUser, jWTSignOptions),
username: req.remote_user.name,
});
}

View file

@ -1,7 +1,10 @@
import buildDebug from 'debug';
import express, { RequestHandler, Router } from 'express';
import express, { Router } from 'express';
import _ from 'lodash';
import { PLUGIN_CATEGORY } from '@verdaccio/core';
import { asyncLoadPlugin } from '@verdaccio/loaders';
import { logger } from '@verdaccio/logger';
import {
renderWebMiddleware,
setSecurityWebHeaders,
@ -9,25 +12,35 @@ import {
validatePackage,
} from '@verdaccio/middleware';
import loadPlugin from '../../lib/plugin-loader';
import webEndpointsApi from './api';
const debug = buildDebug('verdaccio:web');
export const PLUGIN_UI_PREFIX = 'verdaccio-theme';
export const DEFAULT_PLUGIN_UI_THEME = '@verdaccio/ui-theme';
export function loadTheme(config) {
export async function loadTheme(config: any) {
if (_.isNil(config.theme) === false) {
debug('loading custom ui theme');
return _.head(
loadPlugin(
config,
config.theme,
{},
function (plugin) {
return plugin.staticPath && plugin.manifest && plugin.manifestFiles;
},
'verdaccio-theme'
)
const plugin = await asyncLoadPlugin(
config.theme,
{ config, logger },
// TODO: add types { staticPath: string; manifest: unknown; manifestFiles: unknown }
function (plugin: any) {
/**
*
- `staticPath`: is the same data returned in Verdaccio 5.
- `manifest`: A webpack manifest object.
- `manifestFiles`: A object with one property `js` and the array (order matters) of the manifest id to be loaded in the template dynamically.
*/
return plugin.staticPath && plugin.manifest && plugin.manifestFiles;
},
config?.serverSettings?.pluginPrefix ?? PLUGIN_UI_PREFIX,
PLUGIN_CATEGORY.THEME
);
if (plugin.length > 1) {
logger.warn('multiple ui themes are not supported; only the first plugin is used');
}
return _.head(plugin);
}
}
@ -47,8 +60,15 @@ export function localWebEndpointsApi(auth, storage, config): Router {
return route;
}
export default (config, auth, storage) => {
const pluginOptions = loadTheme(config) || require('@verdaccio/ui-theme')();
export default async (config, auth, storage, logger) => {
let pluginOptions = await loadTheme(config);
if (!pluginOptions) {
pluginOptions = require(DEFAULT_PLUGIN_UI_THEME)(config.web);
logger.info(
{ name: DEFAULT_PLUGIN_UI_THEME, pluginCategory: PLUGIN_CATEGORY.THEME },
'plugin @{name} successfully loaded (@{pluginCategory})'
);
}
// eslint-disable-next-line new-cap
const router = Router();
// @ts-ignore

View file

@ -1,547 +1,3 @@
import buildDebug from 'debug';
import { NextFunction } from 'express';
import _ from 'lodash';
import {
getMiddlewareCredentials,
isAESLegacy,
isAuthHeaderValid,
parseAuthTokenHeader,
verifyJWTPayload,
} from '@verdaccio/auth';
import { createAnonymousRemoteUser, createRemoteUser } from '@verdaccio/config';
import { VerdaccioError, pluginUtils } from '@verdaccio/core';
import {
aesEncrypt,
aesEncryptDeprecated,
parseBasicPayload,
signPayload,
utils as signatureUtils,
} from '@verdaccio/signature';
import {
AllowAccess,
Callback,
Config,
JWTSignOptions,
Logger,
PackageAccess,
RemoteUser,
Security,
} from '@verdaccio/types';
import { getMatchedPackagesSpec } from '@verdaccio/utils';
import loadPlugin from '../lib/plugin-loader';
import { $RequestExtend, $ResponseExtend, AESPayload } from '../types';
import { getDefaultPlugins, getSecurity } from './auth-utils';
import { API_ERROR, SUPPORT_ERRORS, TOKEN_BASIC, TOKEN_BEARER } from './constants';
import { logger } from './logger';
import { ErrorCode, convertPayloadToBase64 } from './utils';
const debug = buildDebug('verdaccio:auth');
class Auth {
public config: Config;
public logger: Logger;
public secret: string;
public plugins: pluginUtils.Auth<Config>[];
public constructor(config: Config) {
this.config = config;
this.logger = logger;
this.secret = config.secret;
this.plugins = this._loadPlugin(config);
this._applyDefaultPlugins();
}
private _loadPlugin(config: Config): pluginUtils.Auth<Config>[] {
const pluginOptions = {
config,
logger: this.logger,
};
let authConf = { ...config.auth };
if (authConf?.htpasswd) {
// special case for htpasswd plugin, the v6 version uses bcrypt by default
// 6.x enforces crypt to avoid breaking changes, but is highly recommended using
// bcrypt instead.
if (!authConf.htpasswd.algorithm) {
authConf.htpasswd.algorithm = 'crypt';
this.logger.info(
// eslint-disable-next-line max-len
'the "crypt" algorithm is deprecated consider switch to "bcrypt" in the configuration file. Read the documentation for additional details'
);
}
}
return loadPlugin<pluginUtils.Auth<Config>>(
config,
authConf,
pluginOptions,
(plugin: pluginUtils.Auth<Config>): boolean => {
const { authenticate, allow_access, allow_publish } = plugin;
// @ts-ignore
return authenticate || allow_access || allow_publish;
}
);
}
private _applyDefaultPlugins(): void {
this.plugins.push(getDefaultPlugins(this.logger));
}
public changePassword(
username: string,
password: string, // pragma: allowlist secret
newPassword: string, // pragma: allowlist secret
cb: Callback
): void {
const validPlugins = _.filter(this.plugins, (plugin) => _.isFunction(plugin.changePassword));
if (_.isEmpty(validPlugins)) {
return cb(ErrorCode.getInternalError(SUPPORT_ERRORS.PLUGIN_MISSING_INTERFACE));
}
for (const plugin of validPlugins) {
if (_.isNil(plugin) || _.isFunction(plugin.changePassword) === false) {
debug('auth plugin does not implement changePassword, trying next one');
continue;
} else {
debug('updating password for %o', username);
plugin.changePassword!(username, password, newPassword, (err, profile): void => {
if (err) {
this.logger.error(
{ username, err },
`An error has been produced
updating the password for @{username}. Error: @{err.message}`
);
return cb(err);
}
this.logger.info({ username }, 'updated password for @{username} was successful');
return cb(null, profile);
});
}
}
}
public authenticate(username: string, password: string, cb: Callback): void {
const plugins = this.plugins.slice(0);
const self = this;
(function next(): void {
const plugin = plugins.shift() as pluginUtils.Auth<Config>;
if (_.isFunction(plugin.authenticate) === false) {
return next();
}
debug('authenticating %o', username);
plugin.authenticate(username, password, function (err, groups): void {
if (err) {
self.logger.error(
{ username, err },
'authenticating for user @{username} failed. Error: @{err.message}'
);
return cb(err);
}
// Expect: SKIP if groups is falsey and not an array
// with at least one item (truthy length)
// Expect: CONTINUE otherwise (will error if groups is not
// an array, but this is current behavior)
// Caveat: STRING (if valid) will pass successfully
// bug give unexpected results
// Info: Cannot use `== false to check falsey values`
if (!!groups && groups.length !== 0) {
// TODO: create a better understanding of expectations
if (_.isString(groups)) {
throw new TypeError('plugin group error: invalid type for function');
}
const isGroupValid: boolean = _.isArray(groups);
if (!isGroupValid) {
throw new TypeError(API_ERROR.BAD_FORMAT_USER_GROUP);
}
debug('authentication for user %o was successfully. Groups: %o', username, groups);
return cb(err, createRemoteUser(username, groups));
}
next();
});
})();
}
public add_user(user: string, password: string, cb: Callback): void {
const self = this;
const plugins = this.plugins.slice(0);
debug('add user %o', user);
(function next(): void {
const plugin = plugins.shift() as pluginUtils.Auth<Config>;
let method = 'adduser';
if (_.isFunction(plugin[method]) === false) {
method = 'add_user';
self.logger.warn(
'the plugin method add_user in the auth plugin is deprecated and will be removed in next major release, notify to the plugin author'
);
}
if (_.isFunction(plugin[method]) === false) {
next();
} else {
// p.add_user() execution
plugin[method](user, password, function (err, ok): void {
if (err) {
self.logger.error(
{ user, err: err.message },
'the user @{user} could not being added. Error: @{err}'
);
return cb(err);
}
if (ok) {
self.logger.info({ user }, 'the user @{user} has been added');
return self.authenticate(user, password, cb);
}
next();
});
}
})();
}
/**
* Allow user to access a package.
*/
public allow_access(
{ packageName, packageVersion }: pluginUtils.AuthPluginPackage,
user: RemoteUser,
callback: Callback
): void {
const plugins = this.plugins.slice(0);
const self = this;
const pkgAllowAcces: AllowAccess = { name: packageName, version: packageVersion };
const pkg = Object.assign(
{},
pkgAllowAcces,
getMatchedPackagesSpec(packageName, this.config.packages)
) as AllowAccess & PackageAccess;
debug('allow access for %o', packageName);
(function next(): void {
const plugin: pluginUtils.Auth<unknown> = plugins.shift() as pluginUtils.Auth<Config>;
if (_.isNil(plugin) || _.isFunction(plugin.allow_access) === false) {
return next();
}
plugin.allow_access!(user, pkg, function (err: VerdaccioError | null, ok?: boolean): void {
if (err) {
self.logger.error(
{ packageName, err },
'forbidden access for @{packageName}. Error: @{err.message}'
);
return callback(err);
}
if (ok) {
self.logger.info({ packageName }, 'allowed access for @{packageName}');
return callback(null, ok);
}
next(); // cb(null, false) causes next plugin to roll
});
})();
}
public allow_unpublish(
{ packageName, packageVersion }: pluginUtils.AuthPluginPackage,
user: RemoteUser,
callback: Callback
): void {
const pkg = Object.assign(
{ name: packageName, version: packageVersion },
getMatchedPackagesSpec(packageName, this.config.packages)
);
debug('allow unpublish for %o', packageName);
for (const plugin of this.plugins) {
if (_.isNil(plugin) || _.isFunction(plugin.allow_unpublish) === false) {
debug('allow unpublish for %o plugin does not implement allow_unpublish', packageName);
continue;
} else {
// @ts-ignore
plugin.allow_unpublish!(user, pkg, (err, ok: boolean): void => {
if (err) {
this.logger.error(
{ packageName, user: user?.name },
'@{user} forbidden publish for @{packageName}, it will fallback on unpublish permissions'
);
return callback(err);
}
if (_.isNil(ok) === true) {
debug('we bypass unpublish for %o, publish will handle the access', packageName);
// @ts-ignore
// eslint-disable-next-line
return this.allow_publish(...arguments);
}
if (ok) {
this.logger.info(
{ packageName, user: user?.name },
'@{user} allowed unpublish for @{packageName}'
);
return callback(null, ok);
}
});
}
}
}
/**
* Allow user to publish a package.
*/
public allow_publish(
{ packageName, packageVersion }: pluginUtils.AuthPluginPackage,
user: RemoteUser,
callback: Callback
): void {
const plugins = this.plugins.slice(0);
const self = this;
const pkg = Object.assign(
{ name: packageName, version: packageVersion },
getMatchedPackagesSpec(packageName, this.config.packages)
);
debug('allow publish for %o init | plugins: %o', packageName, plugins?.length);
(function next(): void {
const plugin = plugins.shift();
if (_.isNil(plugin) || _.isFunction(plugin.allow_publish) === false) {
debug('allow publish for %o plugin does not implement allow_publish', packageName);
return next();
}
// @ts-ignore
plugin.allow_publish(user, pkg, (err: any, ok: boolean): void => {
if (_.isNil(err) === false && _.isError(err)) {
self.logger.error(
{ packageName, user: user?.name },
'@{user} is forbidden publish for @{packageName}'
);
return callback(err);
}
if (ok) {
self.logger.info(
{ packageName, user: user?.name },
'@{user} is allowed publish for @{packageName}'
);
return callback(null, ok);
}
debug('allow publish skip validation for %o', packageName);
next(); // cb(null, false) causes next plugin to roll
});
})();
}
public apiJWTmiddleware() {
const plugins = this.plugins.slice(0);
const helpers = { createAnonymousRemoteUser, createRemoteUser };
for (const plugin of plugins) {
if (plugin.apiJWTmiddleware) {
return plugin.apiJWTmiddleware(helpers);
}
}
return (req: $RequestExtend, res: $ResponseExtend, _next: NextFunction): void => {
req.pause();
const next = function (err: any | void): void {
req.resume();
// uncomment this to reject users with bad auth headers
// return _next.apply(null, arguments)
// swallow error, user remains unauthorized
// set remoteUserError to indicate that user was attempting authentication
if (err) {
req.remote_user.error = err.message;
}
return _next();
};
if (this._isRemoteUserValid(req.remote_user)) {
// @ts-ignore
return next();
}
// in case auth header does not exist we return anonymous function
req.remote_user = createAnonymousRemoteUser();
const { authorization } = req.headers;
if (_.isNil(authorization)) {
// @ts-ignore
return next();
}
if (!isAuthHeaderValid(authorization)) {
debug('api middleware auth heather is not valid');
return next(ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER));
}
const security: Security = getSecurity(this.config);
const { secret } = this.config;
if (isAESLegacy(security)) {
debug('api middleware using legacy auth token');
this._handleAESMiddleware(req, security, secret, authorization, next);
} else {
debug('api middleware using JWT auth token');
this._handleJWTAPIMiddleware(req, security, secret, authorization, next);
}
};
}
private _handleJWTAPIMiddleware(
req: $RequestExtend,
security: Security,
secret: string,
authorization: string,
next: Function
): void {
const { scheme, token } = parseAuthTokenHeader(authorization);
if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) {
// this should happen when client tries to login with an existing user
const credentials = convertPayloadToBase64(token).toString();
const { user, password } = parseBasicPayload(credentials) as AESPayload;
this.authenticate(user, password, (err, user): void => {
if (!err) {
req.remote_user = user;
next();
} else {
req.remote_user = createAnonymousRemoteUser();
next(err);
}
});
} else {
// jwt handler
const credentials: any = getMiddlewareCredentials(security, secret, authorization);
if (credentials) {
// if the signature is valid we rely on it
req.remote_user = credentials;
next();
} else {
// with JWT throw 401
next(ErrorCode.getForbidden(API_ERROR.BAD_USERNAME_PASSWORD));
}
}
}
private _handleAESMiddleware(
req: $RequestExtend,
security: Security,
secret: string,
authorization: string,
next: Function
): void {
const credentials: any = getMiddlewareCredentials(security, secret, authorization);
if (credentials) {
const { user, password } = credentials;
this.authenticate(user, password, (err, user): void => {
if (!err) {
req.remote_user = user;
next();
} else {
req.remote_user = createAnonymousRemoteUser();
next(err);
}
});
} else {
// we force npm client to ask again with basic authentication
return next(ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER));
}
}
private _isRemoteUserValid(remote_user: RemoteUser): boolean {
return _.isUndefined(remote_user) === false && _.isUndefined(remote_user.name) === false;
}
/**
* JWT middleware for WebUI
*/
public webUIJWTmiddleware(): Function {
return (req: $RequestExtend, res: $ResponseExtend, _next: NextFunction): void => {
if (this._isRemoteUserValid(req.remote_user)) {
return _next();
}
req.pause();
const next = (err: any | void): void => {
req.resume();
if (err) {
// req.remote_user.error = err.message;
res.status(err.statusCode).send(err.message);
}
return _next();
};
const { authorization } = req.headers;
if (_.isNil(authorization)) {
// @ts-ignore
return next();
}
if (!isAuthHeaderValid(authorization)) {
return next(ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER));
}
const token = (authorization || '').replace(`${TOKEN_BEARER} `, '');
if (!token) {
// @ts-ignore
return next();
}
let credentials;
try {
credentials = verifyJWTPayload(token, this.config.secret);
} catch (err) {
// FIXME: intended behaviour, do we want it?
}
if (this._isRemoteUserValid(credentials)) {
const { name, groups } = credentials;
req.remote_user = createRemoteUser(name, groups);
} else {
req.remote_user = createAnonymousRemoteUser();
}
// @ts-ignore
next();
};
}
public async jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): Promise<string> {
const { real_groups, name, groups } = user;
const realGroupsValidated = _.isNil(real_groups) ? [] : real_groups;
const groupedGroups = _.isNil(groups)
? real_groups
: Array.from(new Set([...groups.concat(realGroupsValidated)]));
const payload: RemoteUser = {
real_groups: realGroupsValidated,
name,
groups: groupedGroups,
};
// TODO: fix on update signature package
const token: string = await signPayload(payload, this.secret, signOptions as any);
return token;
}
/**
* Encrypt a string.
*/
public aesEncrypt(value: string): string | void {
if (this.secret.length === signatureUtils.TOKEN_VALID_LENGTH) {
debug('signing with enhanced aes legacy');
const token = aesEncrypt(value, this.secret);
return token;
} else {
debug('signing with enhanced aes deprecated legacy');
// deprecated aes (legacy) signature, only must be used for legacy version
const token = aesEncryptDeprecated(Buffer.from(value), this.secret).toString('base64');
return token;
}
}
}
import { Auth } from '@verdaccio/auth';
export default Auth;

View file

@ -3,7 +3,6 @@ import builDebug from 'debug';
import _ from 'lodash';
import UrlNode from 'url';
import LocalDatabase from '@verdaccio/local-storage-legacy';
import { ReadTarball, UploadTarball } from '@verdaccio/streams';
import {
Author,
@ -28,7 +27,6 @@ import {
} from '@verdaccio/utils';
import { StoragePluginLegacy } from '../../types/custom';
import loadPlugin from '../lib/plugin-loader';
import { StringValue } from '../types';
import { API_ERROR, DIST_TAGS, HTTP_STATUS, STORAGE, SUPPORT_ERRORS, USERS } from './constants';
import {
@ -42,7 +40,7 @@ import { prepareSearchPackage } from './storage-utils';
import { ErrorCode, isObject, tagVersion } from './utils';
const debug = builDebug('verdaccio:local-storage');
type StoragePlugin = StoragePluginLegacy<Config> | any;
export type StoragePlugin = StoragePluginLegacy<Config> | any;
/**
* Implements Storage interface (same for storage.js, local-storage.js, up-storage.js).
*/
@ -51,10 +49,10 @@ class LocalStorage {
public storagePlugin: StoragePlugin;
public logger: Logger;
public constructor(config: Config, logger: Logger) {
public constructor(config: Config, logger: Logger, localStorage: StoragePlugin) {
this.logger = logger;
this.config = config;
this.storagePlugin = this._loadStorage(config, logger);
this.storagePlugin = localStorage;
}
public addPackage(name: string, pkg: Package, callback: Callback): void {
@ -871,35 +869,6 @@ class LocalStorage {
return this.storagePlugin.setSecret(config.checkSecretKey(secretKey));
}
private _loadStorage(config: Config, logger: Logger): StoragePlugin {
const Storage = this._loadStorePlugin();
if (_.isNil(Storage)) {
assert(this.config.storage, 'CONFIG: storage default path not defined');
return new LocalDatabase(this.config, logger);
}
return Storage as StoragePlugin;
}
private _loadStorePlugin(): StoragePlugin | void {
const plugin_params = {
config: this.config,
logger: this.logger,
};
// eslint-disable-next-line max-len
const plugins: StoragePlugin[] = loadPlugin<StoragePlugin>(
this.config,
this.config.store,
plugin_params,
(plugin): StoragePlugin => {
return plugin.getPackageStorage;
}
);
return _.head(plugins);
}
public saveToken(token: Token): Promise<any> {
if (_.isFunction(this.storagePlugin.saveToken) === false) {
return Promise.reject(

View file

@ -1,184 +0,0 @@
import buildDebug from 'debug';
import _ from 'lodash';
import Path from 'path';
import { pluginUtils } from '@verdaccio/core';
import { Config } from '@verdaccio/types';
import { MODULE_NOT_FOUND } from './constants';
import { logger } from './logger';
const debug = buildDebug('verdaccio:plugin:loader');
/**
* Requires a module.
* @param {*} path the module's path
* @return {Object}
*/
function tryLoad(path: string): any {
try {
debug('loading plugin %s', path);
return require(path);
} catch (err: any) {
if (err.code === MODULE_NOT_FOUND) {
debug('plugin %s not found', path);
return null;
}
logger.error({ err: err.msg }, 'error loading plugin @{err}');
throw err;
}
}
function mergeConfig(appConfig, pluginConfig): Config {
return _.merge(appConfig, pluginConfig);
}
function isValid(plugin): boolean {
return _.isFunction(plugin) || _.isFunction(plugin.default);
}
function isES6(plugin): boolean {
return Object.keys(plugin).includes('default');
}
// export type PluginGeneric<R, T extends IPlugin<R> = ;
/**
* Load a plugin following the rules
* - First try to load from the internal directory plugins (which will disappear soon or later).
* - If the package is scoped eg: @scope/foo, try to load as a package
* - A second attempt from the external plugin directory
* - A third attempt from node_modules, in case to have multiple match as for instance verdaccio-ldap
* and sinopia-ldap. All verdaccio prefix will have preferences.
* @param {*} config a reference of the configuration settings
* @param {*} pluginConfigs
* @param {*} params a set of params to initialize the plugin
* @param {*} sanityCheck callback that check the shape that should fulfill the plugin
* @return {Array} list of plugins
*/
export default function loadPlugin<T extends pluginUtils.Plugin<T>>(
config: Config,
pluginConfigs: any = {},
params: any,
sanityCheck: any,
prefix: string = 'verdaccio'
): any[] {
return Object.keys(pluginConfigs).map((pluginId: string): pluginUtils.Plugin<T> => {
let plugin;
const isScoped: boolean = pluginId.startsWith('@') && pluginId.includes('/');
debug('isScoped %s', isScoped);
if (isScoped) {
plugin = tryLoad(pluginId);
}
const localPlugin = Path.resolve(__dirname + '/../plugins', pluginId);
// try local plugins first
plugin = tryLoad(localPlugin);
// try the external plugin directory
if (plugin === null && config.plugins) {
const pluginDir = config.plugins;
const externalFilePlugin = Path.resolve(pluginDir, pluginId);
plugin = tryLoad(externalFilePlugin);
// npm package
if (plugin === null && pluginId.match(/^[^\.\/]/)) {
plugin = tryLoad(Path.resolve(pluginDir, `${prefix}-${pluginId}`));
// compatibility for old sinopia plugins
if (!plugin) {
plugin = tryLoad(Path.resolve(pluginDir, `sinopia-${pluginId}`));
if (plugin) {
logger.warn(
{ name: pluginId },
`plugin names that start with sinopia-* will be removed in the future, please rename package to verdaccio-*`
);
}
}
}
}
// npm package
if (plugin === null && pluginId.match(/^[^\.\/]/)) {
plugin = tryLoad(`${prefix}-${pluginId}`);
// compatibility for old sinopia plugins
if (!plugin) {
plugin = tryLoad(`sinopia-${pluginId}`);
}
if (plugin) {
debug('plugin %s is an npm package', pluginId);
}
}
if (plugin === null) {
plugin = tryLoad(pluginId);
}
// relative to config path
if (plugin === null && pluginId.match(/^\.\.?($|\/)/)) {
// compatible with 6.x
plugin = tryLoad(Path.resolve(Path.dirname(config.self_path ?? config.configPath), pluginId));
}
if (plugin === null) {
if (isScoped) {
logger.error({ content: pluginId }, 'plugin not found. try npm install @{content}');
} else {
logger.error(
{ content: pluginId, prefix },
'plugin not found. try npm install @{prefix}-@{content}'
);
}
const msg = isScoped
? `
${pluginId} plugin not found. try "npm install ${pluginId}"`
: `
${prefix}-${pluginId} plugin not found. try "npm install ${prefix}-${pluginId}"`;
throw Error(msg);
}
if (!isValid(plugin)) {
logger.error(
{ content: pluginId },
'@{prefix}-@{content} plugin does not have the right code structure'
);
throw Error(`"${pluginId}" plugin does not have the right code structure`);
}
/* eslint new-cap:off */
try {
if (isES6(plugin)) {
debug('plugin is ES6');
plugin = new plugin.default(mergeConfig(config, pluginConfigs[pluginId]), params);
} else {
debug('plugin is commonJS');
plugin = plugin(pluginConfigs[pluginId], params);
}
} catch (error: any) {
plugin = null;
logger.error({ error, pluginId }, 'error loading a plugin @{pluginId}: @{error}');
}
/* eslint new-cap:off */
if (plugin === null || !sanityCheck(plugin)) {
if (isScoped) {
logger.error({ content: pluginId }, "@{content} doesn't look like a valid plugin");
} else {
logger.error(
{ content: pluginId, prefix },
"@{prefix}-@{content} doesn't look like a valid plugin"
);
}
throw Error(`sanity check has failed, "${pluginId}" is not a valid plugin`);
}
if (isScoped) {
logger.info({ content: pluginId }, 'plugin successfully loaded: @{content}');
} else {
logger.info(
{ content: pluginId, prefix },
'plugin successfully loaded: @{prefix}-@{content}'
);
}
return plugin;
});
}

View file

@ -4,7 +4,9 @@ import buildDebug from 'debug';
import _ from 'lodash';
import Stream from 'stream';
import { validatioUtils } from '@verdaccio/core';
import { PLUGIN_CATEGORY, pluginUtils, validatioUtils } from '@verdaccio/core';
import { asyncLoadPlugin } from '@verdaccio/loaders';
import LocalDatabasePlugin from '@verdaccio/local-storage-legacy';
import { SearchMemoryIndexer } from '@verdaccio/search-indexer';
import { ReadTarball } from '@verdaccio/streams';
import {
@ -20,11 +22,12 @@ import {
} from '@verdaccio/types';
import { GenericBody, Token, TokenFilter } from '@verdaccio/types';
import { StoragePluginLegacy } from '../../types/custom';
import { logger } from '../lib/logger';
import { IPluginFilters, ISyncUplinks, StringValue } from '../types';
import { hasProxyTo } from './config-utils';
import { API_ERROR, DIST_TAGS, HTTP_STATUS } from './constants';
import LocalStorage from './local-storage';
import LocalStorage, { StoragePlugin } from './local-storage';
import { mergeVersions } from './metadata-utils';
import {
checkPackageLocal,
@ -60,12 +63,69 @@ class Storage {
public async init(config: Config, filters: IPluginFilters = []): Promise<void> {
if (this.localStorage === null) {
this.filters = filters;
this.localStorage = new LocalStorage(this.config, logger);
const storageInstance = await this.loadStorage(config, this.logger);
this.localStorage = new LocalStorage(this.config, logger, storageInstance);
await this.localStorage.getSecret(config);
debug('initialization completed');
} else {
debug('storage has been already initialized');
}
if (!this.filters) {
this.filters = await asyncLoadPlugin<pluginUtils.ManifestFilter<unknown>>(
this.config.filters,
{
config: this.config,
logger: this.logger,
},
(plugin: pluginUtils.ManifestFilter<Config>) => {
return typeof plugin.filter_metadata !== 'undefined';
},
this.config?.serverSettings?.pluginPrefix,
PLUGIN_CATEGORY.FILTER
);
debug('filters available %o', this.filters.length);
}
}
private async loadStorage(config: Config, logger: Logger): Promise<StoragePlugin> {
const Storage = await this.loadStorePlugin();
if (_.isNil(Storage)) {
assert(this.config.storage, 'CONFIG: storage path not defined');
debug('no custom storage found, loading default storage @verdaccio/local-storage');
const localStorage = new LocalDatabasePlugin(config, logger);
logger.info(
{ name: '@verdaccio/local-storage', pluginCategory: PLUGIN_CATEGORY.STORAGE },
'plugin @{name} successfully loaded (@{pluginCategory})'
);
return localStorage;
}
return Storage as StoragePlugin;
}
private async loadStorePlugin(): Promise<StoragePluginLegacy<Config> | undefined> {
const plugins: StoragePluginLegacy<Config>[] = await asyncLoadPlugin<
pluginUtils.Storage<unknown>
>(
this.config.store,
{
config: this.config,
logger: this.logger,
},
(plugin) => {
return typeof plugin.getPackageStorage !== 'undefined';
},
this.config?.serverSettings?.pluginPrefix,
PLUGIN_CATEGORY.STORAGE
);
if (plugins.length > 1) {
this.logger.warn(
'more than one storage plugins has been detected, multiple storage are not supported, one will be selected automatically'
);
}
return _.head(plugins);
}
/**

View file

@ -3,11 +3,11 @@ import express, { Application } from 'express';
import os from 'os';
import path from 'path';
import { Auth } from '@verdaccio/auth';
import { errorUtils } from '@verdaccio/core';
import { errorReportingMiddleware, final, handleError } from '@verdaccio/middleware';
import { generateRandomHexString } from '@verdaccio/utils';
import Auth from '../../src/lib/auth';
import Config from '../../src/lib/config';
import { logger } from '../../src/lib/logger';
@ -29,7 +29,8 @@ export async function initializeServer(
debug('storage: %s', config.storage);
const storage = new Storage(config);
await storage.init(config, []);
const auth: Auth = new Auth(config);
const auth: Auth = new Auth(config, logger);
await auth.init();
// FUTURE: in v6 auth.init() is being called
// TODO: this might not be need it, used in apiEndpoints
app.use(express.json({ strict: false, limit: '100mb' }));

View file

@ -16,6 +16,7 @@ jest.mock('../../../../src/lib/logger', () => ({
logger: {
child: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
error: jest.fn(),

View file

@ -1,140 +0,0 @@
import _ from 'lodash';
import { errorUtils } from '@verdaccio/core';
import { Config } from '@verdaccio/types';
import Auth from '../../../../src/lib/auth';
import AppConfig from '../../../../src/lib/config';
import { ROLES } from '../../../../src/lib/constants';
import { setup } from '../../../../src/lib/logger';
import { IAuth } from '../../../types';
import { authPluginFailureConf, authPluginPassThrougConf, authProfileConf } from './helper/plugin';
setup([]);
describe('AuthTest', () => {
test('should be defined', () => {
const config: Config = new AppConfig(_.cloneDeep(authProfileConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
});
describe('test authenticate method', () => {
describe('test authenticate states', () => {
test('should be a success login', () => {
const config: Config = new AppConfig(_.cloneDeep(authProfileConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.authenticate('foo', 'bar', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: [
'test',
ROLES.$ALL,
ROLES.$AUTH,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_AUTH,
ROLES.ALL,
],
name: 'foo',
real_groups: groups,
});
});
test('should be a fail on login', () => {
const config: Config = new AppConfig(_.cloneDeep(authPluginFailureConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
auth.authenticate('foo', 'bar', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(errorUtils.getInternalError());
});
});
// plugins are free to send whatever they want, so, we need to test some scenarios
// that might make break the request
// the @ts-ignore below are intended
describe('test authenticate out of control inputs from plugins', () => {
test('should skip falsy values', () => {
const config: Config = new AppConfig(_.cloneDeep(authPluginPassThrougConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
let index = 0;
// as defined by https://developer.mozilla.org/en-US/docs/Glossary/Falsy
for (const value of [false, 0, '', null, undefined, NaN]) {
// @ts-ignore
auth.authenticate(null, value, callback);
const call = callback.mock.calls[index++];
expect(call[0]).toBeDefined();
expect(call[1]).toBeUndefined();
}
});
test('should error truthy non-array', () => {
const config: Config = new AppConfig(_.cloneDeep(authPluginPassThrougConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
for (const value of [true, 1, 'test', {}]) {
expect(function () {
// @ts-ignore
auth.authenticate(null, value, callback);
}).toThrow(TypeError);
expect(callback).not.toHaveBeenCalled();
}
});
test('should skip empty array', () => {
const config: Config = new AppConfig(_.cloneDeep(authPluginPassThrougConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
const value = [];
// @ts-ignore
auth.authenticate(null, value, callback);
expect(callback.mock.calls).toHaveLength(1);
expect(callback.mock.calls[0][0]).toBeDefined();
expect(callback.mock.calls[0][1]).toBeUndefined();
});
test('should accept valid array', () => {
const config: Config = new AppConfig(_.cloneDeep(authPluginPassThrougConf));
const auth: IAuth = new Auth(config);
expect(auth).toBeDefined();
const callback = jest.fn();
let index = 0;
for (const value of [[''], ['1'], ['0'], ['000']]) {
// @ts-ignore
auth.authenticate(null, value, callback);
const call = callback.mock.calls[index++];
expect(call[0]).toBeNull();
expect(call[1].real_groups).toBe(value);
}
});
});
});
});

View file

@ -1,113 +0,0 @@
import path from 'path';
import { setup } from '../../../../src/lib/logger';
import loadPlugin from '../../../../src/lib/plugin-loader';
setup([]);
describe('plugin loader', () => {
const relativePath = path.join(__dirname, './partials/test-plugin-storage');
const buildConf = (name) => {
return {
self_path: path.join(__dirname, './'),
max_users: 0,
auth: {
[`${relativePath}/${name}`]: {},
},
};
};
describe('auth plugins', () => {
test('testing auth valid plugin loader', () => {
const _config = buildConf('verdaccio-plugin');
// @ts-ignore
const plugins = loadPlugin(_config, _config.auth, {}, function (plugin) {
return plugin.authenticate || plugin.allow_access || plugin.allow_publish;
});
expect(plugins).toHaveLength(1);
});
test('fails on load scoped auth missing package', () => {
const _config = buildConf('@scope/package');
try {
// @ts-ignore
loadPlugin(_config, { '@scope/package': {} }, {}, undefined);
} catch (e) {
expect(e.message).toMatch(
`@scope/package plugin not found. try \"npm install @scope/package\"`
);
}
});
// This package is locally installed, just a dummy scoped auth plugin
// TODO: move this package to the public registry
test('should load @verdaccio-scope/verdaccio-auth-foo scoped package', () => {
const _config = buildConf('@verdaccio-scope/verdaccio-auth-foo');
// @ts-ignore
const plugins = loadPlugin(
_config,
{ '@verdaccio-scope/verdaccio-auth-foo': {} },
{},
function (plugin) {
return plugin.authenticate || plugin.allow_access || plugin.allow_publish;
}
);
expect(plugins).toHaveLength(1);
});
test('testing storage valid plugin loader', () => {
const _config = buildConf('verdaccio-es6-plugin');
// @ts-ignore
const plugins = loadPlugin(_config, _config.auth, {}, function (p) {
return p.getPackageStorage;
});
expect(plugins).toHaveLength(1);
});
test('testing auth plugin invalid plugin', () => {
const _config = buildConf('invalid-plugin');
try {
// @ts-ignore
loadPlugin(_config, _config.auth, {}, function (p) {
return p.authenticate || p.allow_access || p.allow_publish;
});
} catch (e) {
expect(e.message).toEqual(
`"${relativePath}/invalid-plugin" plugin does not have the right code structure`
);
}
});
test('testing auth plugin invalid plugin sanityCheck', () => {
const _config = buildConf('invalid-plugin-sanity');
try {
// @ts-ignore
loadPlugin(_config, _config.auth, {}, function (plugin) {
return plugin.authenticate || plugin.allow_access || plugin.allow_publish;
});
} catch (err) {
expect(err.message).toEqual(
`sanity check has failed, "${relativePath}/invalid-plugin-sanity" is not a valid plugin`
);
}
});
test('testing auth plugin no plugins', () => {
const _config = buildConf('invalid-package');
try {
// @ts-ignore
loadPlugin(_config, _config.auth, {}, function (plugin) {
return plugin.authenticate || plugin.allow_access || plugin.allow_publish;
});
} catch (e) {
expect(e.message).toMatch('plugin not found');
expect(e.message).toMatch('/partials/test-plugin-storage/invalid-package');
}
});
test.todo('test middleware plugins');
test.todo('test storage plugins');
});
});

View file

@ -1,554 +0,0 @@
import path from 'path';
import rimRaf from 'rimraf';
import { Config, MergeTags, Package } from '@verdaccio/types';
import AppConfig from '../../../../src/lib/config';
import { API_ERROR, DIST_TAGS, HTTP_STATUS } from '../../../../src/lib/constants';
import LocalStorage from '../../../../src/lib/local-storage';
import { logger, setup } from '../../../../src/lib/logger';
import { generatePackageTemplate } from '../../../../src/lib/storage-utils';
import { readFile } from '../../../functional/lib/test.utils';
import { generateNewVersion } from '../../../lib/utils-test';
// @ts-ignore
import configExample from '../../partials/config';
const readMetadata = (fileName = 'metadata') =>
readFile(`../../unit/partials/${fileName}`).toString();
setup([]);
describe('LocalStorage', () => {
let storage: LocalStorage;
const pkgName = 'npm_test';
const pkgNameScoped = `@scope/${pkgName}-scope`;
const tarballName = `${pkgName}-add-tarball-1.0.4.tgz`;
const tarballName2 = `${pkgName}-add-tarball-1.0.5.tgz`;
const getStorage = (LocalStorageClass = LocalStorage) => {
const config: Config = new AppConfig(
configExample({
self_path: path.join('../partials/store'),
})
);
return new LocalStorageClass(config, logger);
};
const getPackageMetadataFromStore = (pkgName: string): Promise<Package> => {
return new Promise((resolve) => {
storage.getPackageMetadata(pkgName, (err, data) => {
resolve(data);
});
});
};
const addNewVersion = (pkgName: string, version: string) => {
return new Promise((resolve) => {
storage.addVersion(
pkgName,
version,
generateNewVersion(pkgName, version),
'',
(err, data) => {
resolve(data);
}
);
});
};
const addTarballToStore = (pkgName: string, tarballName) => {
return new Promise((resolve, reject) => {
const tarballData = JSON.parse(readMetadata('addTarball').toString());
const stream = storage.addTarball(pkgName, tarballName);
stream.on('error', (err) => {
expect(err).toBeNull();
reject();
});
stream.on('success', () => {
resolve();
});
stream.end(Buffer.from(tarballData.data, 'base64'));
stream.done();
});
};
const addPackageToStore = (pkgName, metadata) => {
return new Promise((resolve, reject) => {
// @ts-ignore
const pkgStoragePath = storage._getLocalStorage(pkgName);
rimRaf(pkgStoragePath.path, (err) => {
expect(err).toBeNull();
storage.addPackage(pkgName, metadata, async (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
});
};
beforeAll(() => {
storage = getStorage();
});
test('should be defined', () => {
expect(storage).toBeDefined();
});
describe('LocalStorage::preparePackage', () => {
test('should add a package', (done) => {
const metadata = JSON.parse(readMetadata().toString());
// @ts-ignore
const pkgStoragePath = storage._getLocalStorage(pkgName);
rimRaf(pkgStoragePath.path, (err) => {
expect(err).toBeNull();
storage.addPackage(pkgName, metadata, (err, data) => {
expect(data.version).toMatch(/1.0.0/);
expect(data.dist.tarball).toMatch(/npm_test-1.0.0.tgz/);
expect(data.name).toEqual(pkgName);
done();
});
});
});
test('should add a @scope package', (done) => {
const metadata = JSON.parse(readMetadata());
// @ts-ignore
const pkgStoragePath = storage._getLocalStorage(pkgNameScoped);
rimRaf(pkgStoragePath.path, (err) => {
expect(err).toBeNull();
storage.addPackage(pkgNameScoped, metadata, (err, data) => {
expect(data.version).toMatch(/1.0.0/);
expect(data.dist.tarball).toMatch(/npm_test-1.0.0.tgz/);
expect(data.name).toEqual(pkgName);
done();
});
});
});
test('should fails on add a package', (done) => {
const metadata = JSON.parse(readMetadata());
storage.addPackage(pkgName, metadata, (err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.CONFLICT);
expect(err.message).toMatch(API_ERROR.PACKAGE_EXIST);
done();
});
});
describe('LocalStorage::mergeTags', () => {
test('should mergeTags', async () => {
const pkgName = 'merge-tags-test-1';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
await addNewVersion(pkgName, '1.0.0');
await addNewVersion(pkgName, '2.0.0');
await addNewVersion(pkgName, '3.0.0');
const tags: MergeTags = {
beta: '3.0.0',
latest: '2.0.0',
};
return new Promise((resolve) => {
storage.mergeTags(pkgName, tags, async (err, data) => {
expect(err).toBeNull();
expect(data).toBeUndefined();
const metadata: Package = await getPackageMetadataFromStore(pkgName);
expect(metadata[DIST_TAGS]).toBeDefined();
expect(metadata[DIST_TAGS]['beta']).toBeDefined();
expect(metadata[DIST_TAGS]['beta']).toBe('3.0.0');
expect(metadata[DIST_TAGS]['latest']).toBe('2.0.0');
resolve();
});
});
});
test('should fails mergeTags version not found', async () => {
const pkgName = 'merge-tags-test-1';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
// const tarballName: string = `${pkgName}-${version}.tgz`;
await addNewVersion(pkgName, '1.0.0');
await addNewVersion(pkgName, '2.0.0');
await addNewVersion(pkgName, '3.0.0');
const tags: MergeTags = {
beta: '9999.0.0',
};
return new Promise((done) => {
storage.mergeTags(pkgName, tags, async (err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.NOT_FOUND);
expect(err.message).toMatch(API_ERROR.VERSION_NOT_EXIST);
done();
});
});
});
test('should fails on mergeTags', async () => {
const tags: MergeTags = {
beta: '3.0.0',
latest: '2.0.0',
};
return new Promise((done) => {
storage.mergeTags('not-found', tags, async (err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.NOT_FOUND);
expect(err.message).toMatch(API_ERROR.NO_PACKAGE);
done();
});
});
});
});
describe('LocalStorage::addVersion', () => {
test('should add new version without tag', async () => {
const pkgName = 'add-version-test-1';
const version = '1.0.1';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
const tarballName = `${pkgName}-${version}.tgz`;
await addNewVersion(pkgName, '9.0.0');
await addTarballToStore(pkgName, `${pkgName}-9.0.0.tgz`);
await addTarballToStore(pkgName, tarballName);
return new Promise((done) => {
storage.addVersion(
pkgName,
version,
generateNewVersion(pkgName, version),
'',
(err, data) => {
expect(err).toBeNull();
expect(data).toBeUndefined();
done();
}
);
});
});
test('should fails on add a duplicated version without tag', async () => {
const pkgName = 'add-version-test-2';
const version = '1.0.1';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
await addNewVersion(pkgName, version);
return new Promise((done) => {
storage.addVersion(pkgName, version, generateNewVersion(pkgName, version), '', (err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.CONFLICT);
expect(err.message).toMatch(API_ERROR.PACKAGE_EXIST);
done();
});
});
});
test('should fails add new version wrong shasum', async () => {
const pkgName = 'add-version-test-4';
const version = '4.0.0';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
const tarballName = `${pkgName}-${version}.tgz`;
await addTarballToStore(pkgName, tarballName);
return new Promise((done) => {
storage.addVersion(
pkgName,
version,
generateNewVersion(pkgName, version, 'fake'),
'',
(err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.BAD_REQUEST);
expect(err.message).toMatch(/shasum error/);
done();
}
);
});
});
test('should add new second version without tag', async () => {
const pkgName = 'add-version-test-3';
const version = '1.0.2';
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
await addNewVersion(pkgName, '1.0.1');
await addNewVersion(pkgName, '1.0.3');
return new Promise((done) => {
storage.addVersion(
pkgName,
version,
generateNewVersion(pkgName, version),
'beta',
(err, data) => {
expect(err).toBeNull();
expect(data).toBeUndefined();
done();
}
);
});
});
});
describe('LocalStorage::updateVersions', () => {
const metadata = JSON.parse(readMetadata('metadata-update-versions-tags'));
const pkgName = 'add-update-versions-test-1';
const version = '1.0.2';
let _storage;
beforeEach((done) => {
class MockLocalStorage extends LocalStorage {}
// @ts-ignore
MockLocalStorage.prototype._writePackage = jest.fn(LocalStorage.prototype._writePackage);
_storage = getStorage(MockLocalStorage);
rimRaf(path.join(configExample().storage, pkgName), async () => {
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
await addNewVersion(pkgName, '1.0.1');
await addNewVersion(pkgName, version);
done();
});
});
test('should update versions from external source', async () => {
return new Promise((done) => {
_storage.updateVersions(pkgName, metadata, (err, data) => {
expect(err).toBeNull();
expect(_storage._writePackage).toHaveBeenCalledTimes(1);
expect(data.versions['1.0.1']).toBeDefined();
expect(data.versions[version]).toBeDefined();
expect(data.versions['1.0.4']).toBeDefined();
expect(data[DIST_TAGS]['latest']).toBeDefined();
expect(data[DIST_TAGS]['latest']).toBe('1.0.1');
expect(data[DIST_TAGS]['beta']).toBeDefined();
expect(data[DIST_TAGS]['beta']).toBe('1.0.2');
expect(data[DIST_TAGS]['next']).toBeDefined();
expect(data[DIST_TAGS]['next']).toBe('1.0.4');
expect(data['_rev'] === metadata['_rev']).toBeFalsy();
expect(data.readme).toBe('readme 1.0.4');
done();
});
});
});
test('should not update if the metadata match', (done) => {
_storage.updateVersions(pkgName, metadata, (e) => {
expect(e).toBeNull();
_storage.updateVersions(pkgName, metadata, (err) => {
expect(err).toBeNull();
expect(_storage._writePackage).toHaveBeenCalledTimes(1);
done();
});
});
});
});
describe('LocalStorage::changePackage', () => {
const pkgName = 'change-package';
test('should unpublish a version', async () => {
await addPackageToStore(pkgName, generatePackageTemplate(pkgName));
await addNewVersion(pkgName, '1.0.1');
await addNewVersion(pkgName, '1.0.2');
await addNewVersion(pkgName, '1.0.3');
const metadata = JSON.parse(readMetadata('changePackage/metadata-change'));
const rev: string = metadata['_rev'];
return new Promise((done) => {
storage.changePackage(pkgName, metadata, rev, (err) => {
expect(err).toBeUndefined();
storage.getPackageMetadata(pkgName, (err, data) => {
expect(err).toBeNull();
expect(data.versions['1.0.1']).toBeDefined();
expect(data.versions['1.0.2']).toBeUndefined();
expect(data.versions['1.0.3']).toBeUndefined();
done();
});
});
});
});
});
describe('LocalStorage::tarball operations', () => {
describe('LocalStorage::addTarball', () => {
test('should add a new tarball', (done) => {
const tarballData = JSON.parse(readMetadata('addTarball'));
const stream = storage.addTarball(pkgName, tarballName);
stream.on('error', (err) => {
expect(err).toBeNull();
done();
});
stream.on('success', function () {
done();
});
stream.end(Buffer.from(tarballData.data, 'base64'));
stream.done();
});
test('should add a new second tarball', (done) => {
const tarballData = JSON.parse(readMetadata('addTarball'));
const stream = storage.addTarball(pkgName, tarballName2);
stream.on('error', (err) => {
expect(err).toBeNull();
done();
});
stream.on('success', function () {
done();
});
stream.end(Buffer.from(tarballData.data, 'base64'));
stream.done();
});
test('should fails on add a duplicated new tarball', (done) => {
const tarballData = JSON.parse(readMetadata('addTarball'));
const stream = storage.addTarball(pkgName, tarballName);
stream.on('error', (err: any) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.CONFLICT);
expect(err.message).toMatch(/this package is already present/);
done();
});
stream.end(Buffer.from(tarballData.data, 'base64'));
stream.done();
});
test('should fails on add a new tarball on missing package', (done) => {
const tarballData = JSON.parse(readMetadata('addTarball'));
const stream = storage.addTarball('unexsiting-package', tarballName);
stream.on('error', (err: any) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.NOT_FOUND);
expect(err.message).toMatch(/no such package available/);
done();
});
stream.on('success', () => {
done();
});
stream.end(Buffer.from(tarballData.data, 'base64'));
stream.done();
});
test('should fails on use invalid package name on add a new tarball', (done) => {
const stream = storage.addTarball(pkgName, `${pkgName}-fails-add-tarball-1.0.4.tgz`);
stream.on('error', function (err: any) {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.BAD_DATA);
expect(err.message).toMatch(/refusing to accept zero-length file/);
done();
});
stream.done();
});
test('should fails on abort on add a new tarball', (done) => {
const stream = storage.addTarball('__proto__', `${pkgName}-fails-add-tarball-1.0.4.tgz`);
stream.abort();
stream.on('error', function (err: any) {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.FORBIDDEN);
expect(err.message).toMatch(/can't use this filename/);
done();
});
stream.done();
});
});
describe('LocalStorage::removeTarball', () => {
test('should remove a tarball', (done) => {
storage.removeTarball(pkgName, tarballName2, 'rev', (err, pkg) => {
expect(err).toBeNull();
expect(pkg).toBeUndefined();
done();
});
});
test('should remove a tarball that does not exist', (done) => {
storage.removeTarball(pkgName, tarballName2, 'rev', (err) => {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.NOT_FOUND);
expect(err.message).toMatch(/no such file available/);
done();
});
});
});
describe('LocalStorage::getTarball', () => {
test('should get a existing tarball', (done) => {
const stream = storage.getTarball(pkgName, tarballName);
stream.on('content-length', function (contentLength) {
expect(contentLength).toBe(279);
done();
});
});
test('should fails on get a tarball that does not exist', (done) => {
const stream = storage.getTarball('fake', tarballName);
stream.on('error', function (err: any) {
expect(err).not.toBeNull();
expect(err.statusCode).toEqual(HTTP_STATUS.NOT_FOUND);
expect(err.message).toMatch(/no such file available/);
done();
});
});
});
describe('LocalStorage::search', () => {
test('should find a tarball', (done) => {
// @ts-ignore
const stream = storage.search('99999');
stream.on('data', function each(pkg) {
expect(pkg.name).toEqual(pkgName);
});
stream.on('error', function (err) {
expect(err).not.toBeNull();
done();
});
stream.on('end', function () {
done();
});
});
});
});
describe('LocalStorage::removePackage', () => {
test.skip('should remove completely package', (done) => {
storage.removePackage(pkgName, (err, data) => {
expect(err).toBeNull();
expect(data).toBeUndefined();
done();
});
});
test('should remove completely @scoped package', (done) => {
storage.removePackage(pkgNameScoped, (err, data) => {
expect(err).toBeNull();
expect(data).toBeUndefined();
done();
});
});
test('should fails with package not found', (done) => {
const pkgName = 'npm_test_fake';
storage.removePackage(pkgName, (err) => {
expect(err).not.toBeNull();
expect(err.message).toMatch(/no such package available/);
done();
});
});
test('should fails with @scoped package not found', (done) => {
storage.removePackage(pkgNameScoped, (err) => {
expect(err).not.toBeNull();
expect(err.message).toMatch(API_ERROR.NO_PACKAGE);
done();
});
});
});
});
});

View file

@ -11916,6 +11916,7 @@ __metadata:
"@verdaccio/auth": 8.0.0-next-8.10
"@verdaccio/config": 8.0.0-next-8.10
"@verdaccio/core": 8.0.0-next-8.10
"@verdaccio/loaders": 8.0.0-next-8.4
"@verdaccio/local-storage-legacy": 11.0.2
"@verdaccio/logger": 8.0.0-next-8.10
"@verdaccio/middleware": 8.0.0-next-8.10