0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

test: add me api tests

This commit is contained in:
Gao Sun 2023-02-13 18:12:11 +08:00
parent 643d418bb1
commit bf8e4c0f6e
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
9 changed files with 198 additions and 29 deletions

View file

@ -1,11 +1,21 @@
import { got } from 'got'; import { got } from 'got';
import { logtoUrl } from '#src/constants.js'; import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
export default got.extend({ prefixUrl: new URL('/api', logtoUrl) }); const api = got.extend({ prefixUrl: new URL('/api', logtoUrl) });
export const authedAdminApi = got.extend({ export default api;
prefixUrl: new URL('/api', logtoUrl),
// TODO: @gao rename
export const authedAdminApi = api.extend({
headers: {
'development-user-id': 'integration-test-admin-user',
},
});
export const adminTenantApi = got.extend({ prefixUrl: new URL('/api', logtoConsoleUrl) });
export const authedAdminTenantApi = adminTenantApi.extend({
headers: { headers: {
'development-user-id': 'integration-test-admin-user', 'development-user-id': 'integration-test-admin-user',
}, },

View file

@ -4,6 +4,7 @@ import type {
Profile, Profile,
RequestVerificationCodePayload, RequestVerificationCodePayload,
} from '@logto/schemas'; } from '@logto/schemas';
import type { Got } from 'got';
import api from './api.js'; import api from './api.js';
@ -11,13 +12,13 @@ export type RedirectResponse = {
redirectTo: string; redirectTo: string;
}; };
export type interactionPayload = { export type InteractionPayload = {
event: InteractionEvent; event: InteractionEvent;
identifier?: IdentifierPayload; identifier?: IdentifierPayload;
profile?: Profile; profile?: Profile;
}; };
export const putInteraction = async (cookie: string, payload: interactionPayload) => export const putInteraction = async (cookie: string, payload: InteractionPayload) =>
api api
.put('interaction', { .put('interaction', {
headers: { cookie }, headers: { cookie },
@ -66,7 +67,7 @@ export const deleteInteractionProfile = async (cookie: string) =>
}) })
.json(); .json();
export const submitInteraction = async (cookie: string) => export const submitInteraction = async (api: Got, cookie: string) =>
api api
.post('interaction/submit', { headers: { cookie }, followRedirect: false }) .post('interaction/submit', { headers: { cookie }, followRedirect: false })
.json<RedirectResponse>(); .json<RedirectResponse>();
@ -97,7 +98,7 @@ export const createSocialAuthorizationUri = async (
followRedirect: false, followRedirect: false,
}); });
export const consent = async (cookie: string) => export const consent = async (api: Got, cookie: string) =>
api api
.post('interaction/consent', { .post('interaction/consent', {
headers: { headers: {

View file

@ -3,6 +3,7 @@ import LogtoClient from '@logto/node';
import { demoAppApplicationId } from '@logto/schemas'; import { demoAppApplicationId } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials'; import type { Nullable, Optional } from '@silverhand/essentials';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import type { Got } from 'got';
import { got } from 'got'; import { got } from 'got';
import { consent, submitInteraction } from '#src/api/index.js'; import { consent, submitInteraction } from '#src/api/index.js';
@ -18,22 +19,24 @@ export const defaultConfig = {
export default class MockClient { export default class MockClient {
public rawCookies: string[] = []; public rawCookies: string[] = [];
protected readonly config: LogtoConfig;
private navigateUrl?: string; private navigateUrl?: string;
private readonly storage: MemoryStorage; private readonly storage: MemoryStorage;
private readonly logto: LogtoClient; private readonly logto: LogtoClient;
private readonly api: Got;
constructor(config?: Partial<LogtoConfig>) { constructor(config?: Partial<LogtoConfig>) {
this.storage = new MemoryStorage(); this.storage = new MemoryStorage();
this.config = { ...defaultConfig, ...config };
this.api = got.extend({ prefixUrl: this.config.endpoint + '/api' });
this.logto = new LogtoClient( this.logto = new LogtoClient(this.config, {
{ ...defaultConfig, ...config }, navigate: (url: string) => {
{ this.navigateUrl = url;
navigate: (url: string) => { },
this.navigateUrl = url; storage: this.storage,
}, });
storage: this.storage,
}
);
} }
// TODO: Rename to sessionCookies or something accurate // TODO: Rename to sessionCookies or something accurate
@ -62,7 +65,7 @@ export default class MockClient {
assert(this.navigateUrl, new Error('Unable to navigate to sign in uri')); assert(this.navigateUrl, new Error('Unable to navigate to sign in uri'));
assert( assert(
this.navigateUrl.startsWith(`${logtoUrl}/oidc/auth`), this.navigateUrl.startsWith(`${this.config.endpoint}/oidc/auth`),
new Error('Unable to navigate to sign in uri') new Error('Unable to navigate to sign in uri')
); );
@ -84,7 +87,10 @@ export default class MockClient {
public async processSession(redirectTo: string) { public async processSession(redirectTo: string) {
// Note: should redirect to OIDC auth endpoint // Note: should redirect to OIDC auth endpoint
assert(redirectTo.startsWith(`${logtoUrl}/oidc/auth`), new Error('SignIn or Register failed')); assert(
redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`),
new Error('SignIn or Register failed')
);
const authResponse = await got.get(redirectTo, { const authResponse = await got.get(redirectTo, {
headers: { headers: {
@ -149,7 +155,7 @@ export default class MockClient {
} }
public async submitInteraction() { public async submitInteraction() {
return submitInteraction(this.interactionCookie); return submitInteraction(this.api, this.interactionCookie);
} }
private readonly consent = async () => { private readonly consent = async () => {
@ -157,10 +163,10 @@ export default class MockClient {
assert(this.interactionCookie, new Error('Session not found')); assert(this.interactionCookie, new Error('Session not found'));
assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found')); assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found'));
const { redirectTo } = await consent(this.interactionCookie); const { redirectTo } = await consent(this.api, this.interactionCookie);
// Note: should redirect to oidc auth endpoint // Note: should redirect to oidc auth endpoint
assert(redirectTo.startsWith(`${logtoUrl}/oidc/auth`), new Error('Consent failed')); assert(redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`), new Error('Consent failed'));
const authCodeResponse = await got.get(redirectTo, { const authCodeResponse = await got.get(redirectTo, {
headers: { headers: {

View file

@ -0,0 +1,97 @@
// To refactor: should combine into other similar utils
// Since they are just different in URLs
import type { LogtoConfig } from '@logto/node';
import type { Role, User } from '@logto/schemas';
import {
PredefinedScope,
adminTenantId,
defaultTenantId,
getManagementApiResourceIndicator,
adminConsoleApplicationId,
InteractionEvent,
} from '@logto/schemas';
import { authedAdminTenantApi as api, adminTenantApi } from '#src/api/api.js';
import type { InteractionPayload } from '#src/api/interaction.js';
import { adminConsoleRedirectUri, logtoConsoleUrl } from '#src/constants.js';
import { initClient } from '#src/helpers/client.js';
import { generatePassword, generateUsername } from '#src/utils.js';
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
export const createResponseWithCode = (statusCode: number) => ({
response: { statusCode },
});
export const createUserWithAllRoles = async () => {
const username = generateUsername();
const password = generatePassword();
const user = await api
.post('users', {
json: { username, password },
})
.json<User>();
// Should have roles for default tenant Management API and admin tenant Me API
const roles = await api.get('roles').json<Role[]>();
await Promise.all(
roles.map(async ({ id }) =>
api.post(`roles/${id}/users`, {
json: { userIds: [user.id] },
})
)
);
return [user, { username, password }] as const;
};
export const deleteUser = async (id: string) => {
await api.delete(`users/${id}`);
};
export const putInteraction = async (cookie: string, payload: InteractionPayload) =>
adminTenantApi
.put('interaction', {
headers: { cookie },
json: payload,
followRedirect: false,
})
.json();
export const initClientAndSignIn = async (
username: string,
password: string,
config?: Partial<LogtoConfig>
) => {
const client = await initClient(
{
endpoint: logtoConsoleUrl,
appId: adminConsoleApplicationId,
...config,
},
adminConsoleRedirectUri
);
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
await client.processSession(redirectTo);
return client;
};
export const createUserAndSignInWithClient = async () => {
const [{ id }, { username, password }] = await createUserWithAllRoles();
const client = await initClientAndSignIn(username, password, {
resources: [resourceDefault, resourceMe],
scopes: [PredefinedScope.All],
});
return { id, client };
};

View file

@ -1,10 +1,11 @@
import type { LogtoConfig } from '@logto/node';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import MockClient from '#src/client/index.js'; import MockClient from '#src/client/index.js';
export const initClient = async () => { export const initClient = async (config?: Partial<LogtoConfig>, redirectUri?: string) => {
const client = new MockClient(); const client = new MockClient(config);
await client.initSession(); await client.initSession(redirectUri);
assert(client.interactionCookie, new Error('Session not found')); assert(client.interactionCookie, new Error('Session not found'));
return client; return client;

View file

@ -1,6 +1,6 @@
import { api } from '#src/api/index.js'; import { api } from '#src/api/index.js';
describe('Health check', () => { describe('health check', () => {
it('should have a health state', async () => { it('should have a health state', async () => {
expect(await api.get('status')).toHaveProperty('statusCode', 204); expect(await api.get('status')).toHaveProperty('statusCode', 204);
}); });

View file

@ -0,0 +1,57 @@
import { got } from 'got';
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
import {
createResponseWithCode,
createUserAndSignInWithClient,
deleteUser,
resourceDefault,
resourceMe,
} from '#src/helpers/admin-tenant.js';
describe('me', () => {
it('should only be available in admin tenant', async () => {
await expect(got.get(new URL('/me/custom-data', logtoConsoleUrl))).rejects.toMatchObject(
createResponseWithCode(401)
);
// Redirect to UI
const response = await got.get(new URL('/me/custom-data', logtoUrl));
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']?.startsWith('text/html;')).toBeTruthy();
});
it('should only recognize the access token with correct resource and scope', async () => {
const { id, client } = await createUserAndSignInWithClient();
await expect(
got.get(logtoConsoleUrl + '/me/custom-data', {
headers: { authorization: `Bearer ${await client.getAccessToken(resourceDefault)}` },
})
).rejects.toMatchObject(createResponseWithCode(401));
await expect(
got.get(logtoConsoleUrl + '/me/custom-data', {
headers: { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` },
})
).resolves.toHaveProperty('statusCode', 200);
await deleteUser(id);
});
it('should be able to update custom data', async () => {
const { id, client } = await createUserAndSignInWithClient();
const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` };
const data = await got
.get(logtoConsoleUrl + '/me/custom-data', { headers })
.json<Record<string, unknown>>();
const newData = await got
.patch(logtoConsoleUrl + '/me/custom-data', { headers, json: { foo: 'bar' } })
.json();
expect({ ...data, foo: 'bar' }).toStrictEqual(newData);
await deleteUser(id);
});
});

View file

@ -58,7 +58,6 @@
"@types/react": "^18.0.20", "@types/react": "^18.0.20",
"eslint": "^8.21.0", "eslint": "^8.21.0",
"jest": "^29.0.3", "jest": "^29.0.3",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0", "lint-staged": "^13.0.0",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"prettier": "^2.8.1", "prettier": "^2.8.1",

View file

@ -709,7 +709,6 @@ importers:
color: ^4.2.3 color: ^4.2.3
eslint: ^8.21.0 eslint: ^8.21.0
jest: ^29.0.3 jest: ^29.0.3
jest-matcher-specific-error: ^1.0.0
lint-staged: ^13.0.0 lint-staged: ^13.0.0
nanoid: ^4.0.0 nanoid: ^4.0.0
postcss: ^8.4.6 postcss: ^8.4.6
@ -736,7 +735,6 @@ importers:
'@types/react': 18.0.26 '@types/react': 18.0.26
eslint: 8.21.0 eslint: 8.21.0
jest: 29.3.1_@types+node@18.11.18 jest: 29.3.1_@types+node@18.11.18
jest-matcher-specific-error: 1.0.0
lint-staged: 13.0.0 lint-staged: 13.0.0
postcss: 8.4.18 postcss: 8.4.18
prettier: 2.8.1 prettier: 2.8.1