merge(main): publish finance store password reveal

This commit is contained in:
WuYongJie 2026-09-08 06:22:23 +08:00
commit 22cede9ed0
14 changed files with 922 additions and 0 deletions

View File

@ -0,0 +1,131 @@
# 财务系统店铺密码按需读取契约
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
## 存储事实与授权边界
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`,使用现有 `src/lib/store-account-crypto.ts` 的 AES-256-GCM(随机 12 字节 IV、认证标签、`v1:` 封装)。不是明文或不可逆 Hash。
- 生产密钥来自 `STORE_ACCOUNT_ENCRYPTION_KEY`,不从数据库读取,不复用财务服务认证凭据。已有银行账号加密也使用此密钥,轮换时必须同时处理。
- 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
- 不提供列表、批量密码导出或自动预取。列表只读取 `passwordConfigured`;每次明确的查看/复制操作单独调用接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
## HTTP 契约
生产配置完成后的地址:
`POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal`
`id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
请求头:
```http
Authorization: Bearer <专用服务令牌>
Content-Type: application/json
```
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
```json
{ "requesterId": "finance-user-123", "purpose": "view" }
```
`purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
成功 `200`:
```json
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
```
所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
错误体:
```json
{ "requestId": "<UUID>", "error": { "code": "FORBIDDEN", "message": "请求被拒绝" } }
```
| HTTP | code | 含义 |
|---|---|---|
| 403 | FORBIDDEN | 服务认证失败、非 HTTPS、非法 ID/JSON/用途、额外参数或非 POST |
| 404 | PASSWORD_NOT_FOUND | 账号不存在或没有可用密码,两者不区分 |
| 429 | RATE_LIMITED | 任一限流窗口超限 |
| 503 | UNAVAILABLE | 密钥/解密、数据库、限流或审计不可用;不返回内部异常 |
审计或数据库故障优先返回 503,绝不在无法持久化成功审计时返回密码。数据库完全不可用时无法保存该次失败审计,由网关仅记录追踪 ID、状态与时间供运维关联。成功审计表示服务器已授权并生成响应,不等同于浏览器已经复制成功。
## 限流和超时
- 数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。查看和复制共享额度,轮换令牌不重置服务身份或额度。
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
- 入口网关还需按来源 IP 对全部请求(包括未授权请求)限制为 60 次/分钟,返回 429 JSON 和 `no-store`;这项入口防滥用配置属于上线门禁。应用层数据库限流负责已认证的敏感读取。
## 环境变量(仅名称及要求)
| 名称 | 所在系统与要求 |
|---|---|
| STORE_ACCOUNT_ENCRYPTION_KEY | 上游已有独立加密密钥,至少 32 字符;生产不回退到 JWT_SECRET |
| STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 |
| STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 |
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
## HTTPS、日志和缓存上线门禁
1. 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
2. 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖 `X-Forwarded-Proto: https` 及 `X-Store-Reveal-Edge-Token`(值来自独立 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN`),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过 `nginx -T` 等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。
3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。
4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。
5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 localStorage/sessionStorage/Service Worker 持久化;显示或复制完清除页面内存状态,不把密码放入通知、埋点或消息队列。
## 视图与审计迁移
迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。
- 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。
- 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。
- `passwordConfigured` 表示存在格式完整的、非空 AES-GCM 密码封装。SQL 不接触密钥,所以不在每次列表查询时验证 GCM 标签/密钥健康;生产预检必须做主机内存中的解密聚合验证。密钥丢失或密文损坏属于 503 运维故障,不能把它伪装成未设置密码。
- 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
备份与执行顺序(生产必须在明确批准后执行):
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
2. 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。
3. 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
回滚:先移除财务令牌阻止新调用并回退应用 SHA。新增状态列与审计表可安全保留以避免丢失审计;旧应用不依赖它们。需要完整结构回退时,经 DBA 检查依赖后,在事务中以备份 DDL 重建旧视图并恢复原所有者/ACL(PostgreSQL 不允许 `CREATE OR REPLACE VIEW` 删除尾列,禁止随意 `DROP ... CASCADE`)。审计记录保留,不直接删表;按项目规则处理已应用迁移历史,禁止直接删除 `_prisma_migrations` 记录或用 `db push`。任何完整结构回退先在隔离库演练。
## 密钥轮换
服务令牌:在受限凭据系统生成新随机令牌;旧值暂放 `STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS`,新值放主变量,重启上游;更新财务后端,使用虚构账号验收;移除旧值并重启。两值对应同一服务身份,审计/额度不变。怀疑泄露时立即撤销旧值,宁可短暂拒绝请求。
网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
数据加密密钥:现有 `v1:` 不含 key ID,不能直接替换环境变量。必须另行批准维护窗口,暂停所有店铺密码及银行账号的写入/读取,备份对应表和旧密钥;用受控迁移进程在内存中旧钥解密、新钥重新 AES-GCM 加密,并在同一数据库事务中更新两类密文字段(SQL 永不接收明文)。验证全量解密聚合、行数及虚构账号后,协调所有应用实例切换新环境密钥,再恢复流量;失败时同时恢复密文备份和旧钥。关闭 SQL 参数/异常参数、APM及备份内容输出;本 PR 不执行此轮换,也不引入第二套密钥或把新旧密钥存入数据库。需要无停机轮换时,应另行实现带 key ID 的 Envelope Encryption/KMS 版本迁移。
## 本次验证记录
- 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。
- 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描 `docker logs`;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。
- Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。
- 生产仅完成只读基线核查;生产 `passwordConfigured` 尚未落库,生产接口尚未发布,因此没有生产新增字段/新接口的验收结论。
运行:`npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts`。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;复用密码真相源、加密和专用服务认证,未通过明文视图绕开根因。剩余门禁为生产批准、备份/TLS拓扑/网关配置及发布后真实验收。

