sync: pick up fail-closed Verae cert fix already deployed live (from fail-closed-verae-certs @1107272: src/app.ts, src/upstream.ts, tests)
This commit is contained in:
parent
2f524deea0
commit
e64a9322ac
7 changed files with 225 additions and 5 deletions
55
src/app.ts
55
src/app.ts
|
|
@ -73,6 +73,45 @@ function parseResultFormats(req: Request): Set<string> {
|
|||
return set;
|
||||
}
|
||||
|
||||
|
||||
/** Production fail-closed: never invent mock-cert-* when an upstream is configured. */
|
||||
function upstreamConfigured(): boolean {
|
||||
return Boolean((process.env.ZAPPIER_UPSTREAM || '').trim());
|
||||
}
|
||||
|
||||
/** Dev-only local mock certificates. Allowed only when upstream is unset. */
|
||||
function allowMockCerts(): boolean {
|
||||
return process.env.ALLOW_MOCK_CERTS === 'true' && !upstreamConfigured();
|
||||
}
|
||||
|
||||
function rejectUpstreamTimestamp(
|
||||
res: Response,
|
||||
upstream: { status: number; body: unknown } | null,
|
||||
): void {
|
||||
if (upstreamConfigured()) {
|
||||
const status =
|
||||
upstream && typeof upstream.status === 'number' && upstream.status >= 400
|
||||
? upstream.status
|
||||
: 502;
|
||||
const body: Record<string, unknown> = {
|
||||
upstreamStatus: upstream?.status ?? null,
|
||||
};
|
||||
if (upstream?.body && typeof upstream.body === 'object' && !Array.isArray(upstream.body)) {
|
||||
Object.assign(body, upstream.body as Record<string, unknown>);
|
||||
} else if (typeof upstream?.body === 'string' && upstream.body) {
|
||||
body.detail = upstream.body;
|
||||
}
|
||||
// Edge error wins over any upstream "error" field.
|
||||
body.error = 'upstream_timestamp_failed';
|
||||
res.status(status).json(body);
|
||||
return;
|
||||
}
|
||||
res.status(503).json({
|
||||
error: 'timestamp_upstream_unconfigured',
|
||||
hint: 'Set ZAPPIER_UPSTREAM for real Verae stamps, or ALLOW_MOCK_CERTS=true for local mock certificates.',
|
||||
});
|
||||
}
|
||||
|
||||
function stampToText(row: {
|
||||
documentName?: string;
|
||||
sha256: string;
|
||||
|
|
@ -430,7 +469,13 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
return;
|
||||
}
|
||||
|
||||
// Local mock when upstream unset/error
|
||||
// Fail closed when upstream is configured (or mock not explicitly allowed).
|
||||
if (!allowMockCerts()) {
|
||||
rejectUpstreamTimestamp(res, upstream);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true
|
||||
const existing = hashIndex.get(sha256);
|
||||
if (existing) {
|
||||
const mockBody = {
|
||||
|
|
@ -685,7 +730,13 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
return;
|
||||
}
|
||||
|
||||
// Local mock when upstream unset or error — Verae BatchTimestampResponse + edge extras
|
||||
// Fail closed when upstream is configured (or mock not explicitly allowed).
|
||||
if (!allowMockCerts()) {
|
||||
rejectUpstreamTimestamp(res, upstream);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true
|
||||
const jobIds: string[] = [];
|
||||
const results: {
|
||||
sha256: string;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export async function fetchVerae(
|
|||
|
||||
/**
|
||||
* After zappier meters the call, forward Verae operations to middleware / API.
|
||||
* When ZAPPIER_UPSTREAM is unset, callers keep the local mock.
|
||||
* When ZAPPIER_UPSTREAM is unset, callers may use ALLOW_MOCK_CERTS=true for local mock.
|
||||
*/
|
||||
export async function proxyVerae(
|
||||
req: Request,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,19 @@ import { InMemoryCustomerRepo } from '../src/auth';
|
|||
import { InMemorySessionRepo } from '../src/accounts';
|
||||
|
||||
describe('xVerae- long-lived API tokens', () => {
|
||||
const prevAllowMock = process.env.ALLOW_MOCK_CERTS;
|
||||
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
|
||||
beforeAll(() => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
process.env.ALLOW_MOCK_CERTS = 'true';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS;
|
||||
else process.env.ALLOW_MOCK_CERTS = prevAllowMock;
|
||||
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
|
||||
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
|
||||
});
|
||||
|
||||
it('portal creates token once; stamp auth accepts xVerae- and Bearer', async () => {
|
||||
const customers = new InMemoryCustomerRepo(
|
||||
DEFAULT_CUSTOMERS.map((c) =>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ import { buildApp } from '../src/app';
|
|||
const KEY = 'key-ada'; // seeded free-tier customer
|
||||
|
||||
describe('Zappier API', () => {
|
||||
const prevAllowMock = process.env.ALLOW_MOCK_CERTS;
|
||||
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
|
||||
beforeAll(() => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
process.env.ALLOW_MOCK_CERTS = 'true';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS;
|
||||
else process.env.ALLOW_MOCK_CERTS = prevAllowMock;
|
||||
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
|
||||
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
|
||||
});
|
||||
|
||||
it('rejects calls without an API key', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get('/v1/status');
|
||||
|
|
@ -24,7 +37,8 @@ describe('Zappier API', () => {
|
|||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ data: 'abc' });
|
||||
expect(a.status).toBe(202);
|
||||
expect(a.status).toBe(200);
|
||||
expect(String(a.body.receipt_id || '')).toMatch(/^mock-cert-/);
|
||||
const b = await request(app)
|
||||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ import {
|
|||
import { emptyChannelRates } from '../src/billing-channels';
|
||||
|
||||
describe('billing headers', () => {
|
||||
const prevAllowMock = process.env.ALLOW_MOCK_CERTS;
|
||||
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
|
||||
beforeAll(() => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
process.env.ALLOW_MOCK_CERTS = 'true';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS;
|
||||
else process.env.ALLOW_MOCK_CERTS = prevAllowMock;
|
||||
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
|
||||
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
|
||||
});
|
||||
|
||||
it('returns spend and balance on metered calls', async () => {
|
||||
const table = emptyChannelRates();
|
||||
table.rates.api.individual.timestamp = 10;
|
||||
|
|
@ -23,7 +36,7 @@ describe('billing headers', () => {
|
|||
.set('x-api-key', 'key-ada')
|
||||
.set('x-billing-channel', 'api')
|
||||
.send({ sha256: '1'.repeat(64) });
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers[BILLING_HEADER_SPEND.toLowerCase()]).toBe('10');
|
||||
expect(res.headers[BILLING_HEADER_BALANCE.toLowerCase()]).toBe('500');
|
||||
expect(res.headers[BILLING_HEADER_CHANNEL.toLowerCase()]).toBe('api');
|
||||
|
|
|
|||
116
tests/fail-closed-certs.test.ts
Normal file
116
tests/fail-closed-certs.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import request from 'supertest';
|
||||
import { buildApp } from '../src/app';
|
||||
|
||||
const KEY = 'key-ada';
|
||||
|
||||
describe('fail-closed Verae timestamp certificates', () => {
|
||||
const prevAllow = process.env.ALLOW_MOCK_CERTS;
|
||||
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
|
||||
|
||||
afterEach(() => {
|
||||
if (prevAllow === undefined) delete process.env.ALLOW_MOCK_CERTS;
|
||||
else process.env.ALLOW_MOCK_CERTS = prevAllow;
|
||||
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
|
||||
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
|
||||
});
|
||||
|
||||
it('returns upstream status (not mock-cert) when ZAPPIER_UPSTREAM is set and upstream is non-2xx', async () => {
|
||||
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
|
||||
delete process.env.ALLOW_MOCK_CERTS;
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(async () => {
|
||||
return new Response(JSON.stringify({ error: 'verae_unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app)
|
||||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ sha256: 'e'.repeat(64) });
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe('upstream_timestamp_failed');
|
||||
expect(res.body.upstreamStatus).toBe(503);
|
||||
expect(String(res.body.receipt_id || '')).not.toMatch(/^mock-cert-/);
|
||||
expect(String(res.body.certificate || '')).not.toMatch(/^mock-cert-/);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 502 when upstream is configured but fetch yields no usable response body path', async () => {
|
||||
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
|
||||
delete process.env.ALLOW_MOCK_CERTS;
|
||||
const orig = globalThis.fetch;
|
||||
// 404 on both preferred and legacy paths → still configured, fail closed (not mock)
|
||||
globalThis.fetch = jest.fn(async () => {
|
||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app)
|
||||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ data: 'fail-closed-404' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('upstream_timestamp_failed');
|
||||
expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('batch timestamps fail closed when upstream returns 500', async () => {
|
||||
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
|
||||
delete process.env.ALLOW_MOCK_CERTS;
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(async () => {
|
||||
return new Response(JSON.stringify({ error: 'boom' }), {
|
||||
status: 500,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app)
|
||||
.post('/v1/timestamps')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ sha256s: ['f'.repeat(64)] });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('upstream_timestamp_failed');
|
||||
expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('without upstream, mock certs require ALLOW_MOCK_CERTS=true', async () => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
delete process.env.ALLOW_MOCK_CERTS;
|
||||
const { app } = buildApp();
|
||||
const res = await request(app)
|
||||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ data: 'no-mock-without-flag' });
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe('timestamp_upstream_unconfigured');
|
||||
expect(String(JSON.stringify(res.body))).not.toMatch(/mock-cert-/);
|
||||
});
|
||||
|
||||
it('ALLOW_MOCK_CERTS=true with upstream unset still emits mock-cert for local dev', async () => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
process.env.ALLOW_MOCK_CERTS = 'true';
|
||||
const { app } = buildApp();
|
||||
const res = await request(app)
|
||||
.post('/v1/timestamp')
|
||||
.set('x-api-key', KEY)
|
||||
.send({ data: 'dev-mock-ok' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(String(res.body.receipt_id)).toMatch(/^mock-cert-/);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,19 @@ import { buildApp, DEFAULT_CUSTOMERS } from '../src/app';
|
|||
import { emptyChannelRates } from '../src/billing-channels';
|
||||
|
||||
describe('POST /v1/timestamps batch', () => {
|
||||
const prevAllowMock = process.env.ALLOW_MOCK_CERTS;
|
||||
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
|
||||
beforeAll(() => {
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
process.env.ALLOW_MOCK_CERTS = 'true';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (prevAllowMock === undefined) delete process.env.ALLOW_MOCK_CERTS;
|
||||
else process.env.ALLOW_MOCK_CERTS = prevAllowMock;
|
||||
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
|
||||
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
|
||||
});
|
||||
|
||||
it('registers many hashes in one call and skips existing for free', async () => {
|
||||
const table = emptyChannelRates();
|
||||
table.rates.zapier.individual.timestamp = 10;
|
||||
|
|
|
|||
Loading…
Reference in a new issue