test(federation): M2 integration tests (grant CRUD, enrollment, replay, at-rest encryption)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,479 @@
|
|||||||
|
/**
|
||||||
|
* Federation M2 integration tests (FED-M2-09).
|
||||||
|
*
|
||||||
|
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
*
|
||||||
|
* Run DB-only tests (no Step-CA):
|
||||||
|
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test \
|
||||||
|
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||||
|
*
|
||||||
|
* Run all tests including Step-CA-dependent ones:
|
||||||
|
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||||
|
* STEP_CA_URL=https://localhost:9000 \
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||||
|
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||||
|
* pnpm --filter @mosaicstack/gateway test \
|
||||||
|
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||||
|
*
|
||||||
|
* Obtaining Step-CA credentials:
|
||||||
|
* # Extract provisioner key from running container:
|
||||||
|
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||||
|
* # Copy root cert from container:
|
||||||
|
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||||
|
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { GoneException } from '@nestjs/common';
|
||||||
|
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
||||||
|
import {
|
||||||
|
createDb,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
federationPeers,
|
||||||
|
federationGrants,
|
||||||
|
federationEnrollmentTokens,
|
||||||
|
inArray,
|
||||||
|
eq,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import * as schema from '@mosaicstack/db';
|
||||||
|
import { seal } from '@mosaicstack/auth';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { GrantsService } from '../../federation/grants.service.js';
|
||||||
|
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||||
|
import { CaService } from '../../federation/ca.service.js';
|
||||||
|
import { FederationScopeError } from '../../federation/scope-schema.js';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for test data isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Unique run prefix to identify rows created by this test run. */
|
||||||
|
const RUN_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
||||||
|
async function insertTestUser(db: Db, id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: `test-user-${id}`,
|
||||||
|
email: `test-${id}@federation-test.invalid`,
|
||||||
|
emailVerified: false,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
||||||
|
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(federationPeers)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
||||||
|
displayName: `Test Peer ${suffix}`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `test-serial-${id}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
state: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB-only test module (CaService mocked so env vars not required)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildDbModule(db: Db) {
|
||||||
|
return Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: DB, useValue: db },
|
||||||
|
GrantsService,
|
||||||
|
{
|
||||||
|
provide: CaService,
|
||||||
|
useValue: {
|
||||||
|
issueCert: async () => {
|
||||||
|
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnrollmentService,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite — DB-only (no Step-CA)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
|
||||||
|
/** IDs created during this run — cleaned up in afterAll. */
|
||||||
|
const createdGrantIds: string[] = [];
|
||||||
|
const createdPeerIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
|
||||||
|
const moduleRef = await buildDbModule(db);
|
||||||
|
grantsService = moduleRef.get(GrantsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up in FK-safe order: tokens → grants → peers → users
|
||||||
|
if (db && createdGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationEnrollmentTokens)
|
||||||
|
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, createdGrantIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (db && createdPeerIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, createdPeerIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (db && createdUserIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(inArray(schema.users.id, createdUserIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (handle) await handle.close().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #1 — grant create writes a pending row
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#1 — createGrant writes a pending row to DB', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test1');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
// Verify the row exists in DB with correct shape
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(federationGrants)
|
||||||
|
.where(eq(federationGrants.id, grant.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row?.status).toBe('pending');
|
||||||
|
expect(row?.peerId).toBe(peerId);
|
||||||
|
expect(row?.subjectUserId).toBe(userId);
|
||||||
|
const storedScope = row?.scope as Record<string, unknown>;
|
||||||
|
expect(storedScope['resources']).toEqual(['tasks']);
|
||||||
|
expect(storedScope['max_rows_per_query']).toBe(100);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #7 — scope with unknown resource type rejected
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const invalidScope = {
|
||||||
|
resources: ['totally_unknown_resource'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test7');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: invalidScope,
|
||||||
|
peerId,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(FederationScopeError);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #8 — listGrants returns accurate status for grants in various states
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test8');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
// Create two pending grants via GrantsService
|
||||||
|
const grantA = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
const grantB = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grantA.id, grantB.id);
|
||||||
|
|
||||||
|
// Insert a third grant directly in 'revoked' state to test status variety
|
||||||
|
const [grantC] = await db
|
||||||
|
.insert(federationGrants)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
subjectUserId: userId,
|
||||||
|
peerId,
|
||||||
|
scope: validScope,
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
createdGrantIds.push(grantC!.id);
|
||||||
|
|
||||||
|
// List all grants for this peer
|
||||||
|
const allForPeer = await grantsService.listGrants({ peerId });
|
||||||
|
|
||||||
|
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
||||||
|
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
||||||
|
expect(ourGrants).toHaveLength(3);
|
||||||
|
|
||||||
|
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
||||||
|
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
||||||
|
expect(pendingGrants).toHaveLength(2);
|
||||||
|
expect(revokedGrants).toHaveLength(1);
|
||||||
|
|
||||||
|
// Status-filtered query
|
||||||
|
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
||||||
|
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
||||||
|
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
||||||
|
|
||||||
|
// Verify peer list from DB also shows the peer rows with correct state
|
||||||
|
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
||||||
|
expect(peers).toHaveLength(1);
|
||||||
|
expect(peers[0]?.state).toBe('pending');
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #5 — client_key_pem encrypted at rest
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
||||||
|
const sealed = seal(rawPem);
|
||||||
|
|
||||||
|
await db.insert(federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
commonName: `test-peer-${RUN_ID}-sealed`,
|
||||||
|
displayName: 'Sealed Key Test Peer',
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `test-serial-sealed-${peerId}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
state: 'pending',
|
||||||
|
clientKeyPem: sealed,
|
||||||
|
});
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
||||||
|
expect(row?.clientKeyPem).toBeDefined();
|
||||||
|
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||||
|
// The sealed value should be non-trivial (at least 20 chars)
|
||||||
|
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite — Step-CA gated
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
let enrollmentService: EnrollmentService;
|
||||||
|
|
||||||
|
const createdGrantIds: string[] = [];
|
||||||
|
const createdPeerIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
|
||||||
|
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
||||||
|
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
grantsService = moduleRef.get(GrantsService);
|
||||||
|
enrollmentService = moduleRef.get(EnrollmentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db && createdGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationEnrollmentTokens)
|
||||||
|
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, createdGrantIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (db && createdPeerIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, createdPeerIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (db && createdUserIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(inArray(schema.users.id, createdUserIds))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (handle) await handle.close().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
||||||
|
async function generateCsrPem(cn: string): Promise<string> {
|
||||||
|
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
||||||
|
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||||
|
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||||
|
name: `CN=${cn}`,
|
||||||
|
keys: keyPair,
|
||||||
|
signingAlgorithm: alg,
|
||||||
|
});
|
||||||
|
return csr.toString('pem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #2 — enrollment signs CSR and returns cert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'ca-test2');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
const { token } = await enrollmentService.createToken({
|
||||||
|
grantId: grant.id,
|
||||||
|
peerId,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
||||||
|
const result = await enrollmentService.redeem(token, csrPem);
|
||||||
|
|
||||||
|
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
|
||||||
|
// Verify the issued cert parses cleanly
|
||||||
|
const cert = new PeculiarX509(result.certPem);
|
||||||
|
expect(cert.serialNumber).toBeTruthy();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #3 — token single-use; second attempt returns GoneException
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#3 — second redeem of the same token throws GoneException', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'ca-test3');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
const { token } = await enrollmentService.createToken({
|
||||||
|
grantId: grant.id,
|
||||||
|
peerId,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
||||||
|
|
||||||
|
// First redeem must succeed
|
||||||
|
const result = await enrollmentService.redeem(token, csrPem);
|
||||||
|
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
|
||||||
|
// Second redeem with the same token must be rejected
|
||||||
|
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user