View File

@ -0,0 +1,49 @@
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
CREATE TABLE public."StoreAccountPasswordAccessAudit" (
"requestId" UUID PRIMARY KEY,
"requesterId" VARCHAR(128),
"serviceId" VARCHAR(64),
"accountId" VARCHAR(128),
purpose VARCHAR(8) NOT NULL CHECK (purpose IN ('view', 'copy', 'unknown')),
status INTEGER NOT NULL CHECK (status IN (200, 403, 404, 429, 503)),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX "StoreAccountPasswordAccessAudit_serviceId_createdAt_idx"
ON public."StoreAccountPasswordAccessAudit" ("serviceId", "createdAt");
CREATE INDEX "StoreAccountPasswordAccessAudit_accountId_createdAt_idx"
ON public."StoreAccountPasswordAccessAudit" ("accountId", "createdAt");
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM PUBLIC;
DO $migration$
DECLARE
original_definition TEXT;
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'store_account_reader') THEN
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM store_account_reader;
END IF;
-- The external view is provisioned separately by the DBA. Do not create it or
-- broaden its grants in environments that have not opted into this integration.
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN RETURN; END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'external_store_accounts' AND table_name = 'accounts_v1'
AND column_name = 'passwordConfigured') THEN
RAISE EXCEPTION 'External account contract needs preflight review';
END IF;
original_definition := rtrim(pg_get_viewdef('external_store_accounts.accounts_v1'::regclass, true), E';\n\r ');
-- Keep the original query, projection, row filters, duplicates (if any), owner
-- and ACL. EXISTS does not multiply rows. No secret is exposed in this view.
EXECUTE format($view$
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
SELECT original.*, EXISTS (
SELECT 1 FROM public."StoreAccount" credential
WHERE credential.id = original.id
AND credential."passwordCiphertext" ~ '^v1:[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{2,}$'
AND length(split_part(credential."passwordCiphertext", '.', 3)) %% 4 <> 1
)::boolean AS "passwordConfigured"
FROM (%s) original
$view$, original_definition);
END $migration$;
COMMIT;

View File

