From 387cdfdb9a283a14eba58376273a61b960fa9b14 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Mon, 7 Apr 2025 11:19:28 +0800 Subject: [PATCH] Merge pull request #7239 from logto-io/charles-log-10872-core-get-one-time-tokens feat(core): add management API to batch fetch one-time tokens --- packages/core/src/queries/one-time-tokens.ts | 34 ++++++++++-- .../src/routes/one-time-tokens.openapi.json | 23 ++++++++ packages/core/src/routes/one-time-tokens.ts | 34 +++++++++++- .../src/api/one-time-token.ts | 3 ++ .../src/tests/api/one-time-tokens.test.ts | 52 +++++++++++++++++++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/packages/core/src/queries/one-time-tokens.ts b/packages/core/src/queries/one-time-tokens.ts index 935301b60..2912673c2 100644 --- a/packages/core/src/queries/one-time-tokens.ts +++ b/packages/core/src/queries/one-time-tokens.ts @@ -5,7 +5,8 @@ import { sql } from '@silverhand/slonik'; import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; -import { convertToIdentifiers } from '#src/utils/sql.js'; +import { getTotalRowCountWithPool } from '#src/database/row-count.js'; +import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; const { table, fields } = convertToIdentifiers(OneTimeTokens); @@ -14,6 +15,31 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { returning: true, }); + const findTotalNumberOfOneTimeTokens = async () => getTotalRowCountWithPool(pool)(table); + + const getOneTimeTokens = async ( + where: { email?: string; status?: OneTimeTokenStatus }, + pagination?: { limit: number; offset: number } + ) => { + const whereEntries = Object.entries(where).filter(([_, value]) => Boolean(value)); + return pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + ${conditionalSql( + whereEntries.length > 0 && whereEntries, + (whereEntries) => + sql`where ${sql.join( + whereEntries.map(([column, value]) => + conditionalSql(value, (value) => sql`${sql.identifier([column])} = ${value}`) + ), + sql` and ` + )}` + )} + order by ${fields.createdAt} desc + ${conditionalSql(pagination, ({ limit, offset }) => sql`limit ${limit} offset ${offset}`)} + `); + }; + const getOneTimeTokenById = buildFindEntityByIdWithPool(pool)(OneTimeTokens); const deleteOneTimeTokenById = buildDeleteByIdWithPool(pool, OneTimeTokens.table); @@ -44,10 +70,12 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => { return { deleteOneTimeTokenById, - insertOneTimeToken, - updateExpiredOneTimeTokensStatusByEmail, + findTotalNumberOfOneTimeTokens, + getOneTimeTokens, getOneTimeTokenById, getOneTimeTokenByToken, + insertOneTimeToken, + updateExpiredOneTimeTokensStatusByEmail, updateOneTimeTokenStatus, }; }; diff --git a/packages/core/src/routes/one-time-tokens.openapi.json b/packages/core/src/routes/one-time-tokens.openapi.json index ebb784bb6..2b917f7e1 100644 --- a/packages/core/src/routes/one-time-tokens.openapi.json +++ b/packages/core/src/routes/one-time-tokens.openapi.json @@ -10,6 +10,29 @@ ], "paths": { "/api/one-time-tokens": { + "get": { + "summary": "Get one-time tokens", + "description": "Get a list of one-time tokens, filtering by email and status, with optional pagination.", + "parameters": [ + { + "name": "email", + "in": "query", + "description": "Filter one-time tokens by email address.", + "required": false + }, + { + "name": "status", + "in": "query", + "description": "Filter one-time tokens by status.", + "required": false + } + ], + "responses": { + "200": { + "description": "A list of one-time tokens." + } + } + }, "post": { "summary": "Create one-time token", "description": "Create a new one-time token associated with an email address. The token can be used for verification purposes and has an expiration time.", diff --git a/packages/core/src/routes/one-time-tokens.ts b/packages/core/src/routes/one-time-tokens.ts index 2d4aa5680..46cf539d6 100644 --- a/packages/core/src/routes/one-time-tokens.ts +++ b/packages/core/src/routes/one-time-tokens.ts @@ -1,10 +1,12 @@ +import { emailRegEx } from '@logto/core-kit'; import { OneTimeTokens, oneTimeTokenStatusGuard } from '@logto/schemas'; import { generateStandardId, generateStandardSecret } from '@logto/shared'; -import { trySafe } from '@silverhand/essentials'; +import { cond, trySafe } from '@silverhand/essentials'; import { addSeconds } from 'date-fns'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; import type { ManagementApiRouter, RouterInitArgs } from './types.js'; @@ -18,8 +20,10 @@ export default function oneTimeTokenRoutes( queries: { oneTimeTokens: { deleteOneTimeTokenById, + findTotalNumberOfOneTimeTokens, insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail, + getOneTimeTokens, getOneTimeTokenById, }, }, @@ -29,6 +33,34 @@ export default function oneTimeTokenRoutes( }, ]: RouterInitArgs ) { + router.get( + '/one-time-tokens', + koaPagination({ isOptional: true }), + koaGuard({ + query: z.object({ + email: z.string().regex(emailRegEx).optional(), + status: oneTimeTokenStatusGuard.optional(), + }), + response: z.array(OneTimeTokens.guard), + status: 200, + }), + async (ctx, next) => { + const { guard, pagination } = ctx; + const { email, status } = guard.query; + const { disabled: isPaginationDisabled } = pagination; + + const [{ count }, oneTimeTokens] = await Promise.all([ + findTotalNumberOfOneTimeTokens(), + getOneTimeTokens({ email, status }, cond(!isPaginationDisabled && pagination)), + ]); + + ctx.pagination.totalCount = count; + ctx.body = oneTimeTokens; + ctx.status = 200; + return next(); + } + ); + router.get( '/one-time-tokens/:id', koaGuard({ diff --git a/packages/integration-tests/src/api/one-time-token.ts b/packages/integration-tests/src/api/one-time-token.ts index b14456ead..739c5aab5 100644 --- a/packages/integration-tests/src/api/one-time-token.ts +++ b/packages/integration-tests/src/api/one-time-token.ts @@ -33,3 +33,6 @@ export const updateOneTimeTokenStatus = async (id: string, status: OneTimeTokenS export const deleteOneTimeTokenById = async (id: string) => authedAdminApi.delete(`one-time-tokens/${id}`).json(); + +export const getOneTimeTokens = async (searchParams?: Record) => + authedAdminApi.get('one-time-tokens', { searchParams }).json(); diff --git a/packages/integration-tests/src/tests/api/one-time-tokens.test.ts b/packages/integration-tests/src/tests/api/one-time-tokens.test.ts index 948c39b35..3e41aa977 100644 --- a/packages/integration-tests/src/tests/api/one-time-tokens.test.ts +++ b/packages/integration-tests/src/tests/api/one-time-tokens.test.ts @@ -1,12 +1,14 @@ import { OneTimeTokenStatus } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; +import { authedAdminApi } from '#src/api/api.js'; import { createOneTimeToken, verifyOneTimeToken, getOneTimeTokenById, updateOneTimeTokenStatus, deleteOneTimeTokenById, + getOneTimeTokens, } from '#src/api/one-time-token.js'; import { expectRejects } from '#src/helpers/index.js'; import { devFeatureTest, waitFor } from '#src/utils.js'; @@ -295,4 +297,54 @@ describe('one-time tokens API', () => { status: 404, }); }); + + it('should be able to batch fetch one-time tokens', async () => { + const email = `foo${generateStandardId()}@bar.com`; + const oneTimeToken1 = await createOneTimeToken({ email }); + const oneTimeToken2 = await createOneTimeToken({ email }); + const oneTimeToken3 = await createOneTimeToken({ email }); + + const tokens = await getOneTimeTokens(); + expect(tokens).toEqual(expect.arrayContaining([oneTimeToken1, oneTimeToken2, oneTimeToken3])); + + const response = await authedAdminApi.get('one-time-tokens?page=1&page_size=2'); + expect(response.headers.get('Total-Number')).toBe('3'); + expect(await response.json()).toEqual(expect.arrayContaining([oneTimeToken2, oneTimeToken3])); + + void deleteOneTimeTokenById(oneTimeToken1.id); + void deleteOneTimeTokenById(oneTimeToken2.id); + void deleteOneTimeTokenById(oneTimeToken3.id); + }); + + it('should be able fetch list of one-time tokens with query params', async () => { + const fooEmail = `foo${generateStandardId()}@bar.com`; + const barEmail = `bar${generateStandardId()}@bar.com`; + const [oneTimeToken1, oneTimeToken2, oneTimeToken3] = await Promise.all([ + createOneTimeToken({ email: fooEmail }), + createOneTimeToken({ email: fooEmail }), + createOneTimeToken({ email: barEmail }), + ]); + + const tokens = await getOneTimeTokens({ email: fooEmail }); + expect(tokens).toEqual(expect.arrayContaining([oneTimeToken1, oneTimeToken2])); + + await updateOneTimeTokenStatus(oneTimeToken1.id, OneTimeTokenStatus.Revoked); + await verifyOneTimeToken({ email: fooEmail, token: oneTimeToken2.token }); + + const revokedTokens = await getOneTimeTokens({ status: OneTimeTokenStatus.Revoked }); + expect(revokedTokens).toEqual([{ ...oneTimeToken1, status: OneTimeTokenStatus.Revoked }]); + + const consumedTokens = await getOneTimeTokens({ status: OneTimeTokenStatus.Consumed }); + expect(consumedTokens).toEqual([{ ...oneTimeToken2, status: OneTimeTokenStatus.Consumed }]); + + const activeBarTokens = await getOneTimeTokens({ + email: barEmail, + status: OneTimeTokenStatus.Active, + }); + expect(activeBarTokens).toEqual([oneTimeToken3]); + + void deleteOneTimeTokenById(oneTimeToken1.id); + void deleteOneTimeTokenById(oneTimeToken2.id); + void deleteOneTimeTokenById(oneTimeToken3.id); + }); });