diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index 22736a479..de9e3695e 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -1,11 +1,21 @@ 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({ - prefixUrl: new URL('/api', logtoUrl), +export default api; + +// 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: { 'development-user-id': 'integration-test-admin-user', }, diff --git a/packages/integration-tests/src/api/interaction.ts b/packages/integration-tests/src/api/interaction.ts index 9df2b6073..198c14128 100644 --- a/packages/integration-tests/src/api/interaction.ts +++ b/packages/integration-tests/src/api/interaction.ts @@ -4,6 +4,7 @@ import type { Profile, RequestVerificationCodePayload, } from '@logto/schemas'; +import type { Got } from 'got'; import api from './api.js'; @@ -11,13 +12,13 @@ export type RedirectResponse = { redirectTo: string; }; -export type interactionPayload = { +export type InteractionPayload = { event: InteractionEvent; identifier?: IdentifierPayload; profile?: Profile; }; -export const putInteraction = async (cookie: string, payload: interactionPayload) => +export const putInteraction = async (cookie: string, payload: InteractionPayload) => api .put('interaction', { headers: { cookie }, @@ -66,7 +67,7 @@ export const deleteInteractionProfile = async (cookie: string) => }) .json(); -export const submitInteraction = async (cookie: string) => +export const submitInteraction = async (api: Got, cookie: string) => api .post('interaction/submit', { headers: { cookie }, followRedirect: false }) .json(); @@ -97,7 +98,7 @@ export const createSocialAuthorizationUri = async ( followRedirect: false, }); -export const consent = async (cookie: string) => +export const consent = async (api: Got, cookie: string) => api .post('interaction/consent', { headers: { diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 92d2b7cf9..9c2c19d3e 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -3,6 +3,7 @@ import LogtoClient from '@logto/node'; import { demoAppApplicationId } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials'; +import type { Got } from 'got'; import { got } from 'got'; import { consent, submitInteraction } from '#src/api/index.js'; @@ -18,22 +19,24 @@ export const defaultConfig = { export default class MockClient { public rawCookies: string[] = []; + protected readonly config: LogtoConfig; + private navigateUrl?: string; private readonly storage: MemoryStorage; private readonly logto: LogtoClient; + private readonly api: Got; constructor(config?: Partial) { this.storage = new MemoryStorage(); + this.config = { ...defaultConfig, ...config }; + this.api = got.extend({ prefixUrl: this.config.endpoint + '/api' }); - this.logto = new LogtoClient( - { ...defaultConfig, ...config }, - { - navigate: (url: string) => { - this.navigateUrl = url; - }, - storage: this.storage, - } - ); + this.logto = new LogtoClient(this.config, { + navigate: (url: string) => { + this.navigateUrl = url; + }, + storage: this.storage, + }); } // 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.startsWith(`${logtoUrl}/oidc/auth`), + this.navigateUrl.startsWith(`${this.config.endpoint}/oidc/auth`), new Error('Unable to navigate to sign in uri') ); @@ -84,7 +87,10 @@ export default class MockClient { public async processSession(redirectTo: string) { // 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, { headers: { @@ -149,7 +155,7 @@ export default class MockClient { } public async submitInteraction() { - return submitInteraction(this.interactionCookie); + return submitInteraction(this.api, this.interactionCookie); } private readonly consent = async () => { @@ -157,10 +163,10 @@ export default class MockClient { assert(this.interactionCookie, 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 - 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, { headers: { diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts new file mode 100644 index 000000000..663b21a7e --- /dev/null +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -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(); + + // Should have roles for default tenant Management API and admin tenant Me API + const roles = await api.get('roles').json(); + 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 +) => { + 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 }; +}; diff --git a/packages/integration-tests/src/helpers/client.ts b/packages/integration-tests/src/helpers/client.ts index b2bfc52d1..46594ed2a 100644 --- a/packages/integration-tests/src/helpers/client.ts +++ b/packages/integration-tests/src/helpers/client.ts @@ -1,10 +1,11 @@ +import type { LogtoConfig } from '@logto/node'; import { assert } from '@silverhand/essentials'; import MockClient from '#src/client/index.js'; -export const initClient = async () => { - const client = new MockClient(); - await client.initSession(); +export const initClient = async (config?: Partial, redirectUri?: string) => { + const client = new MockClient(config); + await client.initSession(redirectUri); assert(client.interactionCookie, new Error('Session not found')); return client; diff --git a/packages/integration-tests/src/tests/api/health-check.test.ts b/packages/integration-tests/src/tests/api/health-check.test.ts index 1c44838eb..cf4c3c1bc 100644 --- a/packages/integration-tests/src/tests/api/health-check.test.ts +++ b/packages/integration-tests/src/tests/api/health-check.test.ts @@ -1,6 +1,6 @@ import { api } from '#src/api/index.js'; -describe('Health check', () => { +describe('health check', () => { it('should have a health state', async () => { expect(await api.get('status')).toHaveProperty('statusCode', 204); }); diff --git a/packages/integration-tests/src/tests/api/me.test.ts b/packages/integration-tests/src/tests/api/me.test.ts new file mode 100644 index 000000000..9db7fc4f8 --- /dev/null +++ b/packages/integration-tests/src/tests/api/me.test.ts @@ -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>(); + const newData = await got + .patch(logtoConsoleUrl + '/me/custom-data', { headers, json: { foo: 'bar' } }) + .json(); + + expect({ ...data, foo: 'bar' }).toStrictEqual(newData); + + await deleteUser(id); + }); +}); diff --git a/packages/toolkit/core-kit/package.json b/packages/toolkit/core-kit/package.json index 7acc46675..84464c3ed 100644 --- a/packages/toolkit/core-kit/package.json +++ b/packages/toolkit/core-kit/package.json @@ -58,7 +58,6 @@ "@types/react": "^18.0.20", "eslint": "^8.21.0", "jest": "^29.0.3", - "jest-matcher-specific-error": "^1.0.0", "lint-staged": "^13.0.0", "postcss": "^8.4.6", "prettier": "^2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 659baf862..56287f354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -709,7 +709,6 @@ importers: color: ^4.2.3 eslint: ^8.21.0 jest: ^29.0.3 - jest-matcher-specific-error: ^1.0.0 lint-staged: ^13.0.0 nanoid: ^4.0.0 postcss: ^8.4.6 @@ -736,7 +735,6 @@ importers: '@types/react': 18.0.26 eslint: 8.21.0 jest: 29.3.1_@types+node@18.11.18 - jest-matcher-specific-error: 1.0.0 lint-staged: 13.0.0 postcss: 8.4.18 prettier: 2.8.1