feat: admin lifecycle endpoints for product integrations (US2)

Implements tasks T021-T025 from specs/002-saas-integration/tasks.md
(User Story 2, P2): an admin can register, rotate, revoke, and change
the status of a ProductIntegration, and retrieve its audit trail.

- ProductIntegrationsService: register (finds-or-creates the Product
  by external id), rotate (dual-credential transition window per
  research.md), revoke, updateStatus, getAuditTrail -- each writes
  its own AuditLog entry via a new shared
  integration-audit-log.repository.ts (extracted from the auth
  plugin, which now reuses it instead of writing to Prisma directly).
- Routes: POST /admin/products/:externalProductId/integration,
  POST/admin/integrations/:id/rotate|revoke, PATCH .../status,
  GET .../audit-trail -- gated by the existing fastify.authenticate
  (human/admin JWT) decorator.
- New integration test (product-integrations-admin.test.ts) covers
  Quickstart Scenarios 5-7 end-to-end against a real Postgres/Redis:
  register+rotate+audit-trail, and revoke-takes-effect-immediately.
  Verified passing against a live database.

Known, pre-existing limitation flagged (not fixed here, out of
scope): fastify.authenticate is currently a no-op stub with no real
JWT verification, so these admin endpoints aren't actually
access-controlled yet -- that depends on the unimplemented
identity/auth module. Documented in the contract and checklist notes
so it isn't mistaken for done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-08-21 18:57:07 +05:30
co-authored by Claude Sonnet 5
parent 8d5731340d
commit 3e2a5b97a3
19 changed files with 551 additions and 45 deletions
@@ -74,6 +74,14 @@
- Manually verified all of spec.md's User Story 1 acceptance scenarios end-to-end against a live
server + Postgres + Redis (via temporary Docker containers), beyond what the automated tests
cover: valid in-scope acceptance, indistinguishable invalid-credential/unregistered-product
rejection, unknown-field rejection, out-of-scope rejection, and replay rejection. User Story 2
(admin onboarding/rotation/revocation/audit-trail) and User Story 3 (rate limiting) are not yet
implemented — see tasks.md T021-T028, still pending.
rejection, unknown-field rejection, out-of-scope rejection, and replay rejection.
- **User Story 2 (admin onboarding/rotation/revocation/audit-trail) is now implemented and
automatically tested** (`tests/integration/product-integrations-admin.test.ts`, run against a
real Postgres/Redis, verified passing). **Known limitation carried over from the existing
codebase, not introduced by this feature**: the admin routes are gated by
`fastify.authenticate` (`src/plugins/auth.plugin.ts`), which is currently a no-op stub — it
never actually verifies a JWT or rejects an unauthenticated caller. These admin endpoints are
therefore not really access-controlled yet. Fixing this requires the `identity/auth` module
(itself unimplemented) and is out of scope for this feature — flagged here and in
`contracts/inbound-request-contract.md` so it isn't mistaken for "done."
- User Story 3 (rate limiting, T026-T028) is not yet implemented.
@@ -58,15 +58,26 @@ request reaches its route handler. Every attempt — pass or fail at any step
## Admin: Integration Lifecycle Endpoints
Extends the existing `catalog/products` module (`src/modules/catalog/products/`):
Extends the existing `catalog/products` module (`src/modules/catalog/products/`). Registration is
keyed by the product's *external* id (the product may not exist locally yet — registering an
integration creates it); every other operation is keyed by the `ProductIntegration`'s own id,
since that's what registration returns and what admin tooling references thereafter:
| Operation | Effect |
|---|---|
| Register integration | Creates `ProductIntegration` for a `Product` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and an initial `allowedScope` |
| Rotate credential | Moves current `credentialRef``previousCredentialRef`, sets `previousCredentialExpiresAt`, issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again, matches "never persist raw credential" — only a reference/hash is stored) |
| Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately |
| Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` |
| Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first |
| Route | Operation | Effect |
|---|---|---|
| `POST /admin/products/:externalProductId/integration` | Register integration | Finds-or-creates the `Product`, then creates its `ProductIntegration` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and the request body's `allowedScope` |
| `POST /admin/integrations/:integrationId/rotate` | Rotate credential | Moves current `credentialRef``previousCredentialRef`, sets `previousCredentialExpiresAt` (research.md's rotation transition window), issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again matches "never persist raw credential," see research.md "Credential storage") |
| `POST /admin/integrations/:integrationId/revoke` | Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately |
| `PATCH /admin/integrations/:integrationId/status` | Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` |
| `GET /admin/integrations/:integrationId/audit-trail` | Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first |
All five require an admin-authenticated caller via the existing human/admin JWT plugin
(`fastify.authenticate`, `src/plugins/auth.plugin.ts`) — a separate concern from the
product-integration signed-token auth this contract otherwise describes. **Known limitation**:
`auth.plugin.ts`'s `authenticate` decorator is currently a stub that performs no real JWT
verification (it exists as scaffolding — see `src/modules/identity/auth`, itself unimplemented).
These admin endpoints are therefore not actually access-controlled yet; real JWT verification is
a separate, pre-existing gap this feature surfaces but does not fix.
All five are admin-authenticated via the existing human/admin JWT `auth.plugin.ts` — a separate
concern from the product-integration signed-token auth this contract otherwise describes.
+5 -5
View File
@@ -141,28 +141,28 @@ immediate, audit trail is retrievable).
### Tests for User Story 2
- [ ] T021 [P] [US2] Integration tests for register/rotate/revoke/get-audit-trail endpoints in
- [X] T021 [P] [US2] Integration tests for register/rotate/revoke/get-audit-trail endpoints in
`tests/integration/product-integrations-admin.test.ts`, covering Quickstart Scenarios 5-7
### Implementation for User Story 2
- [ ] T022 [US2] Add `ProductIntegrationsService` methods (`register`, `rotate`, `revoke`,
- [X] T022 [US2] Add `ProductIntegrationsService` methods (`register`, `rotate`, `revoke`,
`updateStatus`, `getAuditTrail`) in
`src/modules/catalog/products/service/product-integrations.service.ts``register`/
`rotate` generate a new secret, encrypt it (T008) before persisting, and return the
plaintext secret in the response exactly once (depends on T014)
- [ ] T023 [US2] Add `ProductIntegrationsController` with admin-authenticated handlers
- [X] T023 [US2] Add `ProductIntegrationsController` with admin-authenticated handlers
(register/rotate/revoke/updateStatus/getAuditTrail) in
`src/modules/catalog/products/controller/product-integrations.controller.ts`, gated by the
existing `fastify.authenticate` (human/admin JWT, `auth.plugin.ts`) — not the
product-integration plugin from Phase 3 (depends on T022)
- [ ] T024 [US2] Add routes (`POST /admin/products/:id/integration`, `POST
- [X] T024 [US2] Add routes (`POST /admin/products/:id/integration`, `POST
/admin/products/:id/integration/rotate`, `POST /admin/products/:id/integration/revoke`,
`PATCH /admin/products/:id/integration/status`, `GET
/admin/products/:id/integration/audit-trail`) in
`src/modules/catalog/products/routes/product-integrations.routes.ts`, registered from
`src/modules/catalog/products/routes/index.ts` (depends on T023)
- [ ] T025 [US2] Run Quickstart Scenarios 5-7 locally and confirm all three pass
- [X] T025 [US2] Run Quickstart Scenarios 5-7 locally and confirm all three pass
**Checkpoint**: Both Stories 1 and 2 work together — an admin can onboard an integration and User
Story 1's plugin correctly validates against whatever the admin configured.
+6 -1
View File
@@ -1,12 +1,17 @@
import { FastifyInstance } from 'fastify';
import { healthRoutes } from './health.routes';
import { metricsRoutes } from './metrics.routes';
import { productsRoutes, inboundRequestRoutes } from '@/modules/catalog/products';
import {
productsRoutes,
inboundRequestRoutes,
productIntegrationsAdminRoutes,
} from '@/modules/catalog/products';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
await app.register(metricsRoutes);
await app.register(productsRoutes);
await app.register(inboundRequestRoutes);
await app.register(productIntegrationsAdminRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
@@ -7,6 +7,10 @@ export const INTEGRATION_ERROR_CODES = {
export const INTEGRATION_AUDIT_ACTIONS = {
AUTH_SUCCESS: 'integration.auth.success',
AUTH_FAILURE: 'integration.auth.failure',
REGISTERED: 'integration.registered',
ROTATED: 'integration.rotated',
REVOKED: 'integration.revoked',
STATUS_UPDATED: 'integration.status_updated',
} as const;
export const INTEGRATION_AUDIT_FAILURE_REASONS = {
@@ -22,3 +26,8 @@ export const INTEGRATION_TOKEN_CONFIG = {
DEFAULT_TTL_SECONDS: 60,
CLOCK_SKEW_TOLERANCE_SECONDS: 5,
} as const;
// Default credential-rotation transition window — REQUIRES BUSINESS CONFIRMATION per
// docs/10-implementation-roadmap.md if a different value is ever needed; not exposed as
// admin-configurable yet (spec.md Assumptions defers exact values to planning).
export const INTEGRATION_ROTATION_TRANSITION_MINUTES = 60;
@@ -1 +1,2 @@
export * from './products.controller';
export * from './product-integrations.controller';
@@ -0,0 +1,83 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { productIntegrationsService, ProductIntegrationsService } from '../service';
import { registerIntegrationSchema, updateIntegrationStatusSchema } from '../schema';
function actorFrom(request: FastifyRequest): string {
return request.reqContext?.actorId ?? 'admin';
}
export class ProductIntegrationsController {
constructor(private readonly service: ProductIntegrationsService = productIntegrationsService) {}
async register(request: FastifyRequest, reply: FastifyReply) {
const { externalProductId } = request.params as { externalProductId: string };
const body = registerIntegrationSchema.omit({ externalProductId: true }).parse(request.body);
const result = await this.service.register(actorFrom(request), {
externalProductId,
...body,
});
return reply.status(201).send({
success: true,
data: {
productId: result.product.id,
externalProductId: result.product.externalProductId,
integrationId: result.integration.id,
credentialSecret: result.credentialSecret,
},
meta: null,
});
}
async rotate(request: FastifyRequest, reply: FastifyReply) {
const { integrationId } = request.params as { integrationId: string };
const result = await this.service.rotate(actorFrom(request), integrationId);
return reply.status(200).send({
success: true,
data: {
integrationId: result.integration.id,
credentialSecret: result.credentialSecret,
previousCredentialExpiresAt: result.integration.previousCredentialExpiresAt,
},
meta: null,
});
}
async revoke(request: FastifyRequest, reply: FastifyReply) {
const { integrationId } = request.params as { integrationId: string };
const integration = await this.service.revoke(actorFrom(request), integrationId);
return reply.status(200).send({
success: true,
data: { integrationId: integration.id, revokedAt: integration.revokedAt },
meta: null,
});
}
async updateStatus(request: FastifyRequest, reply: FastifyReply) {
const { integrationId } = request.params as { integrationId: string };
const { status } = updateIntegrationStatusSchema.parse(request.body);
const integration = await this.service.updateStatus(actorFrom(request), integrationId, status);
return reply.status(200).send({
success: true,
data: { integrationId: integration.id, status: integration.status },
meta: null,
});
}
async getAuditTrail(request: FastifyRequest, reply: FastifyReply) {
const { integrationId } = request.params as { integrationId: string };
const events = await this.service.getAuditTrail(integrationId);
return reply.status(200).send({
success: true,
data: events,
meta: null,
});
}
}
export const productIntegrationsController = new ProductIntegrationsController();
+9 -2
View File
@@ -1,5 +1,10 @@
export { productsRoutes, inboundRequestRoutes } from './routes';
export { ProductsService, productsService } from './service';
export { productsRoutes, inboundRequestRoutes, productIntegrationsAdminRoutes } from './routes';
export {
ProductsService,
productsService,
ProductIntegrationsService,
productIntegrationsService,
} from './service';
export type { ProductDTO } from './types';
export {
@@ -7,6 +12,8 @@ export {
ProductIntegrationsRepository,
customerReferencesRepository,
CustomerReferencesRepository,
writeIntegrationAuditEvent,
findIntegrationAuditTrail,
} from './repository';
export type { ProductIntegrationWithProduct } from './repository';
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
@@ -1,3 +1,4 @@
export * from './products.repository';
export * from './product-integrations.repository';
export * from './customer-references.repository';
export * from './integration-audit-log.repository';
@@ -0,0 +1,36 @@
import type { Prisma, AuditLog } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
/**
* Writes one AuditLog row for a ProductIntegration-related event (auth attempts, admin
* lifecycle actions). Shared by the auth plugin and the admin service so both go through the
* repository layer rather than calling Prisma directly — docs/07 "Repository is the only layer
* that talks to Prisma."
*/
export async function writeIntegrationAuditEvent(params: {
actor: string;
actorType: string;
action: string;
entityId: string;
reason?: string;
metadata?: Prisma.InputJsonValue;
}): Promise<void> {
await prismaClient.auditLog.create({
data: {
actor: params.actor,
actorType: params.actorType,
action: params.action,
entityType: 'ProductIntegration',
entityId: params.entityId,
...(params.reason !== undefined ? { reason: params.reason } : {}),
...(params.metadata !== undefined ? { metadata: params.metadata } : {}),
},
});
}
export async function findIntegrationAuditTrail(entityId: string): Promise<AuditLog[]> {
return prismaClient.auditLog.findMany({
where: { entityType: 'ProductIntegration', entityId },
orderBy: { createdAt: 'desc' },
});
}
@@ -1,11 +1,30 @@
import { prismaClient } from '@/infrastructure/database';
import { Product } from '@prisma/client';
export class ProductsRepository {
constructor(private readonly prisma = prismaClient) {}
async findAllProducts(): Promise<unknown[]> {
async findAllProducts(): Promise<Product[]> {
return this.prisma.product.findMany();
}
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
return this.prisma.product.findUnique({ where: { externalProductId } });
}
async findById(id: string): Promise<Product | null> {
return this.prisma.product.findUnique({ where: { id } });
}
/** Registering an integration for a not-yet-known product creates it — this feature doesn't
* introduce a separate product-catalog CRUD surface, only what's needed for onboarding. */
async findOrCreateByExternalProductId(externalProductId: string, name: string): Promise<Product> {
return this.prisma.product.upsert({
where: { externalProductId },
update: {},
create: { externalProductId, name, supportEnabled: true, status: 'active' },
});
}
}
export const productsRepository = new ProductsRepository();
@@ -1,2 +1,3 @@
export * from './products.routes';
export * from './inbound-request.routes';
export * from './product-integrations-admin.routes';
@@ -0,0 +1,40 @@
import { FastifyInstance } from 'fastify';
import { productIntegrationsController } from '../controller';
/**
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet.
*/
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/integration',
{ preHandler: fastify.authenticate },
(req, reply) => productIntegrationsController.register(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/rotate',
{ preHandler: fastify.authenticate },
(req, reply) => productIntegrationsController.rotate(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/revoke',
{ preHandler: fastify.authenticate },
(req, reply) => productIntegrationsController.revoke(req, reply),
);
fastify.patch(
'/admin/integrations/:integrationId/status',
{ preHandler: fastify.authenticate },
(req, reply) => productIntegrationsController.updateStatus(req, reply),
);
fastify.get(
'/admin/integrations/:integrationId/audit-trail',
{ preHandler: fastify.authenticate },
(req, reply) => productIntegrationsController.getAuditTrail(req, reply),
);
}
@@ -1,2 +1,3 @@
export * from './products.schema';
export * from './inbound-request.schema';
export * from './product-integrations-admin.schema';
@@ -0,0 +1,23 @@
import { z } from 'zod';
export const registerIntegrationSchema = z
.object({
externalProductId: z.string().min(1),
name: z.string().min(1),
allowedScope: z.object({
allowAnyTenant: z.boolean().optional(),
tenantIds: z.array(z.string()).optional(),
}),
rateLimitPerMinute: z.number().int().positive().optional(),
rateLimitPerUserPerMinute: z.number().int().positive().optional(),
})
.strict();
export const updateIntegrationStatusSchema = z
.object({
status: z.enum(['active', 'suspended']),
})
.strict();
export type RegisterIntegrationBody = z.infer<typeof registerIntegrationSchema>;
export type UpdateIntegrationStatusBody = z.infer<typeof updateIntegrationStatusSchema>;
@@ -1 +1,2 @@
export * from './products.service';
export * from './product-integrations.service';
@@ -0,0 +1,143 @@
import {
productsRepository,
ProductsRepository,
productIntegrationsRepository,
ProductIntegrationsRepository,
writeIntegrationAuditEvent,
findIntegrationAuditTrail,
} from '../repository';
import { encryptCredential, generateCredentialSecret } from '../mapper';
import {
INTEGRATION_AUDIT_ACTIONS,
INTEGRATION_ROTATION_TRANSITION_MINUTES,
} from '@/common/constants';
import { NotFoundError } from '@/common/errors';
import type { AuditLog, Product, ProductIntegration } from '@prisma/client';
export interface RegisterIntegrationInput {
externalProductId: string;
name: string;
allowedScope: object;
rateLimitPerMinute?: number | undefined;
rateLimitPerUserPerMinute?: number | undefined;
}
export interface IssuedCredential {
product: Product;
integration: ProductIntegration;
/** Plaintext secret — returned exactly once, never retrievable again. */
credentialSecret: string;
}
export class ProductIntegrationsService {
constructor(
private readonly productsRepo: ProductsRepository = productsRepository,
private readonly integrationsRepo: ProductIntegrationsRepository = productIntegrationsRepository,
) {}
async register(actor: string, input: RegisterIntegrationInput): Promise<IssuedCredential> {
const product = await this.productsRepo.findOrCreateByExternalProductId(
input.externalProductId,
input.name,
);
const credentialSecret = generateCredentialSecret();
const integration = await this.integrationsRepo.create({
productId: product.id,
credentialRef: encryptCredential(credentialSecret),
authMechanism: 'signed_token',
allowedScope: input.allowedScope,
...(input.rateLimitPerMinute !== undefined
? { rateLimitPerMinute: input.rateLimitPerMinute }
: {}),
...(input.rateLimitPerUserPerMinute !== undefined
? { rateLimitPerUserPerMinute: input.rateLimitPerUserPerMinute }
: {}),
});
await writeIntegrationAuditEvent({
actor,
actorType: 'admin',
action: INTEGRATION_AUDIT_ACTIONS.REGISTERED,
entityId: integration.id,
metadata: { externalProductId: input.externalProductId },
});
return { product, integration, credentialSecret };
}
async rotate(actor: string, integrationId: string): Promise<IssuedCredential> {
const existing = await this.integrationsRepo.findById(integrationId);
if (!existing) throw new NotFoundError('Product integration not found.');
const product = await this.productsRepo.findById(existing.productId);
if (!product) throw new NotFoundError('Product integration not found.');
const credentialSecret = generateCredentialSecret();
const previousCredentialExpiresAt = new Date(
Date.now() + INTEGRATION_ROTATION_TRANSITION_MINUTES * 60_000,
);
const integration = await this.integrationsRepo.rotate(integrationId, {
previousCredentialRef: existing.credentialRef,
previousCredentialExpiresAt,
credentialRef: encryptCredential(credentialSecret),
});
await writeIntegrationAuditEvent({
actor,
actorType: 'admin',
action: INTEGRATION_AUDIT_ACTIONS.ROTATED,
entityId: integrationId,
metadata: { previousCredentialExpiresAt: previousCredentialExpiresAt.toISOString() },
});
return { product, integration, credentialSecret };
}
async revoke(actor: string, integrationId: string): Promise<ProductIntegration> {
const existing = await this.integrationsRepo.findById(integrationId);
if (!existing) throw new NotFoundError('Product integration not found.');
const integration = await this.integrationsRepo.revoke(integrationId);
await writeIntegrationAuditEvent({
actor,
actorType: 'admin',
action: INTEGRATION_AUDIT_ACTIONS.REVOKED,
entityId: integrationId,
});
return integration;
}
async updateStatus(
actor: string,
integrationId: string,
status: 'active' | 'suspended',
): Promise<ProductIntegration> {
const existing = await this.integrationsRepo.findById(integrationId);
if (!existing) throw new NotFoundError('Product integration not found.');
const integration = await this.integrationsRepo.updateStatus(integrationId, status);
await writeIntegrationAuditEvent({
actor,
actorType: 'admin',
action: INTEGRATION_AUDIT_ACTIONS.STATUS_UPDATED,
entityId: integrationId,
metadata: { status },
});
return integration;
}
async getAuditTrail(integrationId: string): Promise<AuditLog[]> {
const existing = await this.integrationsRepo.findById(integrationId);
if (!existing) throw new NotFoundError('Product integration not found.');
return findIntegrationAuditTrail(integrationId);
}
}
export const productIntegrationsService = new ProductIntegrationsService();
+7 -25
View File
@@ -1,7 +1,5 @@
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import type { Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
import { hasSeenJti, markJtiSeen } from '@/infrastructure/cache';
import {
INTEGRATION_ERROR_CODES,
@@ -21,6 +19,7 @@ import {
verifyIntegrationToken,
inboundRequestSchema,
InboundRequest,
writeIntegrationAuditEvent,
} from '@/modules/catalog/products';
declare module 'fastify' {
@@ -44,26 +43,6 @@ function isInScope(scope: unknown, tenantId: string): boolean {
return Array.isArray(parsed.tenantIds) && parsed.tenantIds.includes(tenantId);
}
async function writeAuditEvent(params: {
actor: string;
action: string;
entityId: string;
reason?: string;
metadata?: Prisma.InputJsonValue;
}): Promise<void> {
await prismaClient.auditLog.create({
data: {
actor: params.actor,
actorType: 'system',
action: params.action,
entityType: 'ProductIntegration',
entityId: params.entityId,
...(params.reason !== undefined ? { reason: params.reason } : {}),
...(params.metadata !== undefined ? { metadata: params.metadata } : {}),
},
});
}
function credentialError(message: string): AppError {
return new AppError(message, INTEGRATION_ERROR_CODES.INVALID_CREDENTIAL, 401);
}
@@ -102,8 +81,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
if (!integration) {
// Unresolvable — nothing to audit against as a known entity; audit with a synthetic
// actor so the attempt still leaves a trace without inventing a fake entityId.
await writeAuditEvent({
await writeIntegrationAuditEvent({
actor: 'unknown',
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
@@ -112,8 +92,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
}
const auditFailure = (reason: string) =>
writeAuditEvent({
writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
@@ -202,8 +183,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeAuditEvent({
await writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
@@ -0,0 +1,135 @@
import { describe, it, expect, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { issueIntegrationToken } from '@/modules/catalog/products';
/**
* Covers specs/002-saas-integration/quickstart.md Scenarios 5-7 (rotation zero-downtime,
* revocation is immediate, audit trail is retrievable) against a real Postgres/Redis. See
* specs/002-saas-integration/checklists/requirements.md implementation notes for the known
* DATABASE_URL wiring gap this test shares with tests/integration/product-integration-auth.test.ts.
*/
describe('Product Integration Admin Lifecycle', () => {
let app: FastifyInstance;
const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`;
afterAll(async () => {
await prismaClient.productIntegration.deleteMany({
where: { product: { externalProductId } },
});
await prismaClient.product.deleteMany({ where: { externalProductId } });
await app.close();
});
it('Scenario 5+7: register, then rotate — both old and new credential work during the transition window, and the audit trail records every step', async () => {
app = await buildApp();
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/integration`,
payload: {
name: 'Admin Test Product',
allowedScope: { tenantIds: ['tenant-1'] },
},
});
expect(registerResponse.statusCode).toBe(201);
const registered = registerResponse.json().data;
const originalSecret = registered.credentialSecret;
const integrationId = registered.integrationId;
const rotateResponse = await app.inject({
method: 'POST',
url: `/admin/integrations/${integrationId}/rotate`,
});
expect(rotateResponse.statusCode).toBe(200);
const rotated = rotateResponse.json().data;
const newSecret = rotated.credentialSecret;
expect(newSecret).not.toBe(originalSecret);
const oldTokenRequest = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: {
authorization: `Bearer ${issueIntegrationToken(originalSecret, { externalProductId, tenantId: 'tenant-1', userId: 'user-1' })}`,
},
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'old credential still valid during transition',
},
});
const newTokenRequest = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: {
authorization: `Bearer ${issueIntegrationToken(newSecret, { externalProductId, tenantId: 'tenant-1', userId: 'user-2' })}`,
},
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-2',
source: 'test',
problem: 'new credential valid',
},
});
expect(oldTokenRequest.statusCode).toBe(202);
expect(newTokenRequest.statusCode).toBe(202);
const auditResponse = await app.inject({
method: 'GET',
url: `/admin/integrations/${integrationId}/audit-trail`,
});
expect(auditResponse.statusCode).toBe(200);
const actions = auditResponse.json().data.map((e: { action: string }) => e.action);
expect(actions).toContain('integration.registered');
expect(actions).toContain('integration.rotated');
expect(actions.filter((a: string) => a === 'integration.auth.success')).toHaveLength(2);
});
it('Scenario 6: revocation takes effect on the very next request', async () => {
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}-revoke/integration`,
payload: {
name: 'Admin Test Product Revoke',
allowedScope: { tenantIds: ['tenant-1'] },
},
});
const { credentialSecret, integrationId } = registerResponse.json().data;
const revokeResponse = await app.inject({
method: 'POST',
url: `/admin/integrations/${integrationId}/revoke`,
});
expect(revokeResponse.statusCode).toBe(200);
const requestAfterRevoke = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: {
authorization: `Bearer ${issueIntegrationToken(credentialSecret, { externalProductId: `${externalProductId}-revoke`, tenantId: 'tenant-1', userId: 'user-1' })}`,
},
payload: {
productId: `${externalProductId}-revoke`,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'should be rejected',
},
});
expect(requestAfterRevoke.statusCode).toBe(401);
expect(requestAfterRevoke.json().error.code).toBe('INVALID_INTEGRATION_CREDENTIAL');
await prismaClient.productIntegration.deleteMany({
where: { product: { externalProductId: `${externalProductId}-revoke` } },
});
await prismaClient.product.deleteMany({
where: { externalProductId: `${externalProductId}-revoke` },
});
});
});