0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

test: interaction api counter cases and response status guard (#4306)

* test: add interaction api counter cases and response status guard

* fix(core): fix koa router method type definition
This commit is contained in:
Xiao Yijun 2023-08-14 10:38:05 +08:00 committed by GitHub
parent 0fcea5ae5e
commit 3c903b4778
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 730 additions and 20 deletions

View file

@ -274,11 +274,21 @@ declare module 'koa-router' {
middleware2: Koa.Middleware<StateT & T1, CustomT & U1>,
routeHandler: Router.IMiddleware<StateT & T1 & T2, CustomT & U1 & U2>
): Router<StateT & T1 & T2, CustomT & U1 & U2>;
/**
* Note: When the middleware type has more than 3 generic types, TypeScript infers it as `unknown`.
* Here, we ensure that the input types of these 3 middleware don't depend on preceding types,
* only on the router's provided types.
*
* P.S. This type might not handle cases where later middleware depends on preceding middleware.
* While imperfect, this definition works for most cases.
* When there is a genuine need for dependencies between middlewares,
* consider taking inspiration from `interactionRoutes` to define types for the Router.
*/
post<T1, U1, T2, U2, T3, U3>(
path: string | RegExp | Array<string | RegExp>,
middleware1: Koa.Middleware<T1, U1>,
middleware2: Koa.Middleware<StateT & T1, CustomT & U1>,
middleware3: Koa.Middleware<StateT & T1 & T2, CustomT & U1 & U2>,
middleware1: Koa.Middleware<StateT & T1, CustomT & U1>,
middleware2: Koa.Middleware<StateT & T2, CustomT & U2>,
middleware3: Koa.Middleware<StateT & T3, CustomT & U3>,
routeHandler: Router.IMiddleware<StateT & T1 & T2 & T3, CustomT & U1 & U2 & U3>
): Router<StateT & T1 & T2 & T3, CustomT & U1 & U2 & U3>;

View file

@ -68,6 +68,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
identifier: identifierPayloadGuard.optional(),
profile: profileGuard.optional(),
}),
status: [204, 400, 401, 403, 422],
}),
koaInteractionSie(queries),
async (ctx, next) => {
@ -108,17 +109,24 @@ export default function interactionRoutes<T extends AnonymousRouter>(
);
// Delete Interaction
router.delete(interactionPrefix, async (ctx, next) => {
const error: LogtoErrorCode = 'oidc.aborted';
await assignInteractionResults(ctx, provider, { error });
router.delete(
interactionPrefix,
koaGuard({
status: [204, 400],
}),
async (ctx, next) => {
const error: LogtoErrorCode = 'oidc.aborted';
await assignInteractionResults(ctx, provider, { error });
return next();
});
ctx.status = 204;
return next();
}
);
// Update Interaction Event
router.put(
`${interactionPrefix}/event`,
koaGuard({ body: z.object({ event: eventGuard }) }),
koaGuard({ body: z.object({ event: eventGuard }), status: [204, 400, 403, 404] }),
koaInteractionSie(queries),
async (ctx, next) => {
const { event } = ctx.guard.body;
@ -156,6 +164,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
`${interactionPrefix}/identifiers`,
koaGuard({
body: identifierPayloadGuard,
status: [204, 400, 401, 404, 422],
}),
koaInteractionSie(queries),
async (ctx, next) => {
@ -193,6 +202,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
`${interactionPrefix}/profile`,
koaGuard({
body: profileGuard,
status: [204, 400, 404],
}),
koaInteractionSie(queries),
async (ctx, next) => {
@ -230,6 +240,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
`${interactionPrefix}/profile`,
koaGuard({
body: profileGuard,
status: [204, 400, 404],
}),
koaInteractionSie(queries),
async (ctx, next) => {
@ -265,25 +276,39 @@ export default function interactionRoutes<T extends AnonymousRouter>(
);
// Delete Interaction Profile
router.delete(`${interactionPrefix}/profile`, async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
router.delete(
`${interactionPrefix}/profile`,
koaGuard({
status: [204, 400, 404],
}),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
const log = createLog(`Interaction.${interactionStorage.event}.Profile.Delete`);
log.append({ interactionStorage });
const log = createLog(`Interaction.${interactionStorage.event}.Profile.Delete`);
log.append({ interactionStorage });
const { profile, ...rest } = interactionStorage;
const { profile, ...rest } = interactionStorage;
await storeInteractionResult(rest, ctx, provider);
await storeInteractionResult(rest, ctx, provider);
ctx.status = 204;
ctx.status = 204;
return next();
});
return next();
}
);
// Submit Interaction
router.post(
`${interactionPrefix}/submit`,
koaGuard({
status: [200, 204, 400, 401, 404, 422],
response: z
.object({
redirectTo: z.string(),
})
.optional(),
}),
koaInteractionSie(queries),
koaInteractionHooks(libraries),
async (ctx, next) => {
@ -312,7 +337,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
// Create social authorization url interaction verification
router.post(
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
koaGuard({ body: socialAuthorizationUrlPayloadGuard }),
koaGuard({
body: socialAuthorizationUrlPayloadGuard,
status: [200, 400, 404],
response: z.object({
redirectTo: z.string(),
}),
}),
async (ctx, next) => {
// Check interaction exists
const { event } = getInteractionStorage(ctx.interactionDetails.result);
@ -335,6 +366,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
`${interactionPrefix}/${verificationPath}/verification-code`,
koaGuard({
body: requestVerificationCodePayloadGuard,
status: [204, 400, 404],
}),
async (ctx, next) => {
const { interactionDetails, guard, createLog } = ctx;

View file

@ -27,6 +27,14 @@ export const putInteraction = async (cookie: string, payload: InteractionPayload
})
.json();
export const deleteInteraction = async (cookie: string) =>
api
.delete('interaction', {
headers: { cookie },
followRedirect: false,
})
.json();
export const putInteractionEvent = async (cookie: string, payload: { event: InteractionEvent }) =>
api
.put('interaction/event', { headers: { cookie }, json: payload, followRedirect: false })

View file

@ -0,0 +1,135 @@
import { InteractionEvent } from '@logto/schemas';
import {
putInteractionEvent,
patchInteractionIdentifiers,
putInteractionProfile,
patchInteractionProfile,
deleteInteractionProfile,
createSocialAuthorizationUri,
sendVerificationCode,
putInteraction,
deleteInteraction,
} from '#src/api/interaction.js';
import MockClient from '#src/client/index.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateUsername, generatePassword, generateEmail } from '#src/utils.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('Interaction details guard checking', () => {
// Create a client without interaction cookies
const client = new MockClient();
it('PUT /interaction', async () => {
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('DELETE /interaction', async () => {
await expectRejects(client.send(deleteInteraction), {
code: 'session.not_found',
statusCode: 400,
});
});
it('PUT /interaction/event', async () => {
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.SignIn,
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('PATCH /interaction/identifier', async () => {
await expectRejects(
client.send(patchInteractionIdentifiers, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('PUT /interaction/profile', async () => {
await expectRejects(
client.send(putInteractionProfile, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('PATCH /interaction/profile', async () => {
await expectRejects(
client.send(patchInteractionProfile, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('DELETE /interaction/profile', async () => {
await expectRejects(client.send(deleteInteractionProfile), {
code: 'session.not_found',
statusCode: 400,
});
});
it('POST /interaction/submit', async () => {
await expectRejects(client.submitInteraction(), {
code: 'session.not_found',
statusCode: 400,
});
});
it('POST /interaction/verification/social-authorization-uri', async () => {
await expectRejects(
client.send(createSocialAuthorizationUri, {
state: 'fake_state',
redirectUri: 'https://logto.dev',
connectorId: 'fake_connector_id',
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('POST /interaction/verification/verification-code', async () => {
await expectRejects(
client.send(sendVerificationCode, {
email: generateEmail(),
}),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
});

View file

@ -0,0 +1,120 @@
import { InteractionEvent } from '@logto/schemas';
import {
putInteractionEvent,
patchInteractionIdentifiers,
putInteractionProfile,
patchInteractionProfile,
deleteInteractionProfile,
createSocialAuthorizationUri,
sendVerificationCode,
} from '#src/api/interaction.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateUsername, generatePassword, generateEmail } from '#src/utils.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('Interaction details results checking', () => {
it('PUT /interaction/event', async () => {
const client = await initClient();
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.SignIn,
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
it('PATCH /interaction/identifier', async () => {
const client = await initClient();
await expectRejects(
client.send(patchInteractionIdentifiers, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
it('PUT /interaction/profile', async () => {
const client = await initClient();
await expectRejects(
client.send(putInteractionProfile, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
it('PATCH /interaction/profile', async () => {
const client = await initClient();
await expectRejects(
client.send(patchInteractionProfile, {
username: generateUsername(),
password: generatePassword(),
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
it('DELETE /interaction/profile', async () => {
const client = await initClient();
await expectRejects(client.send(deleteInteractionProfile), {
code: 'session.verification_session_not_found',
statusCode: 404,
});
});
it('POST /interaction/submit', async () => {
const client = await initClient();
await expectRejects(client.submitInteraction(), {
code: 'session.verification_session_not_found',
statusCode: 404,
});
});
it('POST /interaction/verification/social-authorization-uri', async () => {
const client = await initClient();
await expectRejects(
client.send(createSocialAuthorizationUri, {
state: 'fake_state',
redirectUri: 'https://logto.dev',
connectorId: 'fake_connector_id',
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
it('POST /interaction/verification/verification-code', async () => {
const client = await initClient();
await expectRejects(
client.send(sendVerificationCode, {
email: generateEmail(),
}),
{
code: 'session.verification_session_not_found',
statusCode: 404,
}
);
});
});

View file

@ -0,0 +1,38 @@
import { InteractionEvent } from '@logto/schemas';
import { suspendUser } from '#src/api/admin-user.js';
import { patchInteractionIdentifiers, putInteraction } from '#src/api/interaction.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('PATCH /interaction/identifiers', () => {
it('Should fail to update identifiers with username and password if related user is suspended', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods();
const { user, userProfile } = await generateNewUser({ username: true, password: true });
await suspendUser(user.id, true);
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await expectRejects(
client.send(patchInteractionIdentifiers, {
username: userProfile.username,
password: userProfile.password,
}),
{
code: 'user.suspended',
statusCode: 401,
}
);
});
});

View file

@ -0,0 +1,48 @@
import { InteractionEvent } from '@logto/schemas';
import { putInteraction, sendVerificationCode } from '#src/api/interaction.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateEmail, generatePhone } from '#src/utils.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('POST /interaction/verification/verification-code', () => {
it('Should fail to send email verification code if related connector is not found', async () => {
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await expectRejects(
client.send(sendVerificationCode, {
email: generateEmail(),
}),
{
code: 'connector.not_found',
statusCode: 400,
}
);
});
it('Should fail to send phone verification code if related connector is not found', async () => {
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await expectRejects(
client.send(sendVerificationCode, {
phone: generatePhone(),
}),
{
code: 'connector.not_found',
statusCode: 400,
}
);
});
});

View file

@ -0,0 +1,118 @@
import { SignInMode, InteractionEvent } from '@logto/schemas';
import { putInteractionEvent, putInteraction } from '#src/api/interaction.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('PUT /interaction/event', () => {
it('Should fail to update interaction event when the related sign-in mode is not enabled', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods();
const client = await initClient();
await updateSignInExperience({
signInMode: SignInMode.Register,
});
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.SignIn,
}),
{
code: 'auth.forbidden',
statusCode: 403,
}
);
await updateSignInExperience({
signInMode: SignInMode.SignIn,
});
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.Register,
}),
{
code: 'auth.forbidden',
statusCode: 403,
}
);
// Reset
await enableAllPasswordSignInMethods();
});
it('Should fail to change interaction event to another event when the initial event is forgot password', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods();
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.ForgotPassword,
});
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.Register,
}),
{
code: 'session.interaction_not_found',
statusCode: 404,
}
);
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.SignIn,
}),
{
code: 'session.interaction_not_found',
statusCode: 404,
}
);
});
it('Should fail to change interaction event to forgot password if the initial event is not forgot password', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods();
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.Register,
});
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.ForgotPassword,
}),
{
code: 'session.interaction_not_found',
statusCode: 404,
}
);
// Change event to sign-in
await client.successSend(putInteractionEvent, {
event: InteractionEvent.SignIn,
});
await expectRejects(
client.send(putInteractionEvent, {
event: InteractionEvent.ForgotPassword,
}),
{
code: 'session.interaction_not_found',
statusCode: 404,
}
);
});
});

View file

@ -0,0 +1,201 @@
import { SignInIdentifier, InteractionEvent, ConnectorType } from '@logto/schemas';
import { putInteraction } from '#src/api/interaction.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { initClient } from '#src/helpers/client.js';
import {
clearConnectorsByTypes,
setEmailConnector,
setSmsConnector,
} from '#src/helpers/connector.js';
import { expectRejects } from '#src/helpers/index.js';
import {
enableAllPasswordSignInMethods,
enableAllVerificationCodeSignInMethods,
} from '#src/helpers/sign-in-experience.js';
import { generateEmail, generatePhone } from '#src/utils.js';
/**
* Note: These test cases are designed to cover exceptional scenarios of API calls that
* cannot be covered within the auth flow.
*/
describe('PUT /interaction', () => {
/**
* Note: only test email & phone identifier here, since other cases are covered in the auth flow.
*/
it('Should fail to create a sign-in interaction with identifiers when related sign-in methods are not enabled', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods();
// Remove email & phone identifier from sign-in methods
await updateSignInExperience({
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
const client = await initClient();
// Email
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
email: generateEmail(),
verificationCode: '123456',
},
}),
{
code: 'user.sign_in_method_not_enabled',
statusCode: 422,
}
);
// Phone
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
phone: generatePhone(),
verificationCode: '123456',
},
}),
{
code: 'user.sign_in_method_not_enabled',
statusCode: 422,
}
);
// Reset
await enableAllPasswordSignInMethods();
});
/**
* Note: only test email & phone identifier here, since other cases are covered in the auth flow.
*/
it('Should fail to create a register interaction with profile when related sign-up identifiers are not enabled', async () => {
// Init a valid sign-in experience config
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
const client = await initClient();
// Email
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
email: generateEmail(),
},
}),
{
code: 'user.sign_in_method_not_enabled',
statusCode: 422,
}
);
// Phone
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
phone: generatePhone(),
},
}),
{
code: 'user.sign_in_method_not_enabled',
statusCode: 422,
}
);
// Reset
await enableAllPasswordSignInMethods();
});
it('Should fail to create an interaction when verification code is provided in the identifier but failed to verified', async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await setEmailConnector();
await setSmsConnector();
await enableAllVerificationCodeSignInMethods();
const client = await initClient();
// Email
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
email: generateEmail(),
verificationCode: '123456',
},
}),
{
code: 'verification_code.not_found',
statusCode: 400,
}
);
// Email
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
phone: generatePhone(),
verificationCode: '123456',
},
}),
{
code: 'verification_code.not_found',
statusCode: 400,
}
);
// Clear
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
});
it('Should fail to create an interaction when connector id and connector data is provided but failed to verified', async () => {
const client = await initClient();
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
connectorId: 'fake_connector_id',
connectorData: {
email: generateEmail(),
},
},
}),
{
code: 'session.invalid_connector_id',
statusCode: 422,
}
);
});
it('Should fail to create an interaction when connector id is provided but failed to verified', async () => {
const client = await initClient();
await expectRejects(
client.send(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
connectorId: 'fake_connector_id',
email: generateEmail(),
},
}),
{
code: 'session.connector_session_not_found',
statusCode: 400,
}
);
});
});