@ -3597,6 +3597,20 @@ model StoreAccount {
@@index([updatedAt])
}
// Dedicated append-only access audit. Never store password/ciphertext/request bodies.
model StoreAccountPasswordAccessAudit {
requestId String @id @db.Uuid
requesterId String? @db.VarChar(128)
serviceId String? @db.VarChar(64)
accountId String? @db.VarChar(128)
purpose String @db.VarChar(8)
status Int
createdAt DateTime @default(dbgenerated("clock_timestamp()")) @db.Timestamptz(3)
@@index([serviceId, createdAt])
@@index([accountId, createdAt])
}
model StoreAccountStoreProfile {
id String @id @default(cuid())
storeName String

View File

@ -0,0 +1,20 @@
-- Post-migration read-only acceptance. Contains no credential material or row data.
BEGIN READ ONLY;
SET LOCAL statement_timeout = '10s';
SELECT jsonb_build_object(
'database', current_database(),
'count', count(*),
'distinctIds', count(DISTINCT id),
'passwordConfiguredTrue', count(*) FILTER (WHERE "passwordConfigured" IS TRUE),
'passwordConfiguredFalse', count(*) FILTER (WHERE "passwordConfigured" IS FALSE),
'passwordConfiguredNull', count(*) FILTER (WHERE "passwordConfigured" IS NULL),
'type', (SELECT data_type FROM information_schema.columns
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' AND column_name='passwordConfigured'),
'isLastColumn', (SELECT column_name='passwordConfigured' FROM information_schema.columns
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position DESC LIMIT 1),
'readerCanReadView', has_table_privilege('store_account_reader','external_store_accounts.accounts_v1','SELECT'),
'readerCanReadBase', has_table_privilege('store_account_reader','public."StoreAccount"','SELECT'),
'readerCanReadPassword', has_column_privilege('store_account_reader','public."StoreAccount"','passwordCiphertext','SELECT'),
'readerCanReadAudit', has_table_privilege('store_account_reader','public."StoreAccountPasswordAccessAudit"','SELECT')
) FROM external_store_accounts.accounts_v1;
ROLLBACK;

View File

@ -0,0 +1,88 @@
/** @jest-environment node */
import { randomBytes } from 'node:crypto'
import { POST, GET } from './route'
jest.mock('server-only', () => ({}))
const mockReveal = jest.fn()
jest.mock('@/server/services/store-account-password-reveal.service', () => ({
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ reveal: (...args: unknown[]) => mockReveal(...args) })),
}))
const path = 'https://example.test/internal/store-accounts/test-id/password/reveal'
const context = { params: Promise.resolve({ id: 'test-id' }) }
function request(url = path, body = JSON.stringify({ requesterId: 'u', purpose: 'view' })) {
return new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
}
describe('secret HTTP response boundary', () => {
beforeEach(() => { mockReveal.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
afterAll(() => { delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
it.each([200, 403, 404, 429, 503])('sets no-store for status %i and places no credential in headers', async status => {
const password = randomBytes(24).toString('base64url')
mockReveal.mockResolvedValue(status === 200 ? { status, password } : { status })
const response = await POST(request(), context)
expect(response.status).toBe(status)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(JSON.stringify([...response.headers]).includes(password)).toBe(false)
const json = await response.json()
expect(typeof json.requestId).toBe('string')
expect(json.password === password).toBe(status === 200)
expect('password' in json).toBe(status === 200)
})
it('does not trust spoofed forwarded HTTPS by default', async () => {
mockReveal.mockResolvedValue({ status: 403 })
const req = request(path.replace('https:', 'http:'))
req.headers.set('x-forwarded-proto', 'https')
await POST(req, context)
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
})
it('accepts HTTPS metadata only from an authenticated TLS edge', async () => {
const edgeToken = randomBytes(32).toString('base64url')
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
mockReveal.mockResolvedValue({ status: 403 })
const req = request(path.replace('https:', 'http:'))
req.headers.set('x-forwarded-proto', 'https')
req.headers.set('x-store-reveal-edge-token', edgeToken)
await POST(req, context)
expect(mockReveal.mock.calls[0][0].secure).toBe(true)
})
it('does not treat a forwarded HTTPS URL as proof of a trusted TLS socket', async () => {
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = randomBytes(32).toString('base64url')
mockReveal.mockResolvedValue({ status: 403 })
await POST(request(), context)
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
})
it('rejects a wrong edge credential or an insecure edge assertion', async () => {
const edgeToken = randomBytes(32).toString('base64url')
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
mockReveal.mockResolvedValue({ status: 403 })
const req = request()
req.headers.set('x-forwarded-proto', 'https')
req.headers.set('x-store-reveal-edge-token', randomBytes(32).toString('base64url'))
await POST(req, context)
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
req.headers.set('x-store-reveal-edge-token', edgeToken)
req.headers.set('x-forwarded-proto', 'http')
await POST(req, context)
expect(mockReveal.mock.calls[1][0].secure).toBe(false)
})
it('rejects any query string and does not accept GET as a reveal action', async () => {
mockReveal.mockResolvedValue({ status: 403 })
await POST(request(path + '?purpose=copy'), context)
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
await GET(new Request(path), context)
expect(mockReveal.mock.calls[1][0].post).toBe(false)
})
it.each(['{', 'a'.repeat(1025)])('bounds malformed and oversized request bodies (%#)', async body => {
mockReveal.mockResolvedValue({ status: 403 })
await POST(request(path, body), context)
expect(mockReveal.mock.calls[0][0].body).toBeNull()
})
})

View File

@ -0,0 +1,65 @@
import { randomUUID } from 'node:crypto'
import { StoreAccountPasswordRevealService } from '@/server/services/store-account-password-reveal.service'
import { hasTrustedRevealTransport } from '@/server/services/store-account-password-reveal-transport'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10
const service = new StoreAccountPasswordRevealService()
const errors = {
403: ['FORBIDDEN', '请求被拒绝'],
404: ['PASSWORD_NOT_FOUND', '账号或可用密码不存在'],
429: ['RATE_LIMITED', '请求过于频繁'],
503: ['UNAVAILABLE', '服务暂不可用'],
} as const
async function readBody(request: Request): Promise<unknown> {
if (request.method !== 'POST' || !/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) return null
const reader = request.body?.getReader()
if (!reader) return null
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
void reader.cancel().catch(() => {})
}, 1000)
try {
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > 1024) { await reader.cancel(); return null }
chunks.push(value)
}
return timedOut ? null : JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch { return null } finally { clearTimeout(timeout); reader.releaseLock() }
}
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
const requestId = randomUUID()
const url = new URL(request.url)
const secure = hasTrustedRevealTransport(request.headers)
const result = await service.reveal({
accountId: (await context.params).id, body: await readBody(request),
authorization: request.headers.get('authorization'),
secure: secure && !url.search, post: request.method === 'POST', requestId,
})
const headers = {
'Cache-Control': 'no-store', 'Pragma': 'no-cache',
'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId,
'Strict-Transport-Security': 'max-age=31536000',
}
if (result.status === 200) {
return Response.json({ requestId, password: result.password }, { status: 200, headers })
}
const [code, message] = errors[result.status as keyof typeof errors]
return Response.json({ requestId, error: { code, message } }, { status: result.status, headers })
}
export const GET = POST
export const PUT = POST
export const PATCH = POST
export const DELETE = POST
export const OPTIONS = POST
export const HEAD = POST

