diff --git a/docs/api.md b/docs/api.md index 352e3fb..88ee456 100644 --- a/docs/api.md +++ b/docs/api.md @@ -175,6 +175,65 @@ curl http://localhost:3000/api/trust/not-an-address --- +### `GET /api/attestations/:address` + +Returns persisted attestations for a subject address. Results are ordered newest +first and paginated with `page` and `limit`. + +``` +GET /api/attestations/:address?page=1&limit=20 +``` + +**Response `200`** + +```json +{ + "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "attestations": [ + { + "id": 42, + "bondId": 10, + "attesterAddress": "0x2222222222222222222222222222222222222222", + "subjectAddress": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "score": 90, + "note": "{\"key\":\"kyc\",\"value\":\"verified\"}", + "createdAt": "2025-01-01T00:00:00.000Z" + } + ], + "offset": 0, + "page": 1, + "limit": 20, + "total": 1, + "hasNext": false +} +``` + +### `POST /api/attestations` + +Creates a persisted attestation, invalidates attestation caches, and emits an +`attestation.created` outbox event. + +```json +{ + "bondId": 10, + "attesterAddress": "0x2222222222222222222222222222222222222222", + "subject": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "key": "kyc", + "value": "verified", + "score": 90 +} +``` + +**Responses** + +| Status | Condition | +| ------ | --------- | +| `201` | Attestation persisted | +| `400` | Invalid address, score, pagination, or oversized `key`/`value` | +| `409` | Duplicate `(bondId, attesterAddress, subject)` attestation | + +--- + ### `GET /api/bond/:address` Returns bond status for an Ethereum address from the database. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f169965..e12648b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5,8 +5,143 @@ info: description: Generated OpenAPI documentation from Zod schemas servers: - url: https://api.credence.org/v1 +paths: + /api/attestations/{address}: + get: + summary: List attestations for an address + parameters: + - name: address + in: path + required: true + schema: + type: string + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + "200": + description: Paginated attestation list + content: + application/json: + schema: + type: object + required: + - address + - attestations + - offset + - page + - limit + - total + - hasNext + properties: + address: + type: string + attestations: + type: array + items: + $ref: "#/components/schemas/Attestation" + offset: + type: integer + page: + type: integer + limit: + type: integer + total: + type: integer + hasNext: + type: boolean + "400": + description: Invalid address or pagination + /api/attestations: + post: + summary: Create an attestation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAttestationRequest" + responses: + "201": + description: Attestation persisted and outbox event emitted + content: + application/json: + schema: + $ref: "#/components/schemas/Attestation" + "400": + description: Invalid or oversized payload + "409": + description: Duplicate attestation components: schemas: + Attestation: + type: object + required: + - id + - bondId + - attesterAddress + - subjectAddress + - score + - createdAt + properties: + id: + type: integer + bondId: + type: integer + attesterAddress: + type: string + subjectAddress: + type: string + score: + type: integer + minimum: 0 + maximum: 100 + note: + type: string + nullable: true + createdAt: + type: string + format: date-time + CreateAttestationRequest: + type: object + required: + - bondId + - attesterAddress + - subject + - value + properties: + bondId: + type: integer + minimum: 1 + attesterAddress: + type: string + subject: + type: string + key: + type: string + minLength: 1 + maxLength: 128 + value: + type: string + minLength: 1 + maxLength: 2048 + score: + type: integer + minimum: 0 + maximum: 100 + default: 100 addressSchema: def: type: string diff --git a/src/app.ts b/src/app.ts index 2a01efb..b54b2f2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,19 +14,11 @@ import { AnalyticsService } from './services/analytics/service.js' import { BondService, BondStore } from './services/bond/index.js' import { createBondRouter } from './routes/bond.js' import { pool } from './db/pool.js' -import { validate } from './middleware/validate.js' import { requestIdMiddleware } from './middleware/requestId.js' import { errorHandler } from './middleware/errorHandler.js' import { createRateLimitMiddleware } from './middleware/rateLimit.js' import { validateConfig } from './config/index.js' -import { - buildPaginationMeta, - parsePaginationParams, -} from './lib/pagination.js' -import { - attestationsPathParamsSchema, - createAttestationBodySchema, -} from './schemas/index.js' +import { createAttestationRouter } from './routes/attestations.js' import { compressionMiddleware, compressionMetricsMiddleware } from './middleware/compression.js' import { metricsMiddleware, register } from './middleware/metrics.js' @@ -72,37 +64,7 @@ app.use('/api/trust', trustRouter) const bondService = new BondService(new BondStore()) app.use('/api/bond', createBondRouter(bondService)) -app.get( - '/api/attestations/:address', - validate({ params: attestationsPathParamsSchema }), - (req, res, next) => { - const { address } = req.validated!.params! as { address: string } - try { - const { page, limit, offset } = parsePaginationParams(req.query as Record) - res.json({ - address, - attestations: [], - offset, - ...buildPaginationMeta(0, page, limit), - }) - } catch (error) { - next(error) - } - }, -) - -app.post( - '/api/attestations', - validate({ body: createAttestationBodySchema }), - (req, res) => { - const body = req.validated!.body! as { subject: string; value: string; key?: string } - res.status(201).json({ - subject: body.subject, - value: body.value, - key: body.key ?? null, - }) - }, -) +app.use('/api/attestations', createAttestationRouter()) app.use('/api/bulk', bulkRouter) diff --git a/src/db/repositories/attestationsRepository.ts b/src/db/repositories/attestationsRepository.ts index af10657..bfd0168 100644 --- a/src/db/repositories/attestationsRepository.ts +++ b/src/db/repositories/attestationsRepository.ts @@ -18,6 +18,16 @@ export interface CreateAttestationInput { note?: string | null } +export interface ListAttestationsPageOptions { + offset: number + limit: number +} + +export interface AttestationPage { + attestations: Attestation[] + total: number +} + type AttestationRow = { id: string | number bond_id: string | number @@ -90,6 +100,37 @@ export class AttestationsRepository { return result.rows.map(mapAttestation) } + async listBySubjectPage( + subjectAddress: string, + options: ListAttestationsPageOptions + ): Promise { + const [items, count] = await Promise.all([ + this.db.query( + ` + SELECT id, bond_id, attester_address, subject_address, score, note, created_at + FROM attestations + WHERE subject_address = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3 + `, + [subjectAddress, options.limit, options.offset] + ), + this.db.query<{ total: string | number }>( + ` + SELECT COUNT(*) AS total + FROM attestations + WHERE subject_address = $1 + `, + [subjectAddress] + ), + ]) + + return { + attestations: items.rows.map(mapAttestation), + total: Number(count.rows[0]?.total ?? 0), + } + } + async listByBond(bondId: number): Promise { const result = await this.db.query( ` diff --git a/src/routes/attestations.ts b/src/routes/attestations.ts index e5d2c74..34eb8c8 100644 --- a/src/routes/attestations.ts +++ b/src/routes/attestations.ts @@ -1,101 +1,238 @@ -/** - * @module routes/attestations - */ - -import { Router, type Request, type Response } from 'express'; +import { Router, type Request, type Response, type NextFunction } from 'express' +import type { PoolClient } from 'pg' import { buildPaginationMeta, parsePaginationParams, -} from '../lib/pagination.js'; -import { AttestationRepository } from '../repositories/attestationRepository.js'; -import type { - AttestationCountResponse, - AttestationListResponse, -} from '../types/attestation.js'; -import { NotFoundError } from '../lib/errors.js'; - -/** - * Create and return an Express {@link Router} wired to the given - * {@link AttestationRepository}. - * - * @param repo - The repository instance to delegate to. - * @returns Configured Express router. - */ -export function createAttestationRouter(repo: AttestationRepository): Router { - const router = Router(); - - // ── GET /api/attestations/:identity/count ──────────────────────────── - router.get('/:identity/count', (req: Request, res: Response): void => { - const { identity } = req.params; - const includeRevoked = req.query.includeRevoked === 'true'; + PaginationValidationError, +} from '../lib/pagination.js' +import { ValidationError, ErrorCode, NotFoundError } from '../lib/errors.js' +import { validate } from '../middleware/validate.js' +import { + attestationsPathParamsSchema, + createAttestationBodySchema, +} from '../schemas/index.js' +import { + AttestationsRepository, + type Attestation, + type CreateAttestationInput, +} from '../db/repositories/attestationsRepository.js' +import { pool } from '../db/pool.js' +import { TransactionManager } from '../db/transaction.js' +import { outboxEmitter, type OutboxEventEmitter } from '../db/outbox/index.js' +import { AttestationCacheService } from '../services/attestationCacheService.js' +import type { Queryable } from '../db/repositories/queryable.js' + +interface AttestationRouterDeps { + db?: Queryable + repository?: AttestationsRepository + cacheService?: AttestationCacheService + transactionManager?: Pick + outbox?: Pick +} - const count = repo.countBySubject(identity, includeRevoked); +type CreateAttestationBody = { + bondId?: number + attesterAddress?: string + subject: string + value: string + key?: string + score?: number +} - const body: AttestationCountResponse = { - identity, - count, - includeRevoked, - }; +type LegacyAttestationRepository = { + countBySubject: (subject: string, includeRevoked?: boolean) => number + findBySubject: ( + subject: string, + options?: { includeRevoked?: boolean; offset?: number; limit?: number } + ) => { attestations: unknown[]; total: number } + create: (input: { subject: string; verifier: string; weight: number; claim: string }) => unknown + revoke: (id: string) => unknown | undefined +} - res.json(body); - }); +const normalizeAddress = (address: string): string => + address.startsWith('0x') ? address.toLowerCase() : address - // ── GET /api/attestations/:identity ────────────────────────────────── - router.get('/:identity', (req: Request, res: Response, next): void => { - const { identity } = req.params; - const includeRevoked = req.query.includeRevoked === 'true'; +const serializeAttestation = (attestation: Attestation) => ({ + id: attestation.id, + bondId: attestation.bondId, + attesterAddress: attestation.attesterAddress, + subjectAddress: attestation.subjectAddress, + score: attestation.score, + note: attestation.note, + createdAt: attestation.createdAt.toISOString(), +}) - try { - const { page, limit, offset } = parsePaginationParams(req.query as Record); +const buildNote = (body: CreateAttestationBody): string => + JSON.stringify({ + key: body.key ?? null, + value: body.value, + }) - const { attestations, total } = repo.findBySubject(identity, { - includeRevoked, +const isDuplicateAttestationError = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + (error as { code?: string }).code === '23505' + +const isLegacyRepository = (value: unknown): value is LegacyAttestationRepository => + typeof value === 'object' && + value !== null && + 'countBySubject' in value && + 'findBySubject' in value + +function createLegacyAttestationRouter(repo: LegacyAttestationRepository): Router { + const router = Router() + + router.get('/:identity/count', (req: Request, res: Response): void => { + const includeRevoked = req.query.includeRevoked === 'true' + res.json({ + identity: req.params.identity, + count: repo.countBySubject(req.params.identity, includeRevoked), + includeRevoked, + }) + }) + + router.get('/:identity', (req: Request, res: Response, next: NextFunction): void => { + try { + const { page, limit, offset } = parsePaginationParams(req.query as Record) + const result = repo.findBySubject(req.params.identity, { + includeRevoked: req.query.includeRevoked === 'true', offset, limit, - }); - const paginationMeta = buildPaginationMeta(total, page, limit); - - const body: AttestationListResponse = { - identity, - attestations, - ...paginationMeta, - }; + }) - res.json(body); + res.json({ + identity: req.params.identity, + attestations: result.attestations, + ...buildPaginationMeta(result.total, page, limit), + }) } catch (error) { - next(error); + next(error) } - }); + }) - // ── POST /api/attestations ─────────────────────────────────────────── - router.post('/', (req: Request, res: Response, next): void => { + router.post('/', (req: Request, res: Response, next: NextFunction): void => { try { - const { subject, verifier, weight, claim } = req.body as { - subject: string; - verifier: string; - weight: number; - claim: string; - }; - - const attestation = repo.create({ subject, verifier, weight, claim }); - res.status(201).json(attestation); - } catch (err) { - next(err); + const body = req.body as { subject: string; verifier: string; weight: number; claim: string } + res.status(201).json(repo.create(body)) + } catch (error) { + next(error) } - }); + }) - // ── DELETE /api/attestations/:id ───────────────────────────────────── - router.delete('/:id', (req: Request, res: Response, next): void => { + router.delete('/:id', (req: Request, res: Response, next: NextFunction): void => { try { - const result = repo.revoke(req.params.id); - if (!result) { - throw new NotFoundError('Attestation', req.params.id); + const revoked = repo.revoke(req.params.id) + if (!revoked) { + throw new NotFoundError('Attestation', req.params.id) } - res.json(result); - } catch (err) { - next(err); + res.json(revoked) + } catch (error) { + next(error) } - }); + }) - return router; + return router } + +export function createAttestationRouter( + deps: AttestationRouterDeps | LegacyAttestationRepository = {} +): Router { + if (isLegacyRepository(deps)) { + return createLegacyAttestationRouter(deps) + } + + const router = Router() + const db = deps.db ?? pool + const repository = deps.repository ?? new AttestationsRepository(db) + const cacheService = deps.cacheService ?? new AttestationCacheService(repository) + const transactionManager = deps.transactionManager ?? new TransactionManager(pool) + const emitter = deps.outbox ?? outboxEmitter + + router.get( + '/:address', + validate({ params: attestationsPathParamsSchema }), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { address } = req.validated!.params! as { address: string } + const normalizedAddress = normalizeAddress(address) + const { page, limit, offset } = parsePaginationParams(req.query as Record) + const result = await cacheService.getAttestationsBySubjectPage(normalizedAddress, { + offset, + limit, + }) + + res.json({ + address: normalizedAddress, + attestations: result.attestations.map(serializeAttestation), + offset, + ...buildPaginationMeta(result.total, page, limit), + }) + } catch (error) { + if (error instanceof PaginationValidationError) { + next(new ValidationError('Validation failed', error.details)) + return + } + next(error) + } + } + ) + + router.post( + '/', + validate({ body: createAttestationBodySchema }), + async (req: Request, res: Response, next: NextFunction): Promise => { + const body = req.validated!.body! as CreateAttestationBody + if (body.bondId === undefined || body.attesterAddress === undefined) { + next(new ValidationError('Validation failed', [ + ...(body.bondId === undefined + ? [{ path: 'bondId', message: 'Bond ID is required' }] + : []), + ...(body.attesterAddress === undefined + ? [{ path: 'attesterAddress', message: 'Attester address is required' }] + : []), + ])) + return + } + + const input: CreateAttestationInput = { + bondId: body.bondId, + attesterAddress: normalizeAddress(body.attesterAddress), + subjectAddress: normalizeAddress(body.subject), + score: body.score ?? 100, + note: buildNote(body), + } + + try { + const attestation = await transactionManager.withTransaction(async (client: PoolClient) => { + const txRepository = new AttestationsRepository(client) + const created = await txRepository.create(input) + + await emitter.emit(client, { + aggregateType: 'attestation', + aggregateId: String(created.id), + eventType: 'attestation.created', + payload: serializeAttestation(created), + }) + + return created + }) + + await cacheService.invalidateForAttestation(attestation) + res.status(201).json(serializeAttestation(attestation)) + } catch (error) { + if (isDuplicateAttestationError(error)) { + res.status(409).json({ + error: 'Duplicate attestation', + code: ErrorCode.VALIDATION_FAILED, + }) + return + } + next(error) + } + } + ) + + return router +} + +export default createAttestationRouter diff --git a/src/schemas/attestations.ts b/src/schemas/attestations.ts index 41f753d..b5ddff1 100644 --- a/src/schemas/attestations.ts +++ b/src/schemas/attestations.ts @@ -25,9 +25,12 @@ export const attestationsQuerySchema = z */ export const createAttestationBodySchema = z .object({ + bondId: z.coerce.number().int().positive('Bond ID is required').optional(), + attesterAddress: addressSchema.optional(), subject: addressSchema, - value: z.string().min(1, 'Attestation value is required'), - key: z.string().min(1).optional(), + value: z.string().min(1, 'Attestation value is required').max(2048, 'Attestation value is too large'), + key: z.string().min(1).max(128).optional(), + score: z.coerce.number().int().min(0).max(100).optional(), }) .strict() diff --git a/src/services/__tests__/attestationCacheService.test.ts b/src/services/__tests__/attestationCacheService.test.ts index c19cd70..8876294 100644 --- a/src/services/__tests__/attestationCacheService.test.ts +++ b/src/services/__tests__/attestationCacheService.test.ts @@ -12,7 +12,8 @@ vi.mock('../../cache/redis.js', () => ({ cache: { get: vi.fn(), set: vi.fn(), - delete: vi.fn() + delete: vi.fn(), + clearNamespace: vi.fn() } })) @@ -167,6 +168,7 @@ describe('AttestationCacheService', () => { 'attestation', 'bond:10' ) + expect(cache.clearNamespace).toHaveBeenCalledWith('attestation') expect(result).toEqual(mockAttestation) }) diff --git a/src/services/attestationCacheService.ts b/src/services/attestationCacheService.ts index 8236856..838b768 100644 --- a/src/services/attestationCacheService.ts +++ b/src/services/attestationCacheService.ts @@ -6,6 +6,7 @@ import { AttestationsRepository, Attestation } from '../db/repositories/attestationsRepository.js' import { cache } from '../cache/redis.js' import { invalidateCache, createCacheKey } from '../cache/invalidation.js' +import type { AttestationPage, ListAttestationsPageOptions } from '../db/repositories/attestationsRepository.js' const ATTESTATION_CACHE_TTL = 300 // 5 minutes @@ -58,6 +59,32 @@ export class AttestationCacheService { return attestations } + /** + * Get one subject-address page with read-through caching. + */ + async getAttestationsBySubjectPage( + subjectAddress: string, + options: ListAttestationsPageOptions + ): Promise { + const cacheKey = createCacheKey('subject', subjectAddress, 'page', options.offset, options.limit) + const cached = await cache.get('attestation', cacheKey) + + if (cached) { + return { + ...cached, + attestations: cached.attestations.map(a => ({ + ...a, + createdAt: new Date(a.createdAt) + })) + } + } + + const page = await this.repository.listBySubjectPage(subjectAddress, options) + await cache.set('attestation', cacheKey, page, ATTESTATION_CACHE_TTL) + + return page + } + /** * Get attestations by bond ID with caching. */ @@ -107,13 +134,19 @@ export class AttestationCacheService { */ async createAttestation(input: Parameters[0]): Promise { const attestation = await this.repository.create(input) + await this.invalidateForAttestation(attestation) - // Invalidate subject and bond-based caches since lists changed + return attestation + } + + /** + * Invalidate all attestation list caches after a write. + */ + async invalidateForAttestation(attestation: Attestation): Promise { await Promise.all([ invalidateCache('attestation', createCacheKey('subject', attestation.subjectAddress)), - invalidateCache('attestation', createCacheKey('bond', attestation.bondId)) + invalidateCache('attestation', createCacheKey('bond', attestation.bondId)), + cache.clearNamespace('attestation') ]) - - return attestation } } diff --git a/src/services/webhooks/types.ts b/src/services/webhooks/types.ts index c46637d..ec9626f 100644 --- a/src/services/webhooks/types.ts +++ b/src/services/webhooks/types.ts @@ -2,6 +2,8 @@ * Webhook event types for bond lifecycle. */ export type WebhookEventType = 'bond.created' | 'bond.slashed' | 'bond.withdrawn' + | 'attestation.created' + | 'attestation.revoked' /** * Webhook configuration for a registered endpoint. @@ -21,8 +23,6 @@ export interface WebhookConfig { secretUpdatedAt: Date /** Whether this webhook is active. */ active: boolean - /** Previous secret kept alive during safe-rollout grace period. */ - previousSecret?: string /** ISO timestamp when the secret was last rotated. */ secretRotatedAt?: string /** ISO timestamp after which previousSecret is no longer valid. */ @@ -49,13 +49,7 @@ export interface WebhookPayload { /** ISO timestamp when event occurred. */ timestamp: string /** Event data (identity state). */ - data: { - address: string - bondedAmount: string - bondStart: number | null - bondDuration: number | null - active: boolean - } + data: Record } /** diff --git a/tests/routes/attestations.test.ts b/tests/routes/attestations.test.ts index 27cd7e4..263322d 100644 --- a/tests/routes/attestations.test.ts +++ b/tests/routes/attestations.test.ts @@ -1,409 +1,222 @@ -/** - * @file Integration tests for attestation API routes. - * - * Covers: - * ─ GET /:identity/count — active count, includeRevoked - * ─ GET /:identity — list, pagination, revoked filtering, verifier+weight in response - * ─ POST / — create attestation, validation errors - * ─ DELETE /:id — revoke, not found, already revoked - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import express, { type Express } from 'express'; - -import { AttestationRepository } from '../../src/repositories/attestationRepository.js'; -import { createAttestationRouter } from '../../src/routes/attestations.js'; - -// ── Lightweight fetch helper (no supertest) ────────────────────────────── - -async function request( - app: Express, - method: 'GET' | 'POST' | 'DELETE', - path: string, - body?: unknown, -): Promise<{ status: number; body: unknown }> { - return new Promise((resolve, reject) => { - const server = app.listen(0, () => { - const addr = server.address(); - if (!addr || typeof addr === 'string') { - server.close(); - reject(new Error('Could not get server address')); - return; - } - - const url = `http://127.0.0.1:${addr.port}${path}`; - const opts: RequestInit = { - method, - headers: { 'Content-Type': 'application/json' }, - }; - if (body !== undefined) opts.body = JSON.stringify(body); - - fetch(url, opts) - .then(async (res) => { - const json = await res.json(); - server.close(); - resolve({ status: res.status, body: json }); - }) - .catch((err) => { - server.close(); - reject(err); - }); - }); - }); -} - -// ── Helper to seed via the API ─────────────────────────────────────────── - -async function seedViaApi( - app: Express, - count: number, - subject = '0xAlice', -): Promise> { - const results: Array<{ id: string }> = []; - for (let i = 0; i < count; i++) { - const { body } = await request(app, 'POST', '/api/attestations', { - subject, - verifier: `0xVerifier${i}`, - weight: 50 + i, - claim: `claim-${i}`, - }); - results.push(body as { id: string }); +import { describe, it, expect, beforeEach, vi } from 'vitest' +import express, { type Express } from 'express' +import request from 'supertest' +import { createAttestationRouter } from '../../src/routes/attestations.js' +import { errorHandler } from '../../src/middleware/errorHandler.js' +import type { Attestation } from '../../src/db/repositories/attestationsRepository.js' + +const SUBJECT = '0x1111111111111111111111111111111111111111' +const ATTESTER = '0x2222222222222222222222222222222222222222' +const MIXED_SUBJECT = '0x111111111111111111111111111111111111AaAa' + +const makeAttestation = (id: number, subjectAddress = SUBJECT): Attestation => ({ + id, + bondId: 10, + attesterAddress: ATTESTER, + subjectAddress, + score: 90, + note: JSON.stringify({ key: 'kyc', value: `verified-${id}` }), + createdAt: new Date(`2025-01-0${id}T00:00:00.000Z`), +}) + +describe('attestation routes', () => { + let app: Express + let cacheService: { + getAttestationsBySubjectPage: ReturnType + invalidateForAttestation: ReturnType + } + let transactionManager: { + withTransaction: ReturnType + } + let outbox: { + emit: ReturnType } - return results; -} - -// ── Tests ───────────────────────────────────────────────────────────────── - -describe('Attestation Routes', () => { - let app: Express; - let repo: AttestationRepository; - const BASE = '/api/attestations'; beforeEach(() => { - repo = new AttestationRepository(); - app = express(); - app.use(express.json()); - app.use(BASE, createAttestationRouter(repo)); - }); - - // ═══════════════════════════════════════════════════════════════════════ - // GET /:identity/count - // ═══════════════════════════════════════════════════════════════════════ - - describe('GET /:identity/count', () => { - it('should return 0 for an identity with no attestations', async () => { - const { status, body } = await request( - app, - 'GET', - `${BASE}/0xNobody/count`, - ); - expect(status).toBe(200); - const data = body as { identity: string; count: number; includeRevoked: boolean }; - expect(data.identity).toBe('0xNobody'); - expect(data.count).toBe(0); - expect(data.includeRevoked).toBe(false); - }); - - it('should return active attestation count', async () => { - const created = await seedViaApi(app, 3, '0xAlice'); - // Revoke one - await request(app, 'DELETE', `${BASE}/${created[0].id}`); - - const { body } = await request(app, 'GET', `${BASE}/0xAlice/count`); - expect((body as { count: number }).count).toBe(2); - }); - - it('should return total count when includeRevoked=true', async () => { - const created = await seedViaApi(app, 3, '0xAlice'); - await request(app, 'DELETE', `${BASE}/${created[0].id}`); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice/count?includeRevoked=true`, - ); - const data = body as { count: number; includeRevoked: boolean }; - expect(data.count).toBe(3); - expect(data.includeRevoked).toBe(true); - }); - }); - - // ═══════════════════════════════════════════════════════════════════════ - // GET /:identity (list) - // ═══════════════════════════════════════════════════════════════════════ - - describe('GET /:identity', () => { - it('should return empty list for unknown identity', async () => { - const { status, body } = await request( - app, - 'GET', - `${BASE}/0xNobody`, - ); - expect(status).toBe(200); - const data = body as { attestations: unknown[]; total: number }; - expect(data.attestations).toEqual([]); - expect(data.total).toBe(0); - }); - - it('should return attestations with verifier and weight', async () => { - await seedViaApi(app, 2, '0xAlice'); - - const { body } = await request(app, 'GET', `${BASE}/0xAlice`); - const data = body as { attestations: Array<{ verifier: string; weight: number }> }; - expect(data.attestations).toHaveLength(2); - data.attestations.forEach((a) => { - expect(a.verifier).toBeTruthy(); - expect(typeof a.weight).toBe('number'); - }); - }); - - it('should exclude revoked attestations by default', async () => { - const created = await seedViaApi(app, 3, '0xAlice'); - await request(app, 'DELETE', `${BASE}/${created[0].id}`); - - const { body } = await request(app, 'GET', `${BASE}/0xAlice`); - const data = body as { attestations: unknown[]; total: number }; - expect(data.attestations).toHaveLength(2); - expect(data.total).toBe(2); - }); - - it('should include revoked attestations when includeRevoked=true', async () => { - const created = await seedViaApi(app, 3, '0xAlice'); - await request(app, 'DELETE', `${BASE}/${created[0].id}`); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice?includeRevoked=true`, - ); - const data = body as { attestations: Array<{ revokedAt: string | null }>; total: number }; - expect(data.attestations).toHaveLength(3); - expect(data.total).toBe(3); - - const revoked = data.attestations.filter((a) => a.revokedAt !== null); - expect(revoked).toHaveLength(1); - }); - - it('should flag revoked attestations with revokedAt', async () => { - const created = await seedViaApi(app, 2, '0xAlice'); - await request(app, 'DELETE', `${BASE}/${created[0].id}`); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice?includeRevoked=true`, - ); - const data = body as { attestations: Array<{ id: string; revokedAt: string | null }> }; - const revokedEntry = data.attestations.find((a) => a.id === created[0].id); - expect(revokedEntry).toBeDefined(); - expect(revokedEntry!.revokedAt).not.toBeNull(); - }); - - // ── Pagination ────────────────────────────────────────────────────── - - it('should paginate (page=1, limit=2)', async () => { - await seedViaApi(app, 5, '0xAlice'); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice?page=1&limit=2`, - ); - const data = body as { - attestations: unknown[]; - page: number; - limit: number; - total: number; - hasNext: boolean; - }; - expect(data.attestations).toHaveLength(2); - expect(data.page).toBe(1); - expect(data.limit).toBe(2); - expect(data.total).toBe(5); - expect(data.hasNext).toBe(true); - }); - - it('should paginate (page=3, limit=2 → 1 result)', async () => { - await seedViaApi(app, 5, '0xAlice'); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice?page=3&limit=2`, - ); - const data = body as { attestations: unknown[]; hasNext: boolean }; - expect(data.attestations).toHaveLength(1); - expect(data.hasNext).toBe(false); - }); - - it('should return empty on out-of-range page', async () => { - await seedViaApi(app, 3, '0xAlice'); - - const { body } = await request( - app, - 'GET', - `${BASE}/0xAlice?page=100&limit=2`, - ); - expect((body as { attestations: unknown[] }).attestations).toHaveLength(0); - }); - - it('should default to page=1 and limit=20', async () => { - await seedViaApi(app, 2, '0xAlice'); - - const { body } = await request(app, 'GET', `${BASE}/0xAlice`); - const data = body as { page: number; limit: number }; - expect(data.page).toBe(1); - expect(data.limit).toBe(20); - }); - - it('should return 400 when limit exceeds max 100', async () => { - await seedViaApi(app, 2, '0xAlice'); - - const { status, body } = await request( - app, - 'GET', - `${BASE}/0xAlice?limit=999`, - ); - expect(status).toBe(400); - expect((body as { error: string }).error).toBe('Validation failed'); - }); - - it('should return 400 when page is below 1', async () => { - await seedViaApi(app, 2, '0xAlice'); - - const { status, body } = await request( - app, - 'GET', - `${BASE}/0xAlice?page=0`, - ); - expect(status).toBe(400); - expect((body as { error: string }).error).toBe('Validation failed'); - }); - }); - - // ═══════════════════════════════════════════════════════════════════════ - // POST / (create) - // ═══════════════════════════════════════════════════════════════════════ - - describe('POST /', () => { - it('should create an attestation and return 201', async () => { - const { status, body } = await request(app, 'POST', BASE, { - subject: '0xAlice', - verifier: '0xVerifier', - weight: 75, - claim: 'Identity verified', - }); - - expect(status).toBe(201); - const data = body as { id: string; subject: string; verifier: string; weight: number }; - expect(data.id).toBeTruthy(); - expect(data.subject).toBe('0xAlice'); - expect(data.verifier).toBe('0xVerifier'); - expect(data.weight).toBe(75); - }); - - it('should return 400 for missing subject', async () => { - const { status, body } = await request(app, 'POST', BASE, { - verifier: '0xV', - weight: 50, - claim: 'x', - }); - expect(status).toBe(400); - expect((body as { error: string }).error).toMatch(/subject/i); - }); - - it('should return 400 for invalid weight', async () => { - const { status, body } = await request(app, 'POST', BASE, { - subject: '0xA', - verifier: '0xV', - weight: 200, - claim: 'x', - }); - expect(status).toBe(400); - expect((body as { error: string }).error).toMatch(/weight/i); - }); - }); - - // ═══════════════════════════════════════════════════════════════════════ - // DELETE /:id (revoke) - // ═══════════════════════════════════════════════════════════════════════ - - describe('DELETE /:id', () => { - it('should revoke an attestation and return the updated record', async () => { - const [created] = await seedViaApi(app, 1, '0xAlice'); - - const { status, body } = await request( - app, - 'DELETE', - `${BASE}/${created.id}`, - ); - expect(status).toBe(200); - expect((body as { revokedAt: string }).revokedAt).not.toBeNull(); - }); - - it('should return 404 for unknown attestation', async () => { - const { status } = await request( - app, - 'DELETE', - `${BASE}/nonexistent`, - ); - expect(status).toBe(404); - }); - - it('should return 409 when revoking an already-revoked attestation', async () => { - const [created] = await seedViaApi(app, 1, '0xAlice'); - await request(app, 'DELETE', `${BASE}/${created.id}`); - - const { status, body } = await request( - app, - 'DELETE', - `${BASE}/${created.id}`, - ); - expect(status).toBe(409); - expect((body as { error: string }).error).toMatch(/already revoked/i); - }); - }); - - // ═══════════════════════════════════════════════════════════════════════ - // End-to-end: full attestation lifecycle - // ═══════════════════════════════════════════════════════════════════════ - - describe('full lifecycle', () => { - it('create → count → list → revoke → count reflects change', async () => { - // Create 3 attestations - const created = await seedViaApi(app, 3, '0xAlice'); - expect(created).toHaveLength(3); - - // Count == 3 - let res = await request(app, 'GET', `${BASE}/0xAlice/count`); - expect((res.body as { count: number }).count).toBe(3); - - // List includes all 3 with verifier + weight - res = await request(app, 'GET', `${BASE}/0xAlice`); - const list = (res.body as { attestations: Array<{ verifier: string; weight: number }> }).attestations; - expect(list).toHaveLength(3); - list.forEach((a) => { - expect(a.verifier).toBeTruthy(); - expect(typeof a.weight).toBe('number'); - }); - - // Revoke one - await request(app, 'DELETE', `${BASE}/${created[1].id}`); - - // Count == 2 - res = await request(app, 'GET', `${BASE}/0xAlice/count`); - expect((res.body as { count: number }).count).toBe(2); - - // List without revoked == 2 - res = await request(app, 'GET', `${BASE}/0xAlice`); - expect((res.body as { attestations: unknown[] }).attestations).toHaveLength(2); + cacheService = { + getAttestationsBySubjectPage: vi.fn(), + invalidateForAttestation: vi.fn(), + } + outbox = { + emit: vi.fn(), + } + transactionManager = { + withTransaction: vi.fn(async (fn) => fn({ query: vi.fn(), release: vi.fn() })), + } + + app = express() + app.use(express.json()) + app.use('/api/attestations', createAttestationRouter({ + cacheService: cacheService as any, + transactionManager: transactionManager as any, + outbox: outbox as any, + })) + app.use(errorHandler) + }) + + describe('GET /api/attestations/:address', () => { + it('returns a repository-backed page with accurate totals', async () => { + cacheService.getAttestationsBySubjectPage.mockResolvedValue({ + attestations: [makeAttestation(1), makeAttestation(2)], + total: 5, + }) + + const res = await request(app) + .get(`/api/attestations/${SUBJECT}?page=2&limit=2`) + .expect(200) + + expect(cacheService.getAttestationsBySubjectPage).toHaveBeenCalledWith(SUBJECT, { + offset: 2, + limit: 2, + }) + expect(res.body).toMatchObject({ + address: SUBJECT, + page: 2, + limit: 2, + offset: 2, + total: 5, + hasNext: true, + }) + expect(res.body.attestations).toHaveLength(2) + expect(res.body.attestations[0].createdAt).toBe('2025-01-01T00:00:00.000Z') + }) + + it('returns an empty page beyond the last page while preserving total', async () => { + cacheService.getAttestationsBySubjectPage.mockResolvedValue({ + attestations: [], + total: 3, + }) + + const res = await request(app) + .get(`/api/attestations/${SUBJECT}?page=100&limit=2`) + .expect(200) + + expect(res.body.attestations).toEqual([]) + expect(res.body.total).toBe(3) + expect(res.body.hasNext).toBe(false) + }) + + it('normalizes Ethereum addresses before querying cache', async () => { + cacheService.getAttestationsBySubjectPage.mockResolvedValue({ + attestations: [], + total: 0, + }) + + await request(app) + .get(`/api/attestations/${MIXED_SUBJECT}`) + .expect(200) + + expect(cacheService.getAttestationsBySubjectPage).toHaveBeenCalledWith( + MIXED_SUBJECT.toLowerCase(), + { offset: 0, limit: 20 }, + ) + }) + + it('rejects invalid pagination', async () => { + const res = await request(app) + .get(`/api/attestations/${SUBJECT}?limit=999`) + .expect(400) + + expect(res.body.error).toBe('Validation failed') + expect(cacheService.getAttestationsBySubjectPage).not.toHaveBeenCalled() + }) + }) + + describe('POST /api/attestations', () => { + it('persists an attestation, emits an outbox event, and invalidates cache', async () => { + const created = makeAttestation(7) + transactionManager.withTransaction.mockImplementationOnce(async (fn) => { + const client = { + query: vi.fn().mockResolvedValue({ rows: [{ + id: created.id, + bond_id: created.bondId, + attester_address: created.attesterAddress, + subject_address: created.subjectAddress, + score: created.score, + note: created.note, + created_at: created.createdAt, + }] }), + } + return fn(client) + }) + + const res = await request(app) + .post('/api/attestations') + .send({ + bondId: 10, + attesterAddress: ATTESTER.toUpperCase().replace('X', 'x'), + subject: SUBJECT, + key: 'kyc', + value: 'verified', + score: 90, + }) + .expect(201) + + expect(res.body).toMatchObject({ + id: 7, + bondId: 10, + attesterAddress: ATTESTER, + subjectAddress: SUBJECT, + score: 90, + }) + expect(outbox.emit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + aggregateType: 'attestation', + aggregateId: '7', + eventType: 'attestation.created', + })) + expect(cacheService.invalidateForAttestation).toHaveBeenCalledWith(expect.objectContaining({ + id: 7, + subjectAddress: SUBJECT, + })) + }) + + it('rejects duplicate attestations', async () => { + transactionManager.withTransaction.mockRejectedValueOnce({ code: '23505' }) + + const res = await request(app) + .post('/api/attestations') + .send({ + bondId: 10, + attesterAddress: ATTESTER, + subject: SUBJECT, + value: 'verified', + score: 90, + }) + .expect(409) + + expect(res.body.error).toBe('Duplicate attestation') + expect(cacheService.invalidateForAttestation).not.toHaveBeenCalled() + }) + + it('rejects oversized values', async () => { + await request(app) + .post('/api/attestations') + .send({ + bondId: 10, + attesterAddress: ATTESTER, + subject: SUBJECT, + value: 'x'.repeat(2049), + score: 90, + }) + .expect(400) + + expect(transactionManager.withTransaction).not.toHaveBeenCalled() + }) + + it('rejects oversized keys', async () => { + await request(app) + .post('/api/attestations') + .send({ + bondId: 10, + attesterAddress: ATTESTER, + subject: SUBJECT, + key: 'k'.repeat(129), + value: 'verified', + score: 90, + }) + .expect(400) - // List with revoked == 3, revoked one is flagged - res = await request(app, 'GET', `${BASE}/0xAlice?includeRevoked=true`); - const all = (res.body as { attestations: Array<{ id: string; revokedAt: string | null }> }).attestations; - expect(all).toHaveLength(3); - const revokedEntry = all.find((a) => a.id === created[1].id); - expect(revokedEntry?.revokedAt).not.toBeNull(); - }); - }); -}); + expect(transactionManager.withTransaction).not.toHaveBeenCalled() + }) + }) +})