Last eslint tweak (#18198)

* Should be there now

* Format
This commit is contained in:
Rijk van Zanten
2023-04-14 17:40:50 -04:00
committed by GitHub
parent 37658802b7
commit c48309ab68
293 changed files with 1627 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ vi.mock('./database', () => ({
vi.mock('./env', async () => {
const actual = (await vi.importActual('./env')) as { default: Record<string, any> };
const MOCK_ENV = {
...actual.default,
KEY: 'xxxxxxx-xxxxxx-xxxxxxxx-xxxxxxxxxx',
@@ -170,9 +171,11 @@ describe('createApp', async () => {
const testRoute = '/custom-endpoint-to-test';
const testResponse = { key: 'value' };
const mockRouter = Router();
mockRouter.use(testRoute, (_, res) => {
res.json(testResponse);
});
mockGetEndpointRouter.mockReturnValueOnce(mockRouter);
const app = await createApp();

View File

@@ -194,6 +194,7 @@ export default async function createApp(): Promise<express.Application> {
// Set the App's base path according to the APIs public URL
const html = await readFile(adminPath, 'utf8');
const htmlWithVars = html
.replace(/<base \/>/, `<base href="${adminUrl.toString({ rootRelative: true })}/" />`)
.replace(/<embed-head \/>/, embeds.head)

View File

@@ -64,9 +64,11 @@ export class LDAPAuthDriver extends AuthDriver {
const clientConfig = typeof config['client'] === 'object' ? config['client'] : {};
this.bindClient = ldap.createClient({ url: clientUrl, reconnect: true, ...clientConfig });
this.bindClient.on('error', (err: Error) => {
logger.warn(err);
});
this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
this.config = config;
}
@@ -332,6 +334,7 @@ export class LDAPAuthDriver extends AuthDriver {
return new Promise((resolve, reject) => {
const clientConfig = typeof this.config['client'] === 'object' ? this.config['client'] : {};
const client = ldap.createClient({
url: this.config['clientUrl'],
...clientConfig,

View File

@@ -121,6 +121,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
{ code: payload['code'], state: payload['state'] },
{ code_verifier: payload['codeVerifier'], state: generators.codeChallenge(payload['codeVerifier']) }
);
userInfo = await this.client.userinfo(tokenSet.access_token!);
} catch (e) {
throw handleError(e);
@@ -270,6 +271,7 @@ export function createOAuth2AuthRouter(providerName: string): Router {
const provider = getAuthProvider(providerName) as OAuth2AuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const prompt = !!req.query['prompt'];
const token = jwt.sign(
{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
env['SECRET'] as string,
@@ -297,6 +299,7 @@ export function createOAuth2AuthRouter(providerName: string): Router {
},
respond
);
router.get(
'/callback',
asyncHandler(async (req, res, next) => {
@@ -337,6 +340,7 @@ export function createOAuth2AuthRouter(providerName: string): Router {
try {
res.clearCookie(`oauth2.${providerName}`);
authResponse = await authenticationService.login(providerName, {
code: req.query['code'],
codeVerifier: verifier,

View File

@@ -55,6 +55,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
this.redirectUrl = redirectUrl.toString();
this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
this.config = additionalConfig;
this.client = new Promise((resolve, reject) => {
Issuer.discover(issuerUrl)
.then((issuer) => {
@@ -133,11 +134,13 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
try {
const client = await this.client;
const codeChallenge = generators.codeChallenge(payload['codeVerifier']);
tokenSet = await client.callback(
this.redirectUrl,
{ code: payload['code'], state: payload['state'], iss: payload['iss'] },
{ code_verifier: payload['codeVerifier'], state: codeChallenge, nonce: codeChallenge }
);
userInfo = tokenSet.claims();
if (client.issuer.metadata['userinfo_endpoint']) {
@@ -297,6 +300,7 @@ export function createOpenIDAuthRouter(providerName: string): Router {
const provider = getAuthProvider(providerName) as OpenIDAuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const prompt = !!req.query['prompt'];
const token = jwt.sign(
{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
env['SECRET'] as string,
@@ -365,6 +369,7 @@ export function createOpenIDAuthRouter(providerName: string): Router {
try {
res.clearCookie(`openid.${providerName}`);
authResponse = await authenticationService.login(providerName, {
code: req.query['code'],
codeVerifier: verifier,

View File

@@ -137,6 +137,7 @@ export async function apply(snapshotPath: string, options?: { yes: boolean; dryR
}
message = 'The following changes will be applied:\n\n' + chalk.black(message);
if (dryRun) {
logger.info(message);
process.exit(0);

View File

@@ -38,14 +38,17 @@ export async function createCli(): Promise<Command> {
const dbCommand = program.command('database');
dbCommand.command('install').description('Install the database').action(dbInstall);
dbCommand
.command('migrate:latest')
.description('Upgrade the database')
.action(() => dbMigrate('latest'));
dbCommand
.command('migrate:up')
.description('Upgrade the database')
.action(() => dbMigrate('up'));
dbCommand
.command('migrate:down')
.description('Downgrade the database')
@@ -69,6 +72,7 @@ export async function createCli(): Promise<Command> {
.action(usersPasswd);
const rolesCommand = program.command('roles');
rolesCommand
.command('create')
.description('Create a new role')

View File

@@ -29,6 +29,7 @@ router.get(
const defaults = { storage_asset_presets: [], storage_asset_transform: 'all' };
const database = getDatabase();
const savedAssetSettings = await database
.select('storage_asset_presets', 'storage_asset_transform')
.from('directus_settings')
@@ -81,6 +82,7 @@ router.get(
}
const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation['key']!);
const allKeys: string[] = [
...systemKeys,
...(assetSettings.storage_asset_presets || []).map(

View File

@@ -218,6 +218,7 @@ router.get(
data: getAuthProviders(),
disableDefault: env['AUTH_DISABLE_DEFAULT'],
};
return next();
}),
respond

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -53,10 +53,12 @@ router.get(
}
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
res.setHeader(
'Cache-Control',
getCacheControlHeader(req, getMilliseconds(env['EXTENSIONS_CACHE_TTL']), false, false)
);
res.setHeader('Vary', 'Origin, Cache-Control');
res.end(source);
})

View File

@@ -21,6 +21,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const fields = await service.readAll();
res.locals['payload'] = { data: fields || null };
@@ -37,6 +38,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const fields = await service.readAll(req.params['collection']);
res.locals['payload'] = { data: fields || null };
@@ -215,6 +217,7 @@ router.delete(
accountability: req.accountability,
schema: req.schema,
});
await service.deleteField(req.params['collection']!, req.params['field']!);
return next();
}),

View File

@@ -28,6 +28,7 @@ describe('multipartHandler', () => {
params: {},
pipe: (input: NodeJS.WritableStream) => stream.pipe(input),
} as unknown as Request;
const res = {} as Response;
const stream = new PassThrough();
@@ -56,6 +57,7 @@ describe('multipartHandler', () => {
params: {},
pipe: (input: NodeJS.WritableStream) => stream.pipe(input),
} as unknown as Request;
const res = {} as Response;
const stream = new PassThrough();

View File

@@ -128,6 +128,7 @@ router.post(
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey | PrimaryKey[] = [];
if (req.is('multipart/form-data')) {

View File

@@ -84,6 +84,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
@@ -88,6 +89,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readOne(req.params['pk']!, req.sanitizedQuery);
res.locals['payload'] = { data: record || null };

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -86,6 +86,7 @@ router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
if (req.path.endsWith('me')) return next();
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -34,6 +34,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const relations = await service.readAll(req.params['collection']);
res.locals['payload'] = { data: relations || null };
@@ -156,6 +157,7 @@ router.delete(
accountability: req.accountability,
schema: req.schema,
});
await service.deleteOne(req.params['collection']!, req.params['field']!);
return next();
}),

View File

@@ -15,6 +15,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -55,6 +55,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const data = await service.serverInfo();
res.locals['payload'] = { data };
return next();

View File

@@ -16,6 +16,7 @@ router.get(
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readSingleton(req.sanitizedQuery);
res.locals['payload'] = { data: records || null };
return next();
@@ -30,6 +31,7 @@ router.patch(
accountability: req.accountability,
schema: req.schema,
});
await service.upsertSingleton(req.body);
try {

View File

@@ -90,6 +90,7 @@ router.get(
app_access: false,
},
};
res.locals['payload'] = { data: user };
return next();
}
@@ -124,6 +125,7 @@ router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
if (req.path.endsWith('me')) return next();
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
@@ -293,6 +295,7 @@ router.post(
accountability: req.accountability,
schema: req.schema,
});
await service.inviteUser(req.body.email, req.body.role, req.body.invite_url || null);
return next();
}),
@@ -309,10 +312,12 @@ router.post(
asyncHandler(async (req, _res, next) => {
const { error } = acceptInviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
await service.acceptInvite(req.body.token, req.body.password);
return next();
}),
@@ -339,6 +344,7 @@ router.post(
accountability: req.accountability,
schema: req.schema,
});
await authService.verifyPassword(req.accountability.user, req.body.password);
const { url, secret } = await service.generateTFA(req.accountability.user);
@@ -369,6 +375,7 @@ router.post(
const rolesService = new RolesService({
schema: req.schema,
});
const role = (await rolesService.readOne(req.accountability.role)) as Role;
if (role && role.enforce_tfa) {
@@ -423,6 +430,7 @@ router.post(
const rolesService = new RolesService({
schema: req.schema,
});
const role = (await rolesService.readOne(req.accountability.role)) as Role;
if (role && role.enforce_tfa) {

View File

@@ -80,6 +80,7 @@ router.post(
accountability: req.accountability,
schema: req.schema,
});
await service.sort(req.collection, req.body);
return res.status(200).end();
@@ -93,6 +94,7 @@ router.post(
accountability: req.accountability,
schema: req.schema,
});
await service.revert(req.params['revision']!);
next();
}),

View File

@@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -132,6 +132,7 @@ export abstract class SchemaHelper extends DatabaseHelper {
knex.ref('directus_row_number').toQuery(),
knex.raw(`partition by ?? order by ${orderByString}`, [`${table}.${primaryKey}`, ...orderByFields])
);
return dbQuery;
}

View File

@@ -264,6 +264,7 @@ export async function validateMigrations(): Promise<boolean> {
migrationFiles.push(...customMigrationFiles);
const requiredVersions = migrationFiles.map((filePath) => filePath.split('-')[0]);
const completedVersions = (await database.select('version').from('directus_migrations')).map(
({ version }) => version
);

View File

@@ -8,6 +8,7 @@ export async function up(knex: Knex): Promise<void> {
const inspector = createInspector(knex);
const foreignKeys = await inspector.foreignKeys();
const relations = await knex
.select<RelationMeta[]>('id', 'many_collection', 'many_field', 'one_collection')
.from('directus_relations');
@@ -16,6 +17,7 @@ export async function up(knex: Knex): Promise<void> {
const exists = !!foreignKeys.find(
(fk) => fk.table === relation?.many_collection && fk.column === relation?.many_field
);
return exists === false;
});
@@ -43,6 +45,7 @@ export async function up(knex: Knex): Promise<void> {
logger.warn(
`Illegal relationship ${constraint.many_collection}.${constraint.many_field}<->${constraint.one_collection} encountered. Many field equals collections primary key.`
);
corruptedRelations.push(constraint.id);
continue;
}
@@ -101,6 +104,7 @@ export async function up(knex: Knex): Promise<void> {
}
const indexName = getDefaultIndexName('foreign', constraint.many_collection, constraint.many_field);
const builder = table
.foreign(constraint.many_field, indexName)
.references(relatedPrimaryKeyField)
@@ -115,6 +119,7 @@ export async function up(knex: Knex): Promise<void> {
logger.warn(
`Couldn't add foreign key constraint for ${constraint.many_collection}.${constraint.many_field}<->${constraint.one_collection}`
);
logger.warn(err);
}
}
@@ -144,6 +149,7 @@ export async function down(knex: Knex): Promise<void> {
logger.warn(
`Couldn't drop foreign key constraint for ${relation.many_collection}.${relation.many_field}<->${relation.one_collection}`
);
logger.warn(err);
}
}

View File

@@ -118,6 +118,7 @@ export async function up(knex: Knex): Promise<void> {
logger.warn(
`Couldn't clean up index for foreign key ${update.table}.${constraint.column}->${constraint.references}`
);
logger.warn(err);
}
}
@@ -160,6 +161,7 @@ export async function down(knex: Knex): Promise<void> {
logger.warn(
`Couldn't clean up index for foreign key ${update.table}.${constraint.column}->${constraint.references}`
);
logger.warn(err);
}
}

View File

@@ -4,6 +4,7 @@ import { getHelpers } from '../helpers/index.js';
export async function up(knex: Knex): Promise<void> {
const helper = getHelpers(knex).schema;
const type = helper.isOneOfClients(['oracle', 'cockroachdb']) ? 'text' : 'string';
await helper.changeToType('directus_webhooks', 'collections', type, {
nullable: false,
});

View File

@@ -26,6 +26,7 @@ describe('run', () => {
expect(e.message).toBe('Nothing to upgrade');
});
});
it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => {
tracker.on.select('directus_migrations').response([]);
@@ -34,6 +35,7 @@ describe('run', () => {
expect(e.message).toBe('Method implemented in the dialect driver');
});
});
it('returns undefined if the migration is successful', async () => {
tracker.on.select('directus_migrations').response([
{
@@ -42,12 +44,14 @@ describe('run', () => {
timestamp: '2021-11-27 11:36:56.471595-05',
},
]);
tracker.on.delete('directus_relations').response([]);
tracker.on.insert('directus_migrations').response(['Remove System Relations', '20201029A']);
expect(await run(db, 'up')).toBe(undefined);
});
});
describe('when passed the argument down', () => {
it('returns "Nothing To downgrade" if no valid directus_migrations', async () => {
tracker.on.select('directus_migrations').response(['Empty']);
@@ -57,6 +61,7 @@ describe('run', () => {
expect(e.message).toBe(`Couldn't find migration`);
});
});
it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => {
tracker.on.select('directus_migrations').response([]);
@@ -65,6 +70,7 @@ describe('run', () => {
expect(e.message).toBe('Nothing to downgrade');
});
});
it(`returns "Couldn't find migration" if an invalid migration object is supplied`, async () => {
tracker.on.select('directus_migrations').response([
{
@@ -73,12 +79,14 @@ describe('run', () => {
timestamp: '2020-00-32 11:36:56.471595-05',
},
]);
await run(db, 'down').catch((e: Error) => {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe(`Couldn't find migration`);
});
});
});
describe('when passed the argument latest', () => {
it('returns "Nothing To downgrade" if no valid directus_migrations', async () => {
tracker.on.select('directus_migrations').response(['Empty']);
@@ -88,8 +96,10 @@ describe('run', () => {
expect(e.message).toBe(`Method implemented in the dialect driver`);
});
});
it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => {
tracker.on.select('directus_migrations').response([]);
await run(db, 'latest').catch((e: Error) => {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe('Method implemented in the dialect driver');

View File

@@ -15,6 +15,7 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la
let migrationFiles = await fse.readdir(__dirname);
const customMigrationsPath = path.resolve(env['EXTENSIONS_PATH'], 'migrations');
let customMigrationFiles =
((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || [];

View File

@@ -335,6 +335,7 @@ async function getDBQuery(
sortRecords.map((sortRecord) => {
if (sortRecord.column.includes('.')) {
const [alias, field] = sortRecord.column.split('.');
sortRecord.column = getColumn(knex, alias!, field!, false, schema, {
originalCollectionName: getCollectionFromAlias(alias!, aliasMap),
}) as any;
@@ -498,6 +499,7 @@ function mergeWithParentItems(
if (a[column] === b[column]) return 0;
if (a[column] === null) return 1;
if (b[column] === null) return -1;
if (order === 'asc') {
return a[column] < b[column] ? -1 : 1;
} else {

View File

@@ -29,6 +29,7 @@ export class Emitter {
context: EventContext
): Promise<T> {
const events = Array.isArray(event) ? event : [event];
const eventListeners = events.map((event) => ({
event,
listeners: this.filterEmitter.listeners(event) as FilterHandler<T>[],

View File

@@ -443,6 +443,7 @@ function processValues(env: Record<string, any>) {
if (key.length > 5 && key.endsWith('_FILE')) {
newKey = key.slice(0, -5);
if (allowedEnvironmentVars.some((pattern) => pattern.test(newKey as string))) {
if (newKey in env && !(newKey in defaults && env[newKey] === defaults[newKey])) {
throw new Error(

View File

@@ -171,6 +171,7 @@ class ExtensionManager {
const added = this.extensions.filter(
(extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path)
);
const removed = prevExtensions.filter(
(prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path)
);
@@ -356,6 +357,7 @@ class ExtensionManager {
private async generateExtensionBundle(): Promise<string | null> {
const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS);
const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
find: name,
replacement: path,
@@ -370,6 +372,7 @@ class ExtensionManager {
makeAbsoluteExternalsRelative: false,
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })],
});
const { output } = await bundle.generate({ format: 'es', compact: true });
for (const out of output) {
@@ -470,6 +473,7 @@ class ExtensionManager {
for (const operation of [...internalOperations, ...operations]) {
try {
const operationPath = path.resolve(operation.path, operation.entrypoint.api!);
const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import(
`file://${operationPath}`
);

View File

@@ -181,6 +181,7 @@ class FlowManager {
);
events.forEach((event) => emitter.onFilter(event, handler));
this.triggerHandlers.push({
id: flow.id,
events: events.map((event) => ({ type: 'filter', name: event, handler })),
@@ -194,6 +195,7 @@ class FlowManager {
});
events.forEach((event) => emitter.onAction(event, handler));
this.triggerHandlers.push({
id: flow.id,
events: events.map((event) => ({ type: 'action', name: event, handler })),

View File

@@ -11,6 +11,7 @@ vi.mock('./env', async () => {
LOG_LEVEL: 'info',
LOG_STYLE: 'raw',
};
return {
default: MOCK_ENV,
getEnv: () => MOCK_ENV,
@@ -41,6 +42,7 @@ afterEach(() => {
describe('req.headers.authorization', () => {
test('Should redact bearer token in Authorization header', () => {
const instance = pino(httpLoggerOptions, stream);
instance.info({
req: {
headers: {
@@ -48,6 +50,7 @@ describe('req.headers.authorization', () => {
},
},
});
expect(logOutput.mock.calls[0][0]).toMatchObject({
req: {
headers: {
@@ -61,6 +64,7 @@ describe('req.headers.authorization', () => {
describe('req.headers.cookie', () => {
test('Should redact refresh token when there is only one entry', () => {
const instance = pino(httpLoggerOptions, stream);
instance.info({
req: {
headers: {
@@ -68,6 +72,7 @@ describe('req.headers.cookie', () => {
},
},
});
expect(logOutput.mock.calls[0][0]).toMatchObject({
req: {
headers: {
@@ -79,6 +84,7 @@ describe('req.headers.cookie', () => {
test('Should redact refresh token with multiple entries', () => {
const instance = pino(httpLoggerOptions, stream);
instance.info({
req: {
headers: {
@@ -86,6 +92,7 @@ describe('req.headers.cookie', () => {
},
},
});
expect(logOutput.mock.calls[0][0]).toMatchObject({
req: {
headers: {
@@ -99,6 +106,7 @@ describe('req.headers.cookie', () => {
describe('res.headers', () => {
test('Should redact refresh token when there is only one entry', () => {
const instance = pino(httpLoggerOptions, stream);
instance.info({
res: {
headers: {
@@ -106,6 +114,7 @@ describe('res.headers', () => {
},
},
});
expect(logOutput.mock.calls[0][0]).toMatchObject({
res: {
headers: {
@@ -117,6 +126,7 @@ describe('res.headers', () => {
test('Should redact refresh token with multiple entries', () => {
const instance = pino(httpLoggerOptions, stream);
instance.info({
res: {
headers: {
@@ -129,6 +139,7 @@ describe('res.headers', () => {
},
},
});
expect(logOutput.mock.calls[0][0]).toMatchObject({
res: {
headers: {

View File

@@ -33,6 +33,7 @@ if (env['LOG_STYLE'] !== 'raw') {
sync: true,
},
};
httpLoggerOptions.transport = {
target: 'pino-http-print',
options: {
@@ -85,6 +86,7 @@ if (loggerEnvConfig['levels']) {
};
},
};
httpLoggerOptions.formatters = {
level(label: string, number: any) {
return {

View File

@@ -54,6 +54,7 @@ export default function getMailer(): Transporter {
} as Record<string, unknown>);
} else if (transportName === 'mailgun') {
const mg = require('nodemailer-mailgun-transport');
transporter = nodemailer.createTransport(
mg({
auth: {
@@ -65,6 +66,7 @@ export default function getMailer(): Transporter {
);
} else if (transportName === 'sendgrid') {
const sg = require('nodemailer-sendgrid');
transporter = nodemailer.createTransport(
sg({
apiKey: env['EMAIL_SENDGRID_API_KEY'],

View File

@@ -10,10 +10,12 @@ import { InvalidCredentialsException } from '../exceptions/invalid-credentials.j
import { handler } from './authenticate.js';
vi.mock('../../src/database');
vi.mock('../../src/env', () => {
const MOCK_ENV = {
SECRET: 'test',
};
return {
default: MOCK_ENV,
getEnv: () => MOCK_ENV,
@@ -29,6 +31,7 @@ test('Short-circuits when authenticate filter is used', async () => {
ip: '127.0.0.1',
get: vi.fn(),
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
@@ -56,6 +59,7 @@ test('Uses default public accountability when no token is given', async () => {
}
}),
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
@@ -80,10 +84,12 @@ test('Sets accountability to payload contents if valid token is passed', async (
const userID = '3fac3c02-607f-4438-8d6e-6b8b25109b52';
const roleID = '38269fc6-6eb6-475a-93cb-479d97f73039';
const share = 'ca0ad005-f4ad-4bfe-b428-419ee8784790';
const shareScope = {
collection: 'articles',
item: 15,
};
const appAccess = true;
const adminAccess = false;
@@ -114,6 +120,7 @@ test('Sets accountability to payload contents if valid token is passed', async (
}),
token,
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
@@ -189,6 +196,7 @@ test('Throws InvalidCredentialsException when static token is used, but user doe
}),
token: 'static-token',
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
@@ -211,6 +219,7 @@ test('Sets accountability to user information when static token is used', async
}),
token: 'static-token',
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();

View File

@@ -47,6 +47,7 @@ function validateConfiguration() {
const globalPointsPerSec =
Number(env['RATE_LIMITER_GLOBAL_POINTS']) / Math.max(Number(env['RATE_LIMITER_GLOBAL_DURATION']), 1);
const regularPointsPerSec = Number(env['RATE_LIMITER_POINTS']) / Math.max(Number(env['RATE_LIMITER_DURATION']), 1);
if (globalPointsPerSec <= regularPointsPerSec) {

View File

@@ -55,6 +55,7 @@ test(`Short circuits on Array body in update/delete use`, async () => {
test(`Sets sanitizedQuery based on body.query in read operations`, async () => {
mockRequest.method = 'SEARCH';
mockRequest.body = {
query: {
sort: 'id',
@@ -70,6 +71,7 @@ test(`Sets sanitizedQuery based on body.query in read operations`, async () => {
test(`Doesn't allow both query and keys in a batch delete`, async () => {
mockRequest.method = 'DELETE';
mockRequest.body = {
keys: [1, 2, 3],
query: { filter: {} },
@@ -83,6 +85,7 @@ test(`Doesn't allow both query and keys in a batch delete`, async () => {
test(`Requires 'data' on batch update`, async () => {
mockRequest.method = 'PATCH';
mockRequest.body = {
keys: [1, 2, 3],
query: { filter: {} },

View File

@@ -9,6 +9,7 @@ describe('Operations / Condition', () => {
_eq: true,
},
};
const data = {
status: true,
};
@@ -22,6 +23,7 @@ describe('Operations / Condition', () => {
_eq: true,
},
};
const data = {
status: false,
};
@@ -42,6 +44,7 @@ describe('Operations / Condition', () => {
_eq: true,
},
};
const data = {};
expect.assertions(2); // ensure catch block is reached

View File

@@ -96,6 +96,7 @@ describe('Operations / Item Delete', () => {
test('should emit events for deleteOne when true', async () => {
const key = 1;
await config.handler(
{ collection: testCollection, key, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -106,6 +107,7 @@ describe('Operations / Item Delete', () => {
test.each([undefined, false])('should not emit events for deleteOne when %s', async (emitEvents) => {
const key = 1;
await config.handler(
{ collection: testCollection, key, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any
@@ -127,6 +129,7 @@ describe('Operations / Item Delete', () => {
test('should emit events for deleteMany when true', async () => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, key: keys, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -137,6 +140,7 @@ describe('Operations / Item Delete', () => {
test.each([undefined, false])('should not emit events for deleteMany when %s', async (emitEvents) => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, key: keys, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any

View File

@@ -96,6 +96,7 @@ describe('Operations / Item Read', () => {
test('should emit events for readOne when true', async () => {
const key = 1;
await config.handler(
{ collection: testCollection, key, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -106,6 +107,7 @@ describe('Operations / Item Read', () => {
test.each([undefined, false])('should not emit events for readOne when %s', async (emitEvents) => {
const key = 1;
await config.handler(
{ collection: testCollection, key, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any
@@ -127,6 +129,7 @@ describe('Operations / Item Read', () => {
test('should emit events for readMany when true', async () => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, key: keys, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -137,6 +140,7 @@ describe('Operations / Item Read', () => {
test.each([undefined, false])('should not emit events for readMany when %s', async (emitEvents) => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, key: keys, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any

View File

@@ -59,6 +59,7 @@ describe('Operations / Item Update', () => {
test.each([undefined, []])('should call updateByQuery with correct query when key is $payload', async (key) => {
const query = { limit: -1 };
await config.handler(
{ collection: testCollection, payload: testPayload, query, key } as any,
{ accountability: testAccountability, getSchema } as any
@@ -71,6 +72,7 @@ describe('Operations / Item Update', () => {
test('should emit events for updateByQuery when true', async () => {
const query = { limit: -1 };
await config.handler(
{ collection: testCollection, payload: testPayload, query, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -83,6 +85,7 @@ describe('Operations / Item Update', () => {
test.each([undefined, false])('should not emit events for updateByQuery when %s', async (emitEvents) => {
const query = { limit: -1 };
await config.handler(
{ collection: testCollection, payload: testPayload, query, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any
@@ -106,6 +109,7 @@ describe('Operations / Item Update', () => {
test('should emit events for updateOne when true', async () => {
const key = 1;
await config.handler(
{ collection: testCollection, payload: testPayload, key, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -116,6 +120,7 @@ describe('Operations / Item Update', () => {
test.each([undefined, false])('should not emit events for updateOne when %s', async (emitEvents) => {
const key = 1;
await config.handler(
{ collection: testCollection, payload: testPayload, key: key, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any
@@ -137,6 +142,7 @@ describe('Operations / Item Update', () => {
test('should emit events for updateMany when true', async () => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, payload: testPayload, key: keys, emitEvents: true } as any,
{ accountability: testAccountability, getSchema } as any
@@ -147,6 +153,7 @@ describe('Operations / Item Update', () => {
test.each([undefined, false])('should not emit events for updateMany when %s', async (emitEvents) => {
const keys = [1, 2, 3];
await config.handler(
{ collection: testCollection, payload: testPayload, key: keys, emitEvents } as any,
{ accountability: testAccountability, getSchema } as any

View File

@@ -17,6 +17,7 @@ export default defineOperationApi<Options>({
// If 'body' is of type object/undefined (happens when body consists solely of a placeholder)
// convert it to JSON string
const safeBody = typeof body !== 'string' ? JSON.stringify(body) : body;
await mailService.send({
html: type === 'wysiwyg' ? safeBody : md(safeBody),
to,

View File

@@ -43,6 +43,7 @@ export default defineOperationApi<Options>({
message: messageString,
};
});
const result = await notificationsService.createMany(payload);
return result;

View File

@@ -37,10 +37,12 @@ test('no headers configured', async () => {
test('headers array is converted to object', async () => {
const body = 'body';
const headers = [
{ header: 'header1', value: 'value1' },
{ header: 'header2', value: 'value2' },
];
await config.handler({ url, method, body, headers }, {} as any);
expect(axiosDefault).toHaveBeenCalledWith(

View File

@@ -48,6 +48,7 @@ test(`Checks against IPs of local networkInterfaces if IP deny list contains 0.0
test(`Throws error if IP address matches resolved localhost IP`, async () => {
vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: ['0.0.0.0'] });
vi.mocked(os.networkInterfaces).mockReturnValue({
fa0: undefined,
lo0: [

View File

@@ -347,10 +347,12 @@ export class AuthenticationService {
if (record.share_id) {
tokenPayload.share = record.share_id;
tokenPayload.role = record.share_role;
tokenPayload.share_scope = {
collection: record.share_collection,
item: record.share_item,
};
tokenPayload.app_access = false;
tokenPayload.admin_access = false;

View File

@@ -38,6 +38,7 @@ export class AuthorizationService {
this.knex = options.knex || getDatabase();
this.accountability = options.accountability || null;
this.schema = options.schema;
this.payloadService = new PayloadService('directus_permissions', {
knex: this.knex,
schema: this.schema,
@@ -236,6 +237,7 @@ export class AuthorizationService {
parentCollection,
parentField
);
result = mergeRequiredFieldPermissions(result, requiredPermissions);
}
}
@@ -306,6 +308,7 @@ export class AuthorizationService {
parentCollection =
relation.related_collection === parentCollection ? relation.collection : relation.related_collection!;
(result[parentCollection] || (result[parentCollection] = new Set())).add(filterKey);
}
@@ -324,6 +327,7 @@ export class AuthorizationService {
parentCollection,
filterKey
);
result = mergeRequiredFieldPermissions(result, requiredPermissions);
}
}
@@ -335,6 +339,7 @@ export class AuthorizationService {
parentCollection,
filterKey
);
result = mergeRequiredFieldPermissions(result, requiredPermissions);
}
}

View File

@@ -141,6 +141,7 @@ export class CollectionsService {
});
const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[];
await fieldItemsService.createMany(fieldPayloads, {
bypassEmitAction: (params) =>
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
@@ -213,6 +214,7 @@ export class CollectionsService {
autoPurgeSystemCache: false,
bypassEmitAction: (params) => nestedActionEvents.push(params),
});
collectionNames.push(name);
}
@@ -456,6 +458,7 @@ export class CollectionsService {
bypassEmitAction: (params) =>
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
});
collectionKeys.push(payload[collectionKey]);
}
});
@@ -636,6 +639,7 @@ export class CollectionsService {
const newAllowedCollections = relation
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
.join(',');
await trx('directus_relations')
.update({ one_allowed_collections: newAllowedCollections })
.where({ id: relation.meta!.id });

View File

@@ -196,6 +196,7 @@ export class FieldsService {
});
if (!permissions || !permissions.fields) throw new ForbiddenException();
if (permissions.fields.includes('*') === false) {
const allowedFields = permissions.fields;
if (allowedFields.includes(field) === false) throw new ForbiddenException();

View File

@@ -29,6 +29,7 @@ describe('Integration Tests', () => {
knex: db,
schema: { collections: {}, relations: [] },
});
superCreateOne = vi.spyOn(ItemsService.prototype, 'createOne').mockReturnValue(Promise.resolve(1));
});

View File

@@ -271,6 +271,7 @@ export class FilesService extends ItemsService {
try {
const axios = await getAxios();
fileResponse = await axios.get<Readable>(encodeURL(importURL), {
responseType: 'stream',
});

View File

@@ -204,6 +204,7 @@ export class GraphQLService {
const scopeFilter = (collection: SchemaOverview['collections'][string]) => {
if (this.scope === 'items' && collection.collection.startsWith('directus_') === true) return false;
if (this.scope === 'system') {
if (collection.collection.startsWith('directus_') === false) return false;
if (SYSTEM_DENY_LIST.includes(collection.collection)) return false;
@@ -239,6 +240,7 @@ export class GraphQLService {
acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection]!.getResolver(
`${collection.collection}_by_id`
);
acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection]!.getResolver(
`${collection.collection}_aggregated`
);
@@ -264,12 +266,15 @@ export class GraphQLService {
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[`create_${collectionName}_items`] = CreateCollectionTypes[collection.collection]!.getResolver(
`create_${collection.collection}_items`
);
acc[`create_${collectionName}_item`] = CreateCollectionTypes[collection.collection]!.getResolver(
`create_${collection.collection}_item`
);
return acc;
}, {} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>)
);
@@ -893,6 +898,7 @@ export class GraphQLService {
type: GraphQLFloat,
description: field.note,
};
break;
default:
break;
@@ -1115,6 +1121,7 @@ export class GraphQLService {
}
} else if (relation.meta?.one_allowed_collections) {
ReadableCollectionFilterTypes[relation.collection]?.removeField('item');
for (const collection of relation.meta.one_allowed_collections) {
ReadableCollectionFilterTypes[relation.collection]?.addFields({
[`item__${collection}`]: ReadableCollectionFilterTypes[collection]!,
@@ -1997,6 +2004,7 @@ export class GraphQLService {
accountability: this.accountability,
scope: args['scope'] ?? 'items',
});
return service.getSchema('sdl');
},
},
@@ -2011,6 +2019,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
return await service.serverInfo();
},
},
@@ -2021,6 +2030,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
return await service.health();
},
},
@@ -2065,6 +2075,7 @@ export class GraphQLService {
accountability: accountability,
schema: this.schema,
});
const result = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, args?.otp);
if (args['mode'] === 'cookie') {
@@ -2105,6 +2116,7 @@ export class GraphQLService {
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args['refresh_token'] || req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
if (!currentRefreshToken) {
@@ -2150,6 +2162,7 @@ export class GraphQLService {
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args['refresh_token'] || req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
if (!currentRefreshToken) {
@@ -2224,14 +2237,17 @@ export class GraphQLService {
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
});
const authService = new AuthenticationService({
accountability: this.accountability,
schema: this.schema,
});
await authService.verifyPassword(this.accountability.user, args['password']);
const { url, secret } = await service.generateTFA(this.accountability.user);
return { secret, otpauth_url: url };
@@ -2245,6 +2261,7 @@ export class GraphQLService {
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
@@ -2261,10 +2278,12 @@ export class GraphQLService {
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
});
const otpValid = await service.verifyOTP(this.accountability.user, args['otp']);
if (otpValid === false) {
@@ -2321,6 +2340,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
const { item, to } = args;
await service.sort(args['collection'], { item, to });
return true;
@@ -2336,6 +2356,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.revert(args['revision']);
return true;
},
@@ -2366,6 +2387,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.acceptInvite(args['token'], args['password']);
return true;
},
@@ -2474,6 +2496,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll();
},
},
@@ -2502,6 +2525,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
return await service.readOne(args['collection'], args['field']);
},
},
@@ -2575,6 +2599,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
return await service.readOne(args['collection'], args['field']);
},
},
@@ -2599,6 +2624,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.createOne(args['data']);
return await collectionsService.readOne(collectionKey);
},
@@ -2616,6 +2642,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.updateOne(args['collection'], args['data']);
return await collectionsService.readOne(collectionKey);
},
@@ -2635,6 +2662,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await collectionsService.deleteOne(args['collection']);
return { collection: args['collection'] };
},
@@ -2653,6 +2681,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.createField(args['collection'], args['data']);
return await service.readOne(args['collection'], args['data'].field);
},
@@ -2669,10 +2698,12 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.updateField(args['collection'], {
...args['data'],
field: args['field'],
});
return await service.readOne(args['collection'], args['data'].field);
},
},
@@ -2693,6 +2724,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.deleteField(args['collection'], args['field']);
const { collection, field } = args;
return { collection, field };
@@ -2750,6 +2782,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await relationsService.deleteOne(args['collection'], args['field']);
return { collection: args['collection'], field: args['field'] };
},
@@ -2764,10 +2797,12 @@ export class GraphQLService {
resolve: async (_, args, __, info) => {
if (!this.accountability?.user) return null;
const service = new UsersService({ schema: this.schema, accountability: this.accountability });
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(this.accountability.user, query);
@@ -2785,6 +2820,7 @@ export class GraphQLService {
},
resolve: async (_, args, __, info) => {
if (!this.accountability?.user) return null;
const service = new UsersService({
schema: this.schema,
accountability: this.accountability,
@@ -2797,6 +2833,7 @@ export class GraphQLService {
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(this.accountability.user, query);
@@ -2837,6 +2874,7 @@ export class GraphQLService {
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
@@ -2861,6 +2899,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.updateOne(args['id'], { comment: args['comment'] });
if ('directus_activity' in ReadCollectionTypes) {
@@ -2868,6 +2907,7 @@ export class GraphQLService {
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
@@ -2891,6 +2931,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.deleteOne(args['id']);
return { id: args['id'] };
},
@@ -2913,6 +2954,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.importOne(args['url'], args['data']);
if ('directus_files' in ReadCollectionTypes) {
@@ -2920,6 +2962,7 @@ export class GraphQLService {
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
}
@@ -2944,6 +2987,7 @@ export class GraphQLService {
accountability: this.accountability,
schema: this.schema,
});
await service.inviteUser(args['email'], args['role'], args['invite_url'] || null);
return true;
},

View File

@@ -6,6 +6,7 @@ export const GraphQLBigInt = new GraphQLScalarType({
serialize(value) {
if (!value) return value;
if (typeof value === 'string') return value;
if (typeof value !== 'number') {
throw new Error('Value must be a Number');
}

View File

@@ -4,6 +4,7 @@ import processError from './process-error.js';
describe('GraphQL processError util', () => {
const sampleError = new GraphQLError('An error message', { path: ['test_collection'] });
const redactedError = {
message: 'An unexpected error occurred.',
locations: undefined,

View File

@@ -358,6 +358,7 @@ export class ExportService {
if (format === 'csv') {
if (input.length === 0) return '';
const parser = new CSVParser({
transforms: [CSVTransforms.flatten({ separator: '.' })],
header: options?.includeHeader !== false,

View File

@@ -12,10 +12,12 @@ import { systemSchema, userSchema } from '../__utils__/schemas.js';
vi.mock('../env', async () => {
const actual = (await vi.importActual('../env')) as { default: Record<string, any> };
const MOCK_ENV = {
...actual.default,
CACHE_AUTO_PURGE: true,
};
return {
default: MOCK_ENV,
getEnv: () => MOCK_ENV,
@@ -80,6 +82,7 @@ describe('Integration Tests', () => {
expect(tracker.history.insert.length).toBe(1);
expect(tracker.history.insert[0]!.bindings).toStrictEqual([item.id, item.name]);
expect(tracker.history.insert[0]!.sql).toBe(
`insert into "${table}" (${sqlFieldList(schemas[schema].schema, table)}) values (?, ?)`
);
@@ -122,10 +125,12 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'name'] });
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select ${sqlFieldFormatter(
schemas[schema].schema,
@@ -163,10 +168,12 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id);
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -185,6 +192,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id', 'name'],
filter: { name: { _eq: 'something' } },
@@ -192,6 +200,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id", "${table}"."name" from "${table}" where "${table}"."name" = ? and "${table}"."id" = ? order by "${table}"."id" asc limit ?`
);
@@ -236,6 +245,7 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id', 'name'],
filter: { name: { _eq: 'something' } },
@@ -243,6 +253,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id", "${table}"."name" from "${table}" where ("${table}"."name" = ? and "${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -288,17 +299,20 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id'],
filter: { uploaded_by: { _in: ['b5a7dd0f-fc9f-4242-b331-83990990198f'] } },
});
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([
'b5a7dd0f-fc9f-4242-b331-83990990198f',
rawItems[0]!.id,
100,
]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id" from "${table}" where ("${table}"."uploaded_by" in (?) and "${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -364,17 +378,20 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id'],
filter: { uploaded_by: { _in: ['b5a7dd0f-fc9f-4242-b331-83990990198f'] } },
});
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([
'b5a7dd0f-fc9f-4242-b331-83990990198f',
rawItems[0]!.id,
100,
]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id" from "${table}" where ("${table}"."uploaded_by" in (?) and "${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -387,10 +404,12 @@ describe('Integration Tests', () => {
'%s denies one item with filter from tables not as admin and has no field permissions',
async (schema) => {
let table = schemas[schema].tables[1];
const item = {
id: 'd66ec139-2655-48c1-9d9a-4753f98a9ee7',
uploaded_by: '6107c897-9182-40f7-b22e-4f044d1258d2',
};
let itemsService = new ItemsService(table, {
knex: db,
accountability: { role: 'admin', admin: true },
@@ -439,6 +458,7 @@ describe('Integration Tests', () => {
expect(() =>
itemsService.readOne(rawItems[0]!.id, { filter: { name: { _eq: 'something' } } })
).rejects.toThrow("You don't have permission to access this.");
expect(tracker.history.select.length).toBe(0);
}
);
@@ -465,6 +485,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id', 'items.*'],
deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery },
@@ -472,17 +493,21 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(2);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id" from "${table}" where "${table}"."id" = ? order by "${table}"."id" asc limit ?`
);
expect(tracker.history.select[1]!.bindings).toStrictEqual([
childItems[0]!.title,
...rawItems.map((item) => item.id),
25000,
]);
expect(tracker.history.select[1]!.sql).toBe(
`select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where "${childTable}"."title" = ? and "${childTable}"."uploaded_by" in (?, ?) order by "${childTable}"."id" asc limit ?`
);
expect(response).toStrictEqual({ id: rawItems[0]!.id, items: childItems });
});
@@ -555,6 +580,7 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['id', 'items.*'],
deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery },
@@ -562,17 +588,21 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(2);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
expect(tracker.history.select[1]!.bindings).toStrictEqual([
childItems[0]!.title,
...rawItems.map((item) => item.id),
25000,
]);
expect(tracker.history.select[1]!.sql).toBe(
`select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where ("${childTable}"."title" = ?) and "${childTable}"."uploaded_by" in (?, ?) order by "${childTable}"."id" asc limit ?`
);
expect(response).toStrictEqual({ id: rawItems[0]!.id, items: childItems });
}
);
@@ -653,6 +683,7 @@ describe('Integration Tests', () => {
deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery },
})
).rejects.toThrow("You don't have permission to access this.");
expect(tracker.history.select.length).toBe(0);
}
);
@@ -677,6 +708,7 @@ describe('Integration Tests', () => {
expect(() => itemsService.readOne(rawItems[0]!.id)).rejects.toThrow(
"You don't have permission to access this."
);
expect(tracker.history.select.length).toBe(0);
}
);
@@ -706,9 +738,11 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
expect(() => itemsService.readOne(rawItems[0]!.id)).rejects.toThrow(
"You don't have permission to access this."
);
expect(tracker.history.select.length).toBe(0);
}
);
@@ -757,12 +791,14 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['count(items)'],
});
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select (select count(*) from "${childTable}" where "uploaded_by" = "${table}"."id") AS "items_count", "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -814,12 +850,14 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.readOne(rawItems[0]!.id, {
fields: ['count(items)'],
});
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select (select count(*) from "${childTable}" where "uploaded_by" = "${table}"."id" and (("${childTable}"."title" like '%child%'))) AS "items_count", "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?`
);
@@ -845,10 +883,12 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readMany([items[0]!.id, items[1]!.id], { fields: ['id', 'name'] });
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([items[0]!.id, items[1]!.id, 2]);
expect(tracker.history.select[0]!.sql).toBe(
`select ${sqlFieldFormatter(
schemas[schema].schema,
@@ -870,6 +910,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readMany([], {
fields: ['id', 'name'],
filter: { id: { _eq: items[1]!.id } },
@@ -877,6 +918,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([0, items[1]!.id, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select ${sqlFieldFormatter(
schemas[schema].schema,
@@ -897,6 +939,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
const response = await itemsService.readMany([], {
fields: ['id', 'name'],
filter: { _or: [{ id: { _eq: items[1]!.id } }, { name: { _eq: items[1]!.name } }] },
@@ -904,6 +947,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([0, items[1]!.id, items[1]!.name, 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select ${sqlFieldFormatter(
schemas[schema].schema,
@@ -929,6 +973,7 @@ describe('Integration Tests', () => {
title: 'A new child item',
uploaded_by: '6107c897-9182-40f7-b22e-4f044d1258d2',
};
tracker.on.select(childTable).response([childItem]);
tracker.on.update(childTable).response(childItem);
@@ -987,6 +1032,7 @@ describe('Integration Tests', () => {
},
schema: schemas[schema].schema,
});
const response = await itemsService.updateOne(
item.id,
{
@@ -997,21 +1043,29 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(4);
expect(tracker.history.select[0]!.bindings).toStrictEqual([item.id, 1]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id", "${table}"."name" from "${table}" where (("${table}"."id" in (?))) order by "${table}"."id" asc limit ?`
);
expect(tracker.history.select[1]!.bindings).toStrictEqual([item.id, 25000]);
expect(tracker.history.select[1]!.sql).toBe(
`select "${childTable}"."uploaded_by", "${childTable}"."id" from "${childTable}" where "${childTable}"."uploaded_by" in (?) order by "${childTable}"."id" asc limit ?`
);
expect(tracker.history.select[2]!.bindings).toStrictEqual([item.id, 1, 100]);
expect(tracker.history.select[2]!.sql).toBe(
`select "${childTable}"."id" from "${childTable}" where ("${childTable}"."uploaded_by" = ? and 1 = ?) order by "${childTable}"."id" asc limit ?`
);
expect(tracker.history.select[3]!.bindings).toStrictEqual([childItem.id, 1]);
expect(tracker.history.select[3]!.sql).toBe(
`select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where (("${childTable}"."id" in (?))) order by "${childTable}"."id" asc limit ?`
);
expect(tracker.history.update[0]!.bindings).toStrictEqual([null, childItem.id]);
expect(tracker.history.update[0]!.sql).toBe(`update "${childTable}" set "uploaded_by" = ? where "id" in (?)`);
@@ -1093,6 +1147,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'name'],
filter: { name: { _eq: 'something' } },
@@ -1100,6 +1155,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id", "${table}"."name" from "${table}" where "${table}"."name" = ? order by "${table}"."id" asc limit ?`
);
@@ -1116,6 +1172,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'title'],
filter: { uploaded_by: { name: { _eq: 'something' } } },
@@ -1123,6 +1180,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]);
expect(tracker.history.select[0]!.sql).toMatch(
new RegExp(
`select "${otherTable}"."id", "${otherTable}"."title" from "${otherTable}" ` +
@@ -1143,6 +1201,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'name'],
filter: { items: { title: { _eq: 'something' } } },
@@ -1150,6 +1209,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]);
expect(tracker.history.select[0]!.sql).toMatch(
new RegExp(
`select "${table}"."id", "${table}"."name" from "${table}" inner join ` +
@@ -1174,6 +1234,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'name'],
sort: ['name'],
@@ -1181,6 +1242,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([100]);
expect(tracker.history.select[0]!.sql).toBe(
`select "${table}"."id", "${table}"."name" from "${table}" order by "${table}"."name" asc limit ?`
);
@@ -1197,6 +1259,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'title'],
sort: ['uploaded_by.name'],
@@ -1204,6 +1267,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([100]);
expect(tracker.history.select[0]!.sql).toMatch(
new RegExp(
`select "${otherTable}"."id", "${otherTable}"."title" from "${otherTable}" ` +
@@ -1223,6 +1287,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: schemas[schema].schema,
});
await itemsService.readByQuery({
fields: ['id', 'name'],
sort: ['items.title'],
@@ -1230,6 +1295,7 @@ describe('Integration Tests', () => {
expect(tracker.history.select.length).toBe(1);
expect(tracker.history.select[0]!.bindings).toStrictEqual([100, 1, 100]);
expect(tracker.history.select[0]!.sql).toMatch(
new RegExp(
`select "${table}"."id", "${table}"."name" from "${table}" ` +
@@ -1378,6 +1444,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
schema: testSchema,
});
const response = await itemsService.readSingleton({ fields: ['*'] });
expect(tracker.history.select.length).toBe(1);

View File

@@ -60,6 +60,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
return {
trackMutations(count: number) {
mutationCount += count;
if (mutationCount > maxCount) {
throw new InvalidPayloadException(`Exceeded max batch mutation limit of ${maxCount}.`);
}
@@ -92,6 +93,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
*/
async createOne(data: Partial<Item>, opts: MutationOptions = {}): Promise<PrimaryKey> {
if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
if (!opts.bypassLimits) {
opts.mutationTracker.trackMutations(1);
}
@@ -101,6 +103,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const primaryKeyField = this.schema.collections[this.collection]!.primary;
const fields = Object.keys(this.schema.collections[this.collection]!.fields);
const aliases = Object.values(this.schema.collections[this.collection]!.fields)
.filter((field) => field.alias === true)
.map((field) => field.field);
@@ -159,6 +162,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
revisions: revisionsM2O,
nestedActionEvents: nestedActionEventsM2O,
} = await payloadService.processM2O(payloadWithPresets, opts);
const {
payload: payloadWithA2O,
revisions: revisionsA2O,
@@ -324,6 +328,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
bypassEmitAction: (params) => nestedActionEvents.push(params),
mutationTracker: opts.mutationTracker,
});
primaryKeys.push(primaryKey);
}
@@ -538,6 +543,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
*/
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts: MutationOptions = {}): Promise<PrimaryKey[]> {
if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
if (!opts.bypassLimits) {
opts.mutationTracker.trackMutations(keys.length);
}
@@ -549,6 +555,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
validateKeys(this.schema, this.collection, primaryKeyField, keys);
const fields = Object.keys(this.schema.collections[this.collection]!.fields);
const aliases = Object.values(this.schema.collections[this.collection]!.fields)
.filter((field) => field.alias === true)
.map((field) => field.field);
@@ -610,6 +617,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
revisions: revisionsM2O,
nestedActionEvents: nestedActionEventsM2O,
} = await payloadService.processM2O(payloadWithPresets, opts);
const {
payload: payloadWithA2O,
revisions: revisionsA2O,
@@ -638,6 +646,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
key,
opts
);
childrenRevisions.push(...revisions);
nestedActionEvents.push(...nestedActionEventsO2M);
}
@@ -835,6 +844,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
*/
async deleteMany(keys: PrimaryKey[], opts: MutationOptions = {}): Promise<PrimaryKey[]> {
if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
if (!opts.bypassLimits) {
opts.mutationTracker.trackMutations(keys.length);
}

View File

@@ -41,6 +41,7 @@ export class NotificationsService extends ItemsService {
const user = await this.usersService.readOne(data.recipient, {
fields: ['id', 'email', 'email_notifications', 'role.app_access'],
});
const manageUserAccountUrl = new Url(env['PUBLIC_URL']).addPath('admin', 'users', user['id']).toString();
const html = data.message ? md(data.message) : '';

View File

@@ -32,6 +32,7 @@ describe('Integration Tests', () => {
knex: db,
schema: { collections: {}, relations: [] },
});
helpers = getHelpers(db);
});
@@ -219,6 +220,7 @@ describe('Integration Tests', () => {
],
'read'
);
expect(result).toMatchObject([
{
[dateFieldId]: '2022-01-10',

View File

@@ -58,6 +58,7 @@ export class PayloadService {
public transformers: Transformers = {
async hash({ action, value }) {
if (!value) return;
if (action === 'create' || action === 'update') {
return await generateHash(String(value));
}

View File

@@ -32,6 +32,7 @@ export class RelationsService {
this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector();
this.schema = options.schema;
this.accountability = options.accountability || null;
this.relationsItemService = new ItemsService('directus_relations', {
knex: this.knex,
schema: this.schema,
@@ -85,6 +86,7 @@ export class RelationsService {
});
if (!permissions || !permissions.fields) throw new ForbiddenException();
if (permissions.fields.includes('*') === false) {
const allowedFields = permissions.fields;
if (allowedFields.includes(field) === false) throw new ForbiddenException();
@@ -112,6 +114,7 @@ export class RelationsService {
const schemaRow = (await this.schemaInspector.foreignKeys(collection)).find(
(foreignKey) => foreignKey.column === field
);
const stitched = this.stitchRelations(metaRow, schemaRow ? [schemaRow] : []);
const results = await this.filterForbidden(stitched);
@@ -189,6 +192,7 @@ export class RelationsService {
this.alterType(table, relation);
const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!);
const builder = table
.foreign(relation.field!, constraintName)
.references(
@@ -558,6 +562,7 @@ export class RelationsService {
*/
private alterType(table: Knex.TableBuilder, relation: Partial<Relation>) {
const m2oFieldDBType = this.schema.collections[relation.collection!]!.fields[relation.field!]!.dbType;
const relatedFieldDBType =
this.schema.collections[relation.related_collection!]!.fields[
this.schema.collections[relation.related_collection!]!.primary

View File

@@ -51,6 +51,7 @@ describe('Integration Tests', () => {
beforeEach(() => {
tracker.on.any('directus_roles').response({});
tracker.on
.select(/"directus_roles"."id" from "directus_roles" order by "directus_roles"."id" asc limit .*/)
.response([]);
@@ -73,6 +74,7 @@ describe('Integration Tests', () => {
knex: db,
schema: testSchema,
});
superUpdateOne = vi.spyOn(ItemsService.prototype, 'updateOne');
});
@@ -89,6 +91,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [userId1, userId2],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
@@ -101,7 +104,9 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [userId1],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
@@ -115,6 +120,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -134,6 +140,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -150,9 +157,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -164,6 +173,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [{ id: userId1 }, { id: userId2 }],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
@@ -176,7 +186,9 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [{ id: userId1 }],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
@@ -190,6 +202,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -209,6 +222,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -225,9 +239,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -243,6 +259,7 @@ describe('Integration Tests', () => {
delete: [],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
@@ -259,6 +276,7 @@ describe('Integration Tests', () => {
delete: [],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -276,10 +294,13 @@ describe('Integration Tests', () => {
delete: [userId2],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
const result = await service.updateOne(adminRoleId, data);
@@ -295,6 +316,7 @@ describe('Integration Tests', () => {
delete: [userId1],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -318,6 +340,7 @@ describe('Integration Tests', () => {
delete: [userId1],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -334,9 +357,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -352,6 +377,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [userId1, userId2],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -371,6 +397,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [userId1, userId2],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -387,9 +414,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -399,10 +428,13 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [userId1],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
const result = await service.updateOne(adminRoleId, data);
@@ -414,6 +446,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -433,6 +466,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -449,9 +483,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -463,6 +499,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [{ id: userId1 }, { id: userId2 }],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -482,6 +519,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [{ id: userId1 }, { id: userId2 }],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -498,9 +536,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -510,10 +550,13 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [{ id: userId1 }],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
const result = await service.updateOne(adminRoleId, data);
@@ -525,6 +568,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -544,6 +588,7 @@ describe('Integration Tests', () => {
const data: Record<string, any> = {
users: [],
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -560,9 +605,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -578,6 +625,7 @@ describe('Integration Tests', () => {
delete: [],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -595,6 +643,7 @@ describe('Integration Tests', () => {
delete: [],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -618,6 +667,7 @@ describe('Integration Tests', () => {
delete: [],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -634,9 +684,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -650,10 +702,13 @@ describe('Integration Tests', () => {
delete: [userId2],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on
.select('select "id" from "directus_users" where "role" = ?')
.responseOnce([{ id: userId1 }, { id: userId2 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
const result = await service.updateOne(adminRoleId, data);
@@ -669,6 +724,7 @@ describe('Integration Tests', () => {
delete: [userId1],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 });
@@ -692,6 +748,7 @@ describe('Integration Tests', () => {
delete: [userId1],
},
};
tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access });
tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]);
tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 });
@@ -708,9 +765,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateOne).toHaveBeenCalled();
expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe(
`You can't remove the last admin user from the admin role.`
);
expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf(
UnprocessableEntityException
);
@@ -769,6 +828,7 @@ describe('Integration Tests', () => {
checkForOtherAdminRolesSpy = vi
.spyOn(RolesService.prototype as any, 'checkForOtherAdminRoles')
.mockResolvedValueOnce(true);
checkForOtherAdminUsersSpy = vi
.spyOn(RolesService.prototype as any, 'checkForOtherAdminUsers')
.mockResolvedValueOnce(true);
@@ -829,6 +889,7 @@ describe('Integration Tests', () => {
await service.updateBatch([{ id: 1 }]);
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles once', async () => {
await service.updateBatch([{ id: 1, admin_access: false }]);
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);

View File

@@ -41,6 +41,7 @@ export class RolesService extends ItemsService {
const usersThatWereInRoleBefore = (await this.knex.select('id').from('directus_users').where('role', '=', key)).map(
(user) => user.id
);
const usersThatAreRemoved = usersThatWereInRoleBefore.filter((id) =>
Array.isArray(users) ? userKeys.includes(id) === false : users.delete.includes(id) === true
);

View File

@@ -148,6 +148,7 @@ export class ServerService {
logger.warn(
`${service} in WARN state, the observed value ${healthCheck.observedValue} is above the threshold of ${healthCheck.threshold}${healthCheck.observedUnit}`
);
data.status = 'warn';
continue;
}
@@ -358,6 +359,7 @@ export class ServerService {
for (const location of toArray(env['STORAGE_LOCATIONS'])) {
const disk = storage.location(location);
const envThresholdKey = `STORAGE_${location}_HEALTHCHECK_THRESHOLD`.toUpperCase();
checks[`storage:${location}:responseTime`] = [
{
status: 'ok',
@@ -373,6 +375,7 @@ export class ServerService {
try {
await disk.write(`health-${checkID}`, Readable.from(['check']));
const fileStream = await disk.read(`health-${checkID}`);
fileStream.on('data', async () => {
fileStream.destroy();
await disk.delete(`health-${checkID}`);

View File

@@ -150,9 +150,11 @@ describe('Integration Tests', () => {
},
},
]);
vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]);
const spec = await service.oas.generate();
expect(spec.components?.schemas).toMatchInlineSnapshot(`
{
"Diff": {
@@ -421,6 +423,7 @@ describe('Integration Tests', () => {
},
},
]);
vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]);
const spec = await service.oas.generate();

View File

@@ -439,6 +439,7 @@ class OASSpecsService implements SpecificationSubService {
if (relationType === 'm2o') {
const relatedTag = tags.find((tag) => tag['x-collection'] === relation.related_collection);
const relatedPrimaryKeyField = fields.find(
(field) => field.collection === relation.related_collection && field.schema?.is_primary_key
);
@@ -455,6 +456,7 @@ class OASSpecsService implements SpecificationSubService {
];
} else if (relationType === 'o2m') {
const relatedTag = tags.find((tag) => tag['x-collection'] === relation.collection);
const relatedPrimaryKeyField = fields.find(
(field) => field.collection === relation.collection && field.schema?.is_primary_key
);
@@ -462,6 +464,7 @@ class OASSpecsService implements SpecificationSubService {
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
propertyObject.type = 'array';
propertyObject.items = {
oneOf: [
{
@@ -476,6 +479,7 @@ class OASSpecsService implements SpecificationSubService {
const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection']));
propertyObject.type = 'array';
propertyObject.items = {
oneOf: [
{

View File

@@ -122,12 +122,15 @@ describe('Integration Tests', () => {
checkUniqueEmailsSpy = vi
.spyOn(UsersService.prototype as any, 'checkUniqueEmails')
.mockImplementation(() => vi.fn());
checkPasswordPolicySpy = vi
.spyOn(UsersService.prototype as any, 'checkPasswordPolicy')
.mockResolvedValue(() => vi.fn());
checkRemainingAdminExistenceSpy = vi
.spyOn(UsersService.prototype as any, 'checkRemainingAdminExistence')
.mockResolvedValue(() => vi.fn());
checkRemainingActiveAdminSpy = vi
.spyOn(UsersService.prototype as any, 'checkRemainingActiveAdmin')
.mockResolvedValue(() => vi.fn());
@@ -255,9 +258,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateManySpy).toHaveBeenCalled();
expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe(
`You can't change the "${field}" value manually.`
);
expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException);
}
);
@@ -370,9 +375,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateManySpy).toHaveBeenCalled();
expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe(
`You can't change the "${field}" value manually.`
);
expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException);
}
);
@@ -505,9 +512,11 @@ describe('Integration Tests', () => {
}
expect(superUpdateManySpy).toHaveBeenCalled();
expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe(
`You can't change the "${field}" value manually.`
);
expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException);
}
);

View File

@@ -345,6 +345,7 @@ export class UsersService extends ItemsService {
}
const emails = toArray(email);
const mailService = new MailService({
schema: this.schema,
accountability: this.accountability,
@@ -428,9 +429,11 @@ export class UsersService extends ItemsService {
const payload = { email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
const token = jwt.sign(payload, env['SECRET'] as string, { expiresIn: '1d', issuer: 'directus' });
const acceptURL = url
? new Url(url).setQuery('token', token).toString()
: new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
const subjectLine = subject ? subject : 'Password Reset Request';
await mailService.send({

View File

@@ -61,6 +61,7 @@ export class UtilsService {
for (const row of rowsWithoutSortValue) {
lastSortValue++;
await this.knex(collection)
.update({ [sortField]: lastSortValue })
.where({ [primaryKeyField]: row[primaryKeyField] });
@@ -93,6 +94,7 @@ export class UtilsService {
.from(collection)
.where({ [primaryKeyField]: to })
.first();
const targetSortValue = targetSortValueResponse[sortField];
const sourceSortValueResponse = await this.knex
@@ -100,6 +102,7 @@ export class UtilsService {
.from(collection)
.where({ [primaryKeyField]: item })
.first();
const sourceSortValue = sourceSortValueResponse[sortField];
// Set the target item to the new sort value

View File

@@ -67,6 +67,7 @@ describe('Integration Tests', () => {
relations: [],
},
});
messengerPublishSpy = vi.spyOn(getMessenger(), 'publish');
});

View File

@@ -10,6 +10,7 @@ vi.mock('../env');
let mockStorage: StorageManager;
let mockDriver: typeof Driver;
let sample: {
name: string;
};

View File

@@ -18,6 +18,7 @@ let sample: {
};
locations: string[];
};
let mockStorage: StorageManager;
beforeEach(() => {
@@ -42,9 +43,11 @@ beforeEach(() => {
} as unknown as StorageManager;
vi.mocked(getConfigFromEnv).mockImplementation((name) => sample.options[name]!);
vi.mocked(getEnv).mockReturnValue({
STORAGE_LOCATIONS: sample.locations.join(', '),
});
vi.mocked(toArray).mockReturnValue(sample.locations);
});
@@ -61,6 +64,7 @@ test('Gets config for each location', async () => {
await registerLocations(mockStorage);
expect(getConfigFromEnv).toHaveBeenCalledTimes(sample.locations.length);
sample.locations.forEach((location) =>
expect(getConfigFromEnv).toHaveBeenCalledWith(`STORAGE_${location.toUpperCase()}_`)
);
@@ -70,6 +74,7 @@ test('Registers location with driver options for each location', async () => {
await registerLocations(mockStorage);
expect(mockStorage.registerLocation).toHaveBeenCalledTimes(sample.locations.length);
sample.locations.forEach((location) => {
const { driver, ...options } = sample.options[`STORAGE_${location.toUpperCase()}_`]!;

View File

@@ -37,6 +37,7 @@ export async function applyDiff(
const schema = options?.schema ?? (await getSchema({ database, bypassCache: true }));
const nestedActionEvents: ActionEventParams[] = [];
const mutationOptions: MutationOptions = {
autoPurgeSystemCache: false,
bypassEmitAction: (params) => nestedActionEvents.push(params),
@@ -119,6 +120,7 @@ export async function applyDiff(
logger.error(
`Failed to delete collection "${collection}" due to relation "${relation.collection}.${relation.field}"`
);
throw err;
}
}
@@ -153,6 +155,7 @@ export async function applyDiff(
// Check if parent collection already exists in schema
const parentExists = currentSnapshot.collections.find((c) => c.collection === groupName) !== undefined;
// If this is a new collection and the parent collection doesn't exist in current schema ->
// Check if the parent collection will be created as part of applying this snapshot ->
// If yes -> this collection will be created recursively
@@ -165,6 +168,7 @@ export async function applyDiff(
snapshotDiff.collections.filter(
({ collection, diff }) => diff[0]?.kind === DiffKind.NEW && collection === groupName
).length > 0;
// Has group, but parent is not new, parent is also not being created in this snapshot apply
if (parentExists && !parentWillBeCreatedInThisApply) return true;
@@ -230,6 +234,7 @@ export async function applyDiff(
deepDiff.applyChange(acc, undefined, currentDiff);
return acc;
}, cloneDeep(currentField));
await fieldsService.updateField(collection, newValues, mutationOptions);
} catch (err) {
logger.error(`Failed to update field "${collection}.${field}"`);
@@ -286,6 +291,7 @@ export async function applyDiff(
deepDiff.applyChange(acc, undefined, currentDiff);
return acc;
}, cloneDeep(currentRelation));
await relationsService.updateOne(collection, field, newValues, mutationOptions);
} catch (err) {
logger.error(`Failed to update relation "${collection}.${field}"`);

View File

@@ -165,6 +165,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
`${aliasedParentCollection}.${relation.field}`,
`${alias}.${schema.collections[relation.related_collection!]!.primary}`
);
aliasMap[aliasKey]!.collection = relation.related_collection!;
} else if (relationType === 'a2o') {
const pathScope = pathParts[0]!.split(':')[1];
@@ -187,6 +188,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
)
);
});
aliasMap[aliasKey]!.collection = pathScope;
} else if (relationType === 'o2a') {
rootQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => {
@@ -201,6 +203,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
)
);
});
aliasMap[aliasKey]!.collection = relation.collection;
hasMultiRelational = true;
@@ -210,6 +213,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
`${aliasedParentCollection}.${schema.collections[relation.related_collection!]!.primary}`,
`${alias}.${relation.field}`
);
aliasMap[aliasKey]!.collection = relation.collection;
hasMultiRelational = true;
@@ -295,6 +299,7 @@ export function applySort(
relations,
schema,
});
const [alias, field] = columnPath.split('.');
if (!hasMultiRelationalSort) {

View File

@@ -50,6 +50,7 @@ describe('filter items', () => {
{},
],
});
expect(result).toStrictEqual(items.filter((item) => item.action === 'read'));
});
@@ -59,6 +60,7 @@ describe('filter items', () => {
_eq: 'read',
},
});
expect(result).toStrictEqual(items.filter((item) => item.action === 'read'));
});
});

View File

@@ -3,8 +3,10 @@ import { getConfigFromEnv } from './get-config-from-env.js';
export function generateHash(stringToHash: string): Promise<string> {
const argon2HashConfigOptions = getConfigFromEnv('HASH_', 'HASH_RAW'); // Disallow the HASH_RAW option, see https://github.com/directus/directus/discussions/7670#discussioncomment-1255805
// associatedData, if specified, must be passed as a Buffer to argon2.hash, see https://github.com/ranisalt/node-argon2/wiki/Options#associateddata
'associatedData' in argon2HashConfigOptions &&
(argon2HashConfigOptions['associatedData'] = Buffer.from(argon2HashConfigOptions['associatedData']));
return argon2.hash(stringToHash, argon2HashConfigOptions);
}

View File

@@ -183,6 +183,7 @@ export default async function getASTFromQuery(
query: {},
relatedCollection: foundRelation.collection,
});
continue;
}
}

View File

@@ -204,8 +204,10 @@ describe('get cache headers', () => {
return matchingKey ? (scenario.input.headers as any)?.[matchingKey] : undefined;
}),
} as Partial<Request>;
factoryEnv = scenario.input.env;
const { ttl, globalCacheSettings, personalized } = scenario.input;
expect(getCacheControlHeader(mockRequest as Request, ttl, globalCacheSettings, personalized)).toEqual(
scenario.output
);

View File

@@ -8,6 +8,7 @@ vi.mock('../../src/env', () => {
CAMELCASE_OBJECT__FIRST_KEY: 'firstValue',
CAMELCASE_OBJECT__SECOND_KEY: 'secondValue',
};
return {
default: MOCK_ENV,
getEnv: () => MOCK_ENV,

View File

@@ -30,6 +30,7 @@ export function getConfigFromEnv(
const path = key
.split('__')
.map((key, index) => (index === 0 ? transform(transform(key.slice(prefix.length))) : transform(key)));
set(config, path.join('.'), value);
} else {
config[transform(key.slice(prefix.length))] = value;

View File

@@ -10,6 +10,7 @@ const query = `
}
}
`;
const variables = JSON.stringify({ id: 1 });
const additionalProperty = 'test';

View File

@@ -125,6 +125,7 @@ export default function getLocalType(
if (special.includes('uuid') || special.includes('file')) return 'uuid';
if (special.includes('cast-timestamp')) return 'timestamp';
if (special.includes('cast-datetime')) return 'dateTime';
if (type?.startsWith('geometry')) {
return (special[0] as Type) || 'geometry';
}

View File

@@ -64,6 +64,7 @@ test('Returns the payload of an access token', () => {
const payload = { id: 1, role: 1, app_access: true, admin_access: true };
const token = jwt.sign(payload, secret, options);
const result = verifyAccessJWT(token, secret);
expect(result).toEqual({
id: 1,
role: 1,

View File

@@ -22,6 +22,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, permissions: conditionalFilter },
{ ...permissionTemplate, permissions: conditionalFilter2 }
);
expect(mergedPermission).toStrictEqual({
...permissionTemplate,
permissions: {
@@ -36,6 +37,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, validation: conditionalFilter },
{ ...permissionTemplate, validation: conditionalFilter2 }
);
expect(mergedPermission).toStrictEqual({
...permissionTemplate,
validation: {
@@ -50,6 +52,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, permissions: conditionalFilter },
{ ...permissionTemplate, permissions: conditionalFilter2 }
);
expect(mergedPermission).toStrictEqual({
...permissionTemplate,
permissions: {
@@ -64,6 +67,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, validation: conditionalFilter },
{ ...permissionTemplate, validation: conditionalFilter2 }
);
expect(mergedPermission).toStrictEqual({
...permissionTemplate,
validation: {
@@ -78,6 +82,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, permissions: fullFilter },
{ ...permissionTemplate, permissions: conditionalFilter }
);
expect(mergedPermission).toStrictEqual({ ...permissionTemplate, permissions: fullFilter });
});
@@ -87,6 +92,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, validation: fullFilter },
{ ...permissionTemplate, validation: conditionalFilter }
);
expect(mergedPermission).toStrictEqual({ ...permissionTemplate, validation: fullFilter });
});
@@ -96,6 +102,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, permissions: fullFilter },
{ ...permissionTemplate, permissions: conditionalFilter }
);
const expectedPermission = {
...permissionTemplate,
permissions: {
@@ -112,6 +119,7 @@ describe('merging permissions', () => {
{ ...permissionTemplate, validation: fullFilter },
{ ...permissionTemplate, validation: conditionalFilter }
);
const expectedPermission = {
...permissionTemplate,
validation: {

View File

@@ -27,6 +27,7 @@ export function sanitizeField(field: Field | undefined, sanitizeAllSchema = fals
if (!field) return field;
const defaultPaths = ['collection', 'field', 'type', 'meta', 'name', 'children'];
const pickedPaths = sanitizeAllSchema
? defaultPaths
: [

View File

@@ -19,6 +19,7 @@ export class Url {
!isProtocolRelative && !isRootRelative && !isPathRelative
? parsedUrl.protocol.substring(0, parsedUrl.protocol.length - 1)
: null;
this.host = !isRootRelative && !isPathRelative ? parsedUrl.hostname : null;
this.port = parsedUrl.port !== '' ? parsedUrl.port : null;
this.path = parsedUrl.pathname.split('/').filter((p) => p !== '');

View File

@@ -22,6 +22,7 @@ test('should fail on invalid hash', () => {
hash: 'abc',
diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] },
} as SnapshotDiffWithHash;
const snapshot = { hash: 'xyz' } as SnapshotWithHash;
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
@@ -56,6 +57,7 @@ describe('should throw accurate error', () => {
const diff = baseDiff({
collections: [{ collection: 'test', diff: [{ kind: 'N', rhs: {} as Collection }] }],
});
const snapshot = baseSnapshot({ collections: [{ collection: 'test' } as Collection] });
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
@@ -77,6 +79,7 @@ describe('should throw accurate error', () => {
const diff = baseDiff({
fields: [{ collection: 'test', field: 'test', diff: [{ kind: 'N', rhs: {} as SnapshotField }] }],
});
const snapshot = baseSnapshot({ fields: [{ collection: 'test', field: 'test' } as SnapshotField] });
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
@@ -105,6 +108,7 @@ describe('should throw accurate error', () => {
},
],
});
const snapshot = baseSnapshot({
relations: [{ collection: 'test', field: 'test', related_collection: 'relation' } as SnapshotRelation],
});
@@ -248,6 +252,7 @@ test('should not throw error for diffs with varying types of lhs/rhs', () => {
],
},
};
const snapshot = { hash: 'abc' } as SnapshotWithHash;
expect(() => validateApplyDiff(diff, snapshot)).not.toThrow();
@@ -289,6 +294,7 @@ test('should not throw error for relation diff with null related_collection (app
],
},
};
const snapshot = { hash: 'abc' } as SnapshotWithHash;
expect(() => validateApplyDiff(diff, snapshot)).not.toThrow();
@@ -299,6 +305,7 @@ test('should detect empty diff', () => {
hash: 'abc',
diff: { collections: [], fields: [], relations: [] },
};
const snapshot = {} as SnapshotWithHash;
expect(validateApplyDiff(diff, snapshot)).toBe(false);
@@ -309,6 +316,7 @@ test('should pass on valid diff', () => {
hash: 'abc',
diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] },
};
const snapshot = { hash: 'abc' } as SnapshotWithHash;
expect(validateApplyDiff(diff, snapshot)).toBe(true);

View File

@@ -8,6 +8,7 @@ vi.mock('../env', () => ({
PRESENT_TEST_VARIABLE: true,
}),
}));
vi.mock('../logger', () => ({
default: {
error: vi.fn(),

View File

@@ -92,6 +92,7 @@ describe('validate keys', () => {
expect(() =>
validateKeys(schema, 'pk_uuid', 'id', [uuid(), 'fakeuuid-62d9-434d-a7c7-878c8376782e', uuid()])
).toThrowError();
expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), 'invalid', uuid()])).toThrowError();
expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), NaN, uuid()])).toThrowError();
expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), 111, uuid()])).toThrowError();

View File

@@ -3,10 +3,12 @@ import { validateQuery } from './validate-query.js';
vi.mock('../env', async () => {
const actual = (await vi.importActual('../env')) as { default: Record<string, any> };
const MOCK_ENV = {
...actual.default,
MAX_QUERY_LIMIT: 100,
};
return {
default: MOCK_ENV,
getEnv: () => MOCK_ENV,

View File

@@ -134,6 +134,7 @@ function validateList(value: any, key: string) {
function validateBoolean(value: any, key: string) {
if (value === null) return true;
if (typeof value !== 'boolean') {
throw new InvalidQueryException(`"${key}" has to be a boolean`);
}
@@ -143,6 +144,7 @@ function validateBoolean(value: any, key: string) {
function validateGeometry(value: any, key: string) {
if (value === null) return true;
try {
stringify(value);
} catch {