View File

@ -0,0 +1,15 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { proxy } from './proxy'
describe('finance endpoint proxy routing', () => {
it('passes the exact endpoint to its service authentication without a login redirect', async () => {
const response = await proxy(new NextRequest('https://example.test/internal/store-accounts/test-id/password/reveal'))
expect(response.headers.get('location')).toBeNull()
expect(response.headers.get('x-middleware-next')).toBe('1')
})
it('keeps other internal account paths behind browser authentication', async () => {
const response = await proxy(new NextRequest('https://example.test/internal/store-accounts/test-id'))
expect(new URL(response.headers.get('location')!).pathname).toBe('/login')
})
})

View File

@ -81,6 +81,12 @@ interface WorkspaceTokenPayload {
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// This exact machine endpoint authenticates its dedicated finance identity itself.
// Never redirect service credentials into the browser login flow.
if (/^\/internal\/store-accounts\/[^/]+\/password\/reveal\/?$/.test(pathname)) {
return NextResponse.next()
}
if (
OA_BOARD_LOCAL_PREVIEW_ENABLED &&
(pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/'))

View File

@ -0,0 +1,80 @@
import 'server-only'
import { Pool, type PoolClient } from 'pg'
import {
REVEAL_LIMITS,
type RevealAudit, type RevealRepository, type RevealTransaction,
} from '@/server/services/store-account-password-reveal.contract'
// Dedicated, uninstrumented pool: neither query results nor driver errors are logged.
// Passwords are SELECT results only, never SQL parameters or database-side plaintext.
let pool: Pool | undefined
function getPool() {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 4, connectionTimeoutMillis: 1000, idleTimeoutMillis: 10000,
statement_timeout: 1500, query_timeout: 2000,
application_name: 'store-password-reveal',
})
pool.on('error', () => { /* Never emit driver diagnostics on this secret path. */ })
}
return pool
}
class Transaction implements RevealTransaction {
constructor(private readonly client: PoolClient) {}
async limited(event: RevealAudit) {
// Serializes the decision AND its audit across all processes/instances.
await this.client.query('SELECT pg_advisory_xact_lock(1937010546, 1)')
const { rows: [counts] } = await this.client.query<{
serviceMinute: number; serviceDay: number; requesterMinute: number; accountMinute: number
}>(`SELECT
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute')::int AS "serviceMinute",
count(*)::int AS "serviceDay",
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "requesterId" = $2)::int AS "requesterMinute",
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "accountId" = $3)::int AS "accountMinute"
FROM public."StoreAccountPasswordAccessAudit"
WHERE "serviceId" = $1 AND status <> 429 AND "createdAt" > clock_timestamp() - interval '24 hours'`,
[event.serviceId, event.requesterId, event.accountId])
return (Object.keys(REVEAL_LIMITS) as Array<keyof typeof REVEAL_LIMITS>)
.some(key => counts[key] >= REVEAL_LIMITS[key])
}
async findPassword(accountId: string) {
const { rows } = await this.client.query<{ passwordCiphertext: string }>(
'SELECT "passwordCiphertext" FROM public."StoreAccount" WHERE id = $1', [accountId],
)
return rows[0]?.passwordCiphertext || null
}
async audit(event: RevealAudit) {
// Explicit allowlist: never spread request, response, account record or exception.
await this.client.query(`INSERT INTO public."StoreAccountPasswordAccessAudit"
("requestId", "requesterId", "serviceId", "accountId", purpose, status)
VALUES ($1, $2, $3, $4, $5, $6)`,
[event.requestId, event.requesterId, event.serviceId, event.accountId, event.purpose, event.status])
}
}
export class StoreAccountPasswordRevealRepository implements RevealRepository {
constructor(private readonly poolFactory: () => Pool = getPool) {}
async transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T> {
const client = await this.poolFactory().connect()
let broken = false
try {
await client.query('BEGIN')
await client.query("SET LOCAL lock_timeout = '1000ms'")
const result = await run(new Transaction(client))
await client.query('COMMIT')
return result
} catch {
try { await client.query('ROLLBACK') } catch { broken = true }
// No error cause: it might contain driver parameters or a crypto value.
throw new Error('PASSWORD_REVEAL_UNAVAILABLE')
} finally {
client.release(broken)
}
}
}

View File

@ -0,0 +1,16 @@
import 'server-only'
import { createHash, timingSafeEqual } from 'node:crypto'
// Next.js can construct Request.url from forwarded headers, so its URL protocol
// cannot prove socket TLS. Only the trusted TLS edge may attest the transport.
// The financial service must never receive this independent edge credential.
export function hasTrustedRevealTransport(headers: Headers) {
const expected = process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN
const received = headers.get('x-store-reveal-edge-token')
if (expected === process.env.STORE_ACCOUNT_FINANCE_TOKEN ||
expected === process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS) return false
if (!expected || !received || !/^[A-Za-z0-9_-]{43,128}$/.test(expected) ||
!/^[A-Za-z0-9_-]{43,128}$/.test(received) || headers.get('x-forwarded-proto') !== 'https') return false
const digest = (value: string) => createHash('sha256').update(value).digest()
return timingSafeEqual(digest(expected), digest(received))
}

