mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Debug public API tests
This commit is contained in:
parent
b3a5722841
commit
0844b911d9
14599
package-lock.json
generated
14599
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -4,7 +4,7 @@ module.exports = {
|
||||||
'^.+\\.ts?$': 'ts-jest',
|
'^.+\\.ts?$': 'ts-jest',
|
||||||
},
|
},
|
||||||
testURL: 'http://localhost/',
|
testURL: 'http://localhost/',
|
||||||
testRegex: '(/__tests__/.*|(\\.|/)(test-api))\\.ts$',
|
testRegex: '(/__tests__/.*|(\\.|/)(test))\\.ts$',
|
||||||
testPathIgnorePatterns: ['/dist/', '/node_modules/'],
|
testPathIgnorePatterns: ['/dist/', '/node_modules/'],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
globals: {
|
globals: {
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
"start:default": "cd bin && ./n8n",
|
"start:default": "cd bin && ./n8n",
|
||||||
"start:windows": "cd bin && n8n",
|
"start:windows": "cd bin && n8n",
|
||||||
"test": "npm run test:sqlite",
|
"test": "npm run test:sqlite",
|
||||||
"test:sqlite": "export N8N_LOG_LEVEL='debug'; export DB_TYPE=sqlite; jest --detectOpenHandles",
|
"test:sqlite": "export N8N_LOG_LEVEL='debug'; export DB_TYPE=sqlite; jest",
|
||||||
"test:postgres": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=postgresdb && jest",
|
"test:postgres": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=postgresdb && jest",
|
||||||
"test:mysql": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=mysqldb && jest",
|
"test:mysql": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=mysqldb && jest",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
|
|
|
@ -18,7 +18,7 @@ const instanceOwnerSetup = (
|
||||||
next: express.NextFunction,
|
next: express.NextFunction,
|
||||||
): any => {
|
): any => {
|
||||||
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||||
return res.status(404).json({ message: 'asasas' });
|
return res.status(500).json({ message: 'Instance owner is not set up' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
@ -29,7 +29,7 @@ const emailSetup = (
|
||||||
next: express.NextFunction,
|
next: express.NextFunction,
|
||||||
): any => {
|
): any => {
|
||||||
if (!config.getEnv('userManagement.emails.mode')) {
|
if (!config.getEnv('userManagement.emails.mode')) {
|
||||||
return res.status(500).json({ message: 'asasas' });
|
return res.status(500).json({ message: 'Email is not set up' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
@ -44,7 +44,7 @@ const authorize =
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: 'asasas',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -132,7 +132,7 @@ export = {
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new ResponseHelper.ResponseError(
|
throw new ResponseHelper.ResponseError(
|
||||||
`Could not find user with identifier: ${identifier as string}`,
|
`Could not find user with identifier: ${identifier}`,
|
||||||
undefined,
|
undefined,
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
|
|
|
@ -377,6 +377,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
*/
|
*/
|
||||||
this.app.delete(
|
this.app.delete(
|
||||||
`/${this.restEndpoint}/users/:id`,
|
`/${this.restEndpoint}/users/:id`,
|
||||||
|
// @ts-ignore
|
||||||
ResponseHelper.send(async (req: UserRequest.Delete) => {
|
ResponseHelper.send(async (req: UserRequest.Delete) => {
|
||||||
const { id: idToDelete } = req.params;
|
const { id: idToDelete } = req.params;
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { compare } from 'bcryptjs';
|
||||||
|
|
||||||
import { Db } from '../../../src';
|
import { Db } from '../../../src';
|
||||||
import config = require('../../../config');
|
import config = require('../../../config');
|
||||||
import { SUCCESS_RESPONSE_BODY } from './../shared/constants';
|
import { SUCCESS_RESPONSE_BODY } from '../shared/constants';
|
||||||
import { Role } from '../../../src/databases/entities/Role';
|
import { Role } from '../../../src/databases/entities/Role';
|
||||||
import {
|
import {
|
||||||
randomApiKey,
|
randomApiKey,
|
||||||
|
@ -13,10 +13,10 @@ import {
|
||||||
randomInvalidPassword,
|
randomInvalidPassword,
|
||||||
randomName,
|
randomName,
|
||||||
randomValidPassword,
|
randomValidPassword,
|
||||||
} from './../shared/random';
|
} from '../shared/random';
|
||||||
|
|
||||||
import * as utils from './../shared/utils';
|
import * as utils from '../shared/utils';
|
||||||
import * as testDb from './../shared/testDb';
|
import * as testDb from '../shared/testDb';
|
||||||
|
|
||||||
// import * from './../../../src/PublicApi/helpers'
|
// import * from './../../../src/PublicApi/helpers'
|
||||||
|
|
||||||
|
@ -27,6 +27,8 @@ let globalMemberRole: Role;
|
||||||
let workflowOwnerRole: Role;
|
let workflowOwnerRole: Role;
|
||||||
let credentialOwnerRole: Role;
|
let credentialOwnerRole: Role;
|
||||||
|
|
||||||
|
jest.mock('../../../src/telemetry');
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
|
app = utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
|
@ -53,13 +55,13 @@ beforeEach(async () => {
|
||||||
await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
|
await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
|
||||||
await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName);
|
await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName);
|
||||||
|
|
||||||
jest.isolateModules(() => {
|
// jest.isolateModules(() => {
|
||||||
jest.mock('../../../config');
|
// jest.mock('../../../config');
|
||||||
jest.mock('./../../../src/PublicApi/helpers', () => ({
|
// jest.mock('./../../../src/PublicApi/helpers', () => ({
|
||||||
...jest.requireActual('./../../../src/PublicApi/helpers'),
|
// ...jest.requireActual('./../../../src/PublicApi/helpers'),
|
||||||
connectionName: jest.fn(() => testDbName),
|
// connectionName: jest.fn(() => testDbName),
|
||||||
}));
|
// }));
|
||||||
});
|
// });
|
||||||
|
|
||||||
await testDb.createUser({
|
await testDb.createUser({
|
||||||
id: INITIAL_TEST_USER.id,
|
id: INITIAL_TEST_USER.id,
|
||||||
|
@ -90,7 +92,6 @@ test('GET /users should fail due to missing API Key', async () => {
|
||||||
const response = await authOwnerAgent.get('/v1/users');
|
const response = await authOwnerAgent.get('/v1/users');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(401);
|
expect(response.statusCode).toBe(401);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users should fail due to invalid API Key', async () => {
|
test('GET /users should fail due to invalid API Key', async () => {
|
||||||
|
@ -118,21 +119,27 @@ test('GET /users should fail due to member trying to access owner only endpoint'
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users should fail due no instance owner not setup', async () => {
|
test('GET /users should fail due no instance owner not setup', async () => {
|
||||||
|
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
|
||||||
|
// console.log(authOwnerAgent);
|
||||||
|
|
||||||
const response = await authOwnerAgent.get('/v1/users');
|
const response = await authOwnerAgent.get('/v1/users');
|
||||||
|
// const response2 = await authOwnerAgent.get('/v1/spec');
|
||||||
|
// const response3 = await authOwnerAgent.get('/v1/hello');
|
||||||
|
|
||||||
|
// console.log(response.body);
|
||||||
|
// console.log(response.statusCode);
|
||||||
|
|
||||||
|
// console.log(authOwnerAgent.app);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(500);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users should return all users', async () => {
|
test('GET /users should return all users', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -175,8 +182,6 @@ test('GET /users should return all users', async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test('GET /users/:identifier should fail due to missing API Key', async () => {
|
test('GET /users/:identifier should fail due to missing API Key', async () => {
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
|
@ -187,7 +192,6 @@ test('GET /users/:identifier should fail due to missing API Key', async () => {
|
||||||
const response = await authOwnerAgent.get(`/v1/users/${owner.id}`);
|
const response = await authOwnerAgent.get(`/v1/users/${owner.id}`);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(401);
|
expect(response.statusCode).toBe(401);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:identifier should fail due to invalid API Key', async () => {
|
test('GET /users/:identifier should fail due to invalid API Key', async () => {
|
||||||
|
@ -213,7 +217,6 @@ test('GET /users/:identifier should fail due to member trying to access owner on
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:identifier should fail due no instance owner not setup', async () => {
|
test('GET /users/:identifier should fail due no instance owner not setup', async () => {
|
||||||
|
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
@ -223,11 +226,9 @@ test('GET /users/:identifier should fail due no instance owner not setup', async
|
||||||
const response = await authOwnerAgent.get(`/v1/users/${owner.id}`);
|
const response = await authOwnerAgent.get(`/v1/users/${owner.id}`);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(500);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:email with unexisting email should return 404', async () => {
|
test('GET /users/:email with unexisting email should return 404', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -238,7 +239,6 @@ test('GET /users/:email with unexisting email should return 404', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:id with unexisting id should return 404', async () => {
|
test('GET /users/:id with unexisting id should return 404', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -249,7 +249,6 @@ test('GET /users/:id with unexisting id should return 404', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:email should return a user', async () => {
|
test('GET /users/:email should return a user', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -258,36 +257,35 @@ test('GET /users/:email should return a user', async () => {
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
} = response.body;
|
} = response.body;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBeDefined();
|
expect(email).toBeDefined();
|
||||||
expect(firstName).toBeDefined();
|
expect(firstName).toBeDefined();
|
||||||
expect(lastName).toBeDefined();
|
expect(lastName).toBeDefined();
|
||||||
expect(personalizationAnswers).toBeUndefined();
|
expect(personalizationAnswers).toBeUndefined();
|
||||||
expect(password).toBeUndefined();
|
expect(password).toBeUndefined();
|
||||||
expect(resetPasswordToken).toBeUndefined();
|
expect(resetPasswordToken).toBeUndefined();
|
||||||
//virtual method not working
|
//virtual method not working
|
||||||
//expect(isPending).toBe(false);
|
//expect(isPending).toBe(false);
|
||||||
expect(globalRole).toBeUndefined();
|
expect(globalRole).toBeUndefined();
|
||||||
expect(createdAt).toBeDefined();
|
expect(createdAt).toBeDefined();
|
||||||
expect(updatedAt).toBeDefined();
|
expect(updatedAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /users/:id should return a user', async () => {
|
test('GET /users/:id should return a user', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -334,7 +332,6 @@ test('POST /users should fail due to missing API Key', async () => {
|
||||||
const response = await authOwnerAgent.post('/v1/users');
|
const response = await authOwnerAgent.post('/v1/users');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(401);
|
expect(response.statusCode).toBe(401);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail due to invalid API Key', async () => {
|
test('POST /users should fail due to invalid API Key', async () => {
|
||||||
|
@ -350,7 +347,6 @@ test('POST /users should fail due to invalid API Key', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail due to member trying to access owner only endpoint', async () => {
|
test('POST /users should fail due to member trying to access owner only endpoint', async () => {
|
||||||
|
|
||||||
const member = await testDb.createUser();
|
const member = await testDb.createUser();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: member });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: member });
|
||||||
|
@ -361,7 +357,6 @@ test('POST /users should fail due to member trying to access owner only endpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail due instance owner not setup', async () => {
|
test('POST /users should fail due instance owner not setup', async () => {
|
||||||
|
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
@ -371,11 +366,9 @@ test('POST /users should fail due instance owner not setup', async () => {
|
||||||
const response = await authOwnerAgent.post('/v1/users').send([]);
|
const response = await authOwnerAgent.post('/v1/users').send([]);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(500);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail due smtp email not setup', async () => {
|
test('POST /users should fail due smtp email not setup', async () => {
|
||||||
|
|
||||||
config.set('userManagement.emails.mode', '');
|
config.set('userManagement.emails.mode', '');
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
@ -385,11 +378,9 @@ test('POST /users should fail due smtp email not setup', async () => {
|
||||||
const response = await authOwnerAgent.post('/v1/users').send([]);
|
const response = await authOwnerAgent.post('/v1/users').send([]);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(500);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail due not valid body structure', async () => {
|
test('POST /users should fail due not valid body structure', async () => {
|
||||||
|
|
||||||
const owner = await Db.collections.User!.findOneOrFail();
|
const owner = await Db.collections.User!.findOneOrFail();
|
||||||
|
|
||||||
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner });
|
||||||
|
@ -397,7 +388,6 @@ test('POST /users should fail due not valid body structure', async () => {
|
||||||
const response = await authOwnerAgent.post('/v1/users').send({});
|
const response = await authOwnerAgent.post('/v1/users').send({});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const INITIAL_TEST_USER = {
|
const INITIAL_TEST_USER = {
|
|
@ -113,7 +113,9 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
|
||||||
const functionEndpoints: string[] = [];
|
const functionEndpoints: string[] = [];
|
||||||
|
|
||||||
endpointGroups.forEach((group) =>
|
endpointGroups.forEach((group) =>
|
||||||
(group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(group),
|
(group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(
|
||||||
|
group,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [routerEndpoints, functionEndpoints];
|
return [routerEndpoints, functionEndpoints];
|
||||||
|
@ -149,12 +151,13 @@ export function initConfigFile() {
|
||||||
/**
|
/**
|
||||||
* Create a request agent, optionally with an auth cookie.
|
* Create a request agent, optionally with an auth cookie.
|
||||||
*/
|
*/
|
||||||
export function createAgent(app: express.Application, options?: { apiPath?: ApiPath, auth: boolean; user: User }) {
|
export function createAgent(
|
||||||
|
app: express.Application,
|
||||||
|
options?: { apiPath?: ApiPath; auth: boolean; user: User },
|
||||||
|
) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
|
|
||||||
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
|
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
|
||||||
agent.use(prefix(REST_PATH_SEGMENT));
|
|
||||||
|
|
||||||
if (options?.auth && options?.user) {
|
if (options?.auth && options?.user) {
|
||||||
const { token } = issueJWT(options.user);
|
const { token } = issueJWT(options.user);
|
||||||
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
||||||
|
@ -163,6 +166,7 @@ export function createAgent(app: express.Application, options?: { apiPath?: ApiP
|
||||||
|
|
||||||
if (options?.apiPath === 'public') {
|
if (options?.apiPath === 'public') {
|
||||||
agent.use(prefix(PUBLIC_API_REST_PATH_SEGMENT));
|
agent.use(prefix(PUBLIC_API_REST_PATH_SEGMENT));
|
||||||
|
// console.log(agent.app._events.request._router.stack.slice(-1)[0].handle);
|
||||||
|
|
||||||
if (options?.auth && options?.user.apiKey) {
|
if (options?.auth && options?.user.apiKey) {
|
||||||
agent.set({ 'X-N8N-API-KEY': options.user.apiKey });
|
agent.set({ 'X-N8N-API-KEY': options.user.apiKey });
|
||||||
|
|
Loading…
Reference in a new issue