View File

@ -0,0 +1,29 @@
import { z } from 'zod'
export const revealInputSchema = z.object({
requesterId: z.string().regex(/^[A-Za-z0-9_.:@-]{1,128}$/),
purpose: z.enum(['view', 'copy']),
}).strict()
export const accountIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)
export const FINANCE_SERVICE_ID = 'finance-store-accounts'
export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const
export type RevealInput = z.infer<typeof revealInputSchema>
export type RevealStatus = 200 | 403 | 404 | 429 | 503
export type RevealAudit = {
requestId: string
requesterId: string | null
serviceId: string | null
accountId: string | null
purpose: 'view' | 'copy' | 'unknown'
status: RevealStatus
}
export type RevealResult = { status: RevealStatus; password?: string }
export interface RevealTransaction {
limited(audit: RevealAudit): Promise<boolean>
findPassword(accountId: string): Promise<string | null>
audit(event: RevealAudit): Promise<void>
}
export interface RevealRepository {
transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T>
}

View File

@ -0,0 +1,111 @@
/** @jest-environment node */
import { randomBytes, randomUUID } from 'node:crypto'
import { encryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { StoreAccountPasswordRevealService } from './store-account-password-reveal.service'
import type { RevealAudit, RevealTransaction } from './store-account-password-reveal.contract'
jest.mock('server-only', () => ({}))
describe('finance password reveal security boundary', () => {
const token = randomBytes(32).toString('base64url')
const syntheticPassword = randomBytes(24).toString('base64url')
let tx: jest.Mocked<RevealTransaction>
let events: RevealAudit[]
let service: StoreAccountPasswordRevealService
const request = (overrides = {}) => ({
accountId: 'synthetic-account', body: { requesterId: 'finance-user-1', purpose: 'view' },
authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), ...overrides,
})
beforeEach(() => {
process.env.STORE_ACCOUNT_ENCRYPTION_KEY = randomBytes(32).toString('base64url')
process.env.STORE_ACCOUNT_FINANCE_TOKEN = token
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
events = []
tx = {
limited: jest.fn().mockResolvedValue(false),
findPassword: jest.fn().mockResolvedValue(encryptStoreAccountPassword(syntheticPassword)),
audit: jest.fn(async event => { events.push(event) }),
}
service = new StoreAccountPasswordRevealService({ transaction: run => run(tx) })
})
afterAll(() => {
delete process.env.STORE_ACCOUNT_ENCRYPTION_KEY
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
})
it.each(['view', 'copy'])('decrypts a single synthetic account for %s and audits only metadata', async purpose => {
const result = await service.reveal(request({ body: { requesterId: 'finance-user-1', purpose } }))
expect(result.status).toBe(200)
// Boolean assertion prevents even a failed test diff from printing secrets.
expect(result.password === syntheticPassword).toBe(true)
expect(tx.findPassword).toHaveBeenCalledWith('synthetic-account')
expect(events).toHaveLength(1)
expect(Object.keys(events[0]).sort()).toEqual(['accountId', 'purpose', 'requestId', 'requesterId', 'serviceId', 'status'])
expect(events[0].serviceId).toBe('finance-store-accounts')
expect(events[0].status).toBe(200)
expect(JSON.stringify(events).includes(syntheticPassword)).toBe(false)
})
it.each([
{ authorization: null }, { authorization: 'Bearer user-session' }, { secure: false }, { post: false },
{ accountId: ['a', 'b'] }, { accountId: 'a,b' }, { accountId: '../a' },
{ body: { requesterId: 'u', purpose: 'export' } },
{ body: { requesterId: 'u', purpose: 'view', ids: ['a', 'b'] } },
{ body: { purpose: 'view' } }, { body: null },
])('rejects malformed, bulk, insecure or unauthorized input without selecting a credential (%#)', async overrides => {
const result = await service.reveal(request(overrides))
expect(result.status).toBe(403)
expect(result.password === undefined).toBe(true)
expect(tx.findPassword).not.toHaveBeenCalled()
expect(events[0].status).toBe(403)
})
it('fails closed when credentials are unconfigured', async () => {
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
expect((await service.reveal(request())).status).toBe(403)
expect(tx.findPassword).not.toHaveBeenCalled()
})
it('accepts the previous dedicated token only during rotation', async () => {
process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS = token
process.env.STORE_ACCOUNT_FINANCE_TOKEN = randomBytes(32).toString('base64url')
expect((await service.reveal(request())).status).toBe(200)
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
expect((await service.reveal(request())).status).toBe(403)
})
it.each([null, ''])('returns the same 404 for missing or empty passwords', async value => {
tx.findPassword.mockResolvedValue(value)
expect((await service.reveal(request())).status).toBe(404)
expect(events[0].status).toBe(404)
})
it('returns 429 before reading or decrypting when over the limit', async () => {
tx.limited.mockResolvedValue(true)
expect((await service.reveal(request())).status).toBe(429)
expect(tx.findPassword).not.toHaveBeenCalled()
expect(events[0].status).toBe(429)
})
it('suppresses sensitive exceptions and fails closed when audit is unavailable', async () => {
const logs = ['log', 'warn', 'error', 'info', 'debug'].map(method =>
jest.spyOn(console, method as 'log').mockImplementation(() => {}))
try {
tx.audit.mockRejectedValue(new Error(syntheticPassword))
const result = await service.reveal(request())
expect(result.status).toBe(503)
expect(result.password === undefined).toBe(true)
expect(logs.every(log => log.mock.calls.length === 0)).toBe(true)
} finally { logs.forEach(log => log.mockRestore()) }
})
it('records decryption failure without including the exception or credential', async () => {
tx.findPassword.mockResolvedValue('invalid-envelope')
const result = await service.reveal(request())
expect(result.status).toBe(503)
expect(events[0].status).toBe(503)
expect(JSON.stringify(events).includes('invalid-envelope')).toBe(false)
})
})

View File

@ -0,0 +1,71 @@
import 'server-only'
import { createHash, timingSafeEqual } from 'node:crypto'
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository'
import {
accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema,
type RevealAudit, type RevealRepository, type RevealResult,
} from './store-account-password-reveal.contract'
function authenticate(authorization: string | null) {
const received = authorization?.match(/^Bearer ([A-Za-z0-9_-]{43,128})$/)?.[1]
const primary = process.env.STORE_ACCOUNT_FINANCE_TOKEN
if (!received || !primary || !/^[A-Za-z0-9_-]{43,128}$/.test(primary)) return false
const digest = (s: string) => createHash('sha256').update(s).digest()
const candidate = digest(received)
// Only these dedicated credentials map to this identity. Session/JWT/DB tokens do not.
const tokens = [primary, process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS]
let matched = false
for (const token of tokens) {
if (token && /^[A-Za-z0-9_-]{43,128}$/.test(token)) {
matched = timingSafeEqual(candidate, digest(token)) || matched
}
}
return matched
}
export class StoreAccountPasswordRevealService {
constructor(
private readonly repository: RevealRepository = new StoreAccountPasswordRevealRepository(),
private readonly decrypt: (value: string) => string = decryptStoreAccountPassword,
) {}
async reveal(input: {
accountId: unknown; body: unknown; authorization: string | null
secure: boolean; post: boolean; requestId: string
}): Promise<RevealResult> {
const account = accountIdSchema.safeParse(input.accountId)
const body = revealInputSchema.safeParse(input.body)
const authenticated = authenticate(input.authorization)
const event: RevealAudit = {
requestId: input.requestId,
requesterId: body.success ? body.data.requesterId : null,
serviceId: authenticated ? FINANCE_SERVICE_ID : null,
accountId: account.success ? account.data : null,
purpose: body.success ? body.data.purpose : 'unknown', status: 403,
}
try {
return await this.repository.transaction(async tx => {
let result: RevealResult = { status: 403 }
if (authenticated && await tx.limited(event)) {
result = { status: 429 }
} else if (authenticated && input.secure && input.post && account.success && body.success) {
const encrypted = await tx.findPassword(account.data)
if (!encrypted) result = { status: 404 }
else {
try {
// Audit commits before a plaintext response can leave this service.
const password = this.decrypt(encrypted)
result = password ? { status: 200, password } : { status: 404 }
} catch { result = { status: 503 } }
}
}
await tx.audit({ ...event, status: result.status })
return result
})
} catch {
// Failure to persist audit/limit is fail-closed. Never emit the underlying error.
return { status: 503 }
}
}
}

View File

@ -0,0 +1,227 @@
/** @jest-environment node */
import { randomBytes, randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { Pool } from 'pg'
import { encryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository'
import type { StoreAccountPasswordRevealService as ServiceType } from '@/server/services/store-account-password-reveal.service'
import { POST } from '@/app/internal/store-accounts/[id]/password/reveal/route'
jest.mock('server-only', () => ({}))
let mockService: ServiceType
jest.mock('@/server/services/store-account-password-reveal.service', () => ({
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({
reveal: (...args: Parameters<ServiceType['reveal']>) => mockService.reveal(...args),
})),
}))
// Explicit opt-in, loopback only. Creates and removes only its own random fixture DB/role.
const adminUrl = process.env.STORE_REVEAL_TEST_ADMIN_URL
const integration = adminUrl ? describe : describe.skip
integration('finance reveal with real PostgreSQL, migration and crypto', () => {
const suffix = randomBytes(8).toString('hex')
const dbName = `store_reveal_test_${suffix}`
const readerName = `store_reveal_reader_${suffix}`
const password = randomBytes(24).toString('base64url')
const token = randomBytes(32).toString('base64url')
const edgeToken = randomBytes(32).toString('base64url')
const startedAt = new Date().toISOString()
let admin: Pool
let db: Pool
let initialRows: string
let initialColumns: string[]
let databaseCreated = false
let roleCreated = false
let sqlContainsPassword = false
let consoleContainsPassword = false
let logSpies: jest.SpyInstance[] = []
async function call(id = 'synthetic-1', actor = 'finance-user', authorization: string | null = `Bearer ${token}`) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Forwarded-Proto': 'https', 'X-Store-Reveal-Edge-Token': edgeToken,
}
if (authorization) headers.Authorization = authorization
return POST(new Request(`https://example.test/internal/store-accounts/${id}/password/reveal`, {
method: 'POST', headers, body: JSON.stringify({ requesterId: actor, purpose: 'view' }),
}), { params: Promise.resolve({ id }) })
}
beforeAll(async () => {
const target = new URL(adminUrl!)
if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) throw new Error('Local test database required')
admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 2000 })
await admin.query(`CREATE DATABASE "${dbName}"`)
databaseCreated = true
await admin.query(`CREATE ROLE "${readerName}" NOLOGIN`)
roleCreated = true
target.pathname = `/${dbName}`
db = new Pool({ connectionString: target.toString(), max: 8,
// Enable actual server statement logs in this isolated test session only.
// Never log bind values, even on errors.
options: '-c log_statement=all -c log_parameter_max_length=0 -c log_parameter_max_length_on_error=0 -c log_min_error_statement=panic',
statement_timeout: 3000, connectionTimeoutMillis: 2000,
})
db.on('connect', client => {
const original = client.query.bind(client)
// Observe SQL sent to PostgreSQL in memory, output a boolean only.
client.query = ((...args: unknown[]) => {
sqlContainsPassword ||= JSON.stringify(args).includes(password)
return (original as (...args: unknown[]) => unknown)(...args)
}) as typeof client.query
})
logSpies = ['log', 'warn', 'error', 'info', 'debug'].map(method =>
jest.spyOn(console, method as 'log').mockImplementation((...args: unknown[]) => {
consoleContainsPassword ||= args.some(arg => String(arg).includes(password))
}))
process.env.STORE_ACCOUNT_ENCRYPTION_KEY = randomBytes(32).toString('base64url')
process.env.STORE_ACCOUNT_FINANCE_TOKEN = token
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
await db.query(`CREATE TABLE public."StoreAccount" (id TEXT PRIMARY KEY, "storeName" TEXT,
"passwordCiphertext" TEXT NOT NULL DEFAULT '', visible BOOLEAN NOT NULL);
CREATE SCHEMA external_store_accounts;
CREATE VIEW external_store_accounts.accounts_v1 AS
SELECT id, "storeName" FROM public."StoreAccount" WHERE visible;
GRANT USAGE ON SCHEMA external_store_accounts TO "${readerName}";
GRANT SELECT ON external_store_accounts.accounts_v1 TO "${readerName}";`)
await db.query(`INSERT INTO public."StoreAccount"
SELECT 'synthetic-' || n, 'Synthetic store ' || n,
CASE WHEN n <= 957 THEN $1 ELSE '' END, n <= 958 FROM generate_series(1,960) n`,
[encryptStoreAccountPassword(password)])
initialRows = JSON.stringify((await db.query('SELECT * FROM external_store_accounts.accounts_v1 ORDER BY id')).rows)
initialColumns = (await db.query(`SELECT column_name FROM information_schema.columns
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position`)).rows.map(r => r.column_name)
await db.query(readFileSync(resolve('prisma/migrations/20260908120000_store_account_password_reveal/migration.sql'), 'utf8'))
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
mockService = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
}, 20000)
beforeEach(async () => { await db.query('TRUNCATE public."StoreAccountPasswordAccessAudit"') })
afterAll(async () => {
logSpies.forEach(spy => spy.mockRestore())
delete process.env.STORE_ACCOUNT_ENCRYPTION_KEY
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN
if (db) await db.end()
if (admin) {
// Names are generated above, never accepted from env or user input.
if (databaseCreated) await admin.query(`DROP DATABASE "${dbName}"`)
if (roleCreated) await admin.query(`DROP ROLE "${readerName}"`)
await admin.end()
}
})
it('preserves every original field and filter with 958 unique rows and a final boolean (957/1/0)', async () => {
const { rows: [stats] } = await db.query(`SELECT count(*)::int AS total, count(DISTINCT id)::int AS unique,
count(*) FILTER (WHERE "passwordConfigured" IS TRUE)::int AS yes,
count(*) FILTER (WHERE "passwordConfigured" IS FALSE)::int AS no,
count(*) FILTER (WHERE "passwordConfigured" IS NULL)::int AS unknown
FROM external_store_accounts.accounts_v1`)
expect(stats).toEqual({ total: 958, unique: 958, yes: 957, no: 1, unknown: 0 })
const { rows: columns } = await db.query(`SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position`)
expect(columns.map(c => c.column_name)).toEqual([...initialColumns, 'passwordConfigured'])
expect(columns.at(-1)?.data_type).toBe('boolean')
expect(JSON.stringify((await db.query('SELECT id, "storeName" FROM external_store_accounts.accounts_v1 ORDER BY id')).rows) === initialRows).toBe(true)
})
it('returns correct synthetic plaintext through the HTTP handler only after audit commits', async () => {
const response = await call()
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(body.password === password).toBe(true)
const { rows } = await db.query('SELECT * FROM public."StoreAccountPasswordAccessAudit" WHERE "requestId"=$1', [body.requestId])
expect(rows.length).toBe(1)
expect(rows[0].status).toBe(200)
expect(rows[0].requesterId).toBe('finance-user')
expect(rows[0].serviceId).toBe('finance-store-accounts')
expect(rows[0].accountId).toBe('synthetic-1')
expect(rows[0].purpose).toBe('view')
expect(rows[0].createdAt instanceof Date).toBe(true)
expect(JSON.stringify(rows).includes(password)).toBe(false)
})
it('persists 403 and 404 attempts and uses no-store on both', async () => {
const unauthorized = await call('synthetic-1', 'u', null)
const missing = await call('does-not-exist')
const empty = await call('synthetic-958')
expect([unauthorized.status, missing.status, empty.status]).toEqual([403, 404, 404])
expect([unauthorized, missing, empty].every(r => r.headers.get('cache-control') === 'no-store')).toBe(true)
const { rows } = await db.query('SELECT status FROM public."StoreAccountPasswordAccessAudit" ORDER BY "createdAt"')
expect(rows.map(r => r.status)).toEqual([403, 404, 404])
})
it('enforces account limits atomically across concurrent requests and service instances', async () => {
const responses = await Promise.all(Array.from({ length: 8 }, (_, n) => call('synthetic-1', `actor-${n}`)))
expect(responses.filter(r => r.status === 200).length).toBe(5)
expect(responses.filter(r => r.status === 429).length).toBe(3)
expect(responses.every(r => r.headers.get('cache-control') === 'no-store')).toBe(true)
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
const restarted = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
const result = await restarted.reveal({ accountId: 'synthetic-1', body: { requesterId: 'new-actor', purpose: 'copy' },
authorization: `Bearer ${token}`, post: true, secure: true, requestId: randomUUID() })
expect(result.status).toBe(429)
})
it('enforces requester limits even when the caller changes accounts', async () => {
const responses = []
for (let n = 1; n <= 11; n++) responses.push(await call(`synthetic-${n}`))
expect(responses.filter(r => r.status === 200).length).toBe(10)
expect(responses[10].status).toBe(429)
})
it.each([{ count: 30, age: '0 seconds' }, { count: 300, age: '2 hours' }])('enforces global service limits (%#)', async ({ count, age }) => {
for (let n = 0; n < count; n++) {
await db.query(`INSERT INTO public."StoreAccountPasswordAccessAudit"
("requestId", "serviceId", purpose, status, "createdAt") VALUES ($1,'finance-store-accounts','view',404,clock_timestamp()-$2::interval)`,
[randomUUID(), age])
}
expect((await call('synthetic-10', 'new-user')).status).toBe(429)
})
it('returns 503 without plaintext when audit persistence fails', async () => {
await db.query(`ALTER TABLE public."StoreAccountPasswordAccessAudit" ADD CONSTRAINT test_reject_success CHECK(status <> 200)`)
try {
const response = await call()
expect(response.status).toBe(503)
expect((await response.text()).includes(password)).toBe(false)
} finally { await db.query('ALTER TABLE public."StoreAccountPasswordAccessAudit" DROP CONSTRAINT test_reject_success') }
})
it('does not widen the ordinary reader ACL to the base table, password or audit', async () => {
const client = await db.connect()
try {
await client.query(`SET ROLE "${readerName}"`)
expect((await client.query('SELECT count(*)::int AS total FROM external_store_accounts.accounts_v1')).rows[0].total).toBe(958)
for (const sql of [
'SELECT * FROM public."StoreAccount" LIMIT 0',
'SELECT "passwordCiphertext" FROM public."StoreAccount" LIMIT 0',
'SELECT * FROM public."StoreAccountPasswordAccessAudit" LIMIT 0',
]) {
let denied = false
try { await client.query(sql) } catch (error) { denied = (error as { code: string }).code === '42501' }
expect(denied).toBe(true)
}
} finally { await client.query('RESET ROLE'); client.release() }
})
it('contains no synthetic plaintext in application, audit or actual PostgreSQL query logs', async () => {
await call()
expect(sqlContainsPassword).toBe(false)
expect(consoleContainsPassword).toBe(false)
expect(JSON.stringify((await db.query('SELECT * FROM public."StoreAccountPasswordAccessAudit"')).rows).includes(password)).toBe(false)
// Optional in CI; required for local acceptance when configured. Read output
// only into memory and assert booleans so logs and secrets cannot enter reports.
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
const logs = spawnSync('docker', ['logs', '--since', startedAt, process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
const output = String(logs.stdout) + String(logs.stderr)
expect(logs.status === 0).toBe(true)
expect(output.includes('StoreAccountPasswordAccessAudit')).toBe(true)
expect(output.includes(password)).toBe(false)
}
})
})