fix(store-accounts): 按明文存储规则修正密码状态 #21
@ -1,14 +1,12 @@
|
||||
# 财务系统店铺密码直接显示契约
|
||||
|
||||
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
|
||||
|
||||
> 2026-09-08 存储变更:按管理层决定,店铺账号密码/银行卡号取消数据库加密,改为明文落库;存量 `v1:` 密文已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。本契约的读取链路兼容明文与历史密文,认证、审计、限流要求不变;下文"存储事实与授权边界"中的加密描述保留为历史设计记录。
|
||||
状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 `passwordConfigured`。
|
||||
|
||||
## 存储事实与授权边界
|
||||
|
||||
- 密码真相源是 `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。过程只输出聚合,不导出密码或密文。
|
||||
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史 `v1:` AES-256-GCM 值保留兼容。
|
||||
- 存量 `v1:` 值已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。`STORE_ACCOUNT_ENCRYPTION_KEY` 仅用于可能残留或恢复出的历史 `v1:` 值,不参与当前明文值读取。
|
||||
- `passwordConfigured` 只表示存储值是否非空,不校验字符集或密码强度;因此包含 `+`、`/`、`=` 等字符的密码也会正确返回 true。它不返回密码本身。
|
||||
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
|
||||
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
|
||||
- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
|
||||
@ -91,7 +89,7 @@ Content-Type: application/json
|
||||
|
||||
| 名称 | 所在系统与要求 |
|
||||
|---|---|
|
||||
| STORE_ACCOUNT_ENCRYPTION_KEY | 上游已有独立加密密钥,至少 32 字符;生产不回退到 JWT_SECRET |
|
||||
| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 `v1:` 密文,至少 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 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
|
||||
@ -108,25 +106,25 @@ Content-Type: application/json
|
||||
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 结果或参数追踪。
|
||||
5. 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。
|
||||
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
|
||||
|
||||
## 视图与审计迁移
|
||||
|
||||
迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。
|
||||
初始迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。明文状态修复:`prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql`。
|
||||
|
||||
- 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。
|
||||
- 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。
|
||||
- `passwordConfigured` 表示存在格式完整的、非空 AES-GCM 密码封装。SQL 不接触密钥,所以不在每次列表查询时验证 GCM 标签/密钥健康;生产预检必须做主机内存中的解密聚合验证。密钥丢失或密文损坏属于 503 运维故障,不能把它伪装成未设置密码。
|
||||
- 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。
|
||||
- `passwordConfigured` 的当前定义为 `passwordCiphertext <> ''`。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。
|
||||
- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。
|
||||
|
||||
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
|
||||
|
||||
备份与执行顺序(生产必须在明确批准后执行):
|
||||
|
||||
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
|
||||
2. 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。
|
||||
3. 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。
|
||||
2. 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。
|
||||
3. 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。
|
||||
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
|
||||
5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
|
||||
|
||||
@ -138,7 +136,7 @@ Content-Type: application/json
|
||||
|
||||
网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
|
||||
|
||||
数据加密密钥:现有 `v1:` 不含 key ID,不能直接替换环境变量。必须另行批准维护窗口,暂停所有店铺密码及银行账号的写入/读取,备份对应表和旧密钥;用受控迁移进程在内存中旧钥解密、新钥重新 AES-GCM 加密,并在同一数据库事务中更新两类密文字段(SQL 永不接收明文)。验证全量解密聚合、行数及虚构账号后,协调所有应用实例切换新环境密钥,再恢复流量;失败时同时恢复密文备份和旧钥。关闭 SQL 参数/异常参数、APM及备份内容输出;本 PR 不执行此轮换,也不引入第二套密钥或把新旧密钥存入数据库。需要无停机轮换时,应另行实现带 key ID 的 Envelope Encryption/KMS 版本迁移。
|
||||
历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 `v1:` 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。
|
||||
|
||||
## 本次验证记录
|
||||
|
||||
@ -148,8 +146,8 @@ Content-Type: application/json
|
||||
- 隔离本地 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` 尚未落库,生产接口尚未发布,因此没有生产新增字段/新接口的验收结论。
|
||||
- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。
|
||||
|
||||
运行:`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拓扑/网关配置及发布后真实验收。
|
||||
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '30s';
|
||||
|
||||
DO $preflight$
|
||||
DECLARE
|
||||
actual_columns TEXT[];
|
||||
expected_columns CONSTANT TEXT[] := ARRAY[
|
||||
'id', 'storeName', 'operationOwner', 'operationDepartmentHead',
|
||||
'operationDirector', 'profitCenter', 'subject', 'accountType', 'account',
|
||||
'boundPhone', 'verificationContact', 'jstOrderService', 'accountGroupName',
|
||||
'createdAt', 'updatedAt', 'passwordConfigured'
|
||||
];
|
||||
BEGIN
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||
INTO actual_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'external_store_accounts'
|
||||
AND table_name = 'accounts_v1';
|
||||
|
||||
IF actual_columns IS DISTINCT FROM expected_columns THEN
|
||||
RAISE EXCEPTION 'External account contract needs preflight review';
|
||||
END IF;
|
||||
END $preflight$;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
original_owner OID;
|
||||
original_acl ACLITEM[];
|
||||
BEGIN
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT relowner, relacl
|
||||
INTO original_owner, original_acl
|
||||
FROM pg_class
|
||||
WHERE oid = 'external_store_accounts.accounts_v1'::regclass;
|
||||
|
||||
CREATE TEMP TABLE store_account_view_rows_before ON COMMIT DROP AS
|
||||
SELECT to_jsonb(account_row) - 'passwordConfigured' AS row_value
|
||||
FROM external_store_accounts.accounts_v1 account_row;
|
||||
|
||||
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
|
||||
SELECT
|
||||
source.id,
|
||||
source."storeName",
|
||||
source."operationOwner",
|
||||
source."operationDepartmentHead",
|
||||
source."operationDirector",
|
||||
source."profitCenter",
|
||||
source.subject,
|
||||
source."accountType",
|
||||
source.account,
|
||||
source."boundPhone",
|
||||
source."verificationContact",
|
||||
source."jstOrderService",
|
||||
source."accountGroupName",
|
||||
source."createdAt",
|
||||
source."updatedAt",
|
||||
(source."passwordCiphertext" <> '')::boolean AS "passwordConfigured"
|
||||
FROM public."StoreAccount" source;
|
||||
|
||||
IF EXISTS (
|
||||
(SELECT row_value FROM store_account_view_rows_before
|
||||
EXCEPT ALL
|
||||
SELECT to_jsonb(account_row) - 'passwordConfigured'
|
||||
FROM external_store_accounts.accounts_v1 account_row)
|
||||
UNION ALL
|
||||
(SELECT to_jsonb(account_row) - 'passwordConfigured'
|
||||
FROM external_store_accounts.accounts_v1 account_row
|
||||
EXCEPT ALL
|
||||
SELECT row_value FROM store_account_view_rows_before)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'External account rows changed while fixing password status';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class
|
||||
WHERE oid = 'external_store_accounts.accounts_v1'::regclass
|
||||
AND (relowner <> original_owner OR relacl IS DISTINCT FROM original_acl)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'External account owner or ACL changed';
|
||||
END IF;
|
||||
END $migration$;
|
||||
|
||||
COMMIT;
|
||||
@ -20,11 +20,11 @@ jest.mock('@/server/services/store-account-password-reveal.service', () => ({
|
||||
// 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', () => {
|
||||
integration('finance reveal with real PostgreSQL and plaintext storage', () => {
|
||||
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 password = `synthetic-${randomBytes(18).toString('base64')}=+`
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const edgeToken = randomBytes(32).toString('base64url')
|
||||
const startedAt = new Date().toISOString()
|
||||
@ -52,17 +52,34 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
|
||||
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}"`)
|
||||
admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 10000 })
|
||||
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
|
||||
const created = spawnSync('docker', [
|
||||
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
|
||||
'createdb', '-U', decodeURIComponent(target.username), dbName,
|
||||
], { encoding: 'utf8' })
|
||||
if (created.status !== 0) throw new Error('Failed to create isolated local test database')
|
||||
} else {
|
||||
await admin.query(`CREATE DATABASE "${dbName}"`)
|
||||
}
|
||||
databaseCreated = true
|
||||
await admin.query(`CREATE ROLE "${readerName}" NOLOGIN`)
|
||||
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
|
||||
const createdRole = spawnSync('docker', [
|
||||
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
|
||||
'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1),
|
||||
'-v', 'ON_ERROR_STOP=1', '-c', `CREATE ROLE "${readerName}" NOLOGIN`,
|
||||
], { encoding: 'utf8' })
|
||||
if (createdRole.status !== 0) throw new Error('Failed to create isolated local test role')
|
||||
} else {
|
||||
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,
|
||||
statement_timeout: 3000, connectionTimeoutMillis: 10000,
|
||||
})
|
||||
db.on('connect', client => {
|
||||
const original = client.query.bind(client)
|
||||
@ -79,23 +96,38 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
|
||||
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);
|
||||
await db.query(`CREATE TABLE public."StoreAccount" (
|
||||
id TEXT PRIMARY KEY, "storeName" TEXT,
|
||||
"operationOwner" TEXT NOT NULL DEFAULT '', "operationDepartmentHead" TEXT NOT NULL DEFAULT '',
|
||||
"operationDirector" TEXT NOT NULL DEFAULT '', "profitCenter" TEXT NOT NULL DEFAULT '',
|
||||
subject TEXT NOT NULL DEFAULT '', "accountType" TEXT NOT NULL DEFAULT '', account TEXT NOT NULL DEFAULT '',
|
||||
"passwordCiphertext" TEXT NOT NULL DEFAULT '', "boundPhone" TEXT NOT NULL DEFAULT '',
|
||||
"verificationContact" TEXT NOT NULL DEFAULT '', "jstOrderService" TEXT NOT NULL DEFAULT '',
|
||||
"accountGroupName" TEXT NOT NULL DEFAULT '', "createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(), 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;
|
||||
SELECT id, "storeName", "operationOwner", "operationDepartmentHead", "operationDirector",
|
||||
"profitCenter", subject, "accountType", account, "boundPhone", "verificationContact",
|
||||
"jstOrderService", "accountGroupName", "createdAt", "updatedAt"
|
||||
FROM public."StoreAccount";
|
||||
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"
|
||||
await db.query(`INSERT INTO public."StoreAccount" (id, "storeName", "passwordCiphertext", visible)
|
||||
SELECT 'synthetic-' || n, 'Synthetic store ' || n,
|
||||
CASE WHEN n <= 957 THEN $1 ELSE '' END, n <= 958 FROM generate_series(1,960) n`,
|
||||
CASE WHEN n <= 957 THEN $1 ELSE '' END, true FROM generate_series(1,958) n`,
|
||||
[encryptStoreAccountPassword(password)])
|
||||
initialRows = JSON.stringify((await db.query('SELECT * FROM external_store_accounts.accounts_v1 ORDER BY id')).rows)
|
||||
initialRows = JSON.stringify((await db.query('SELECT id, "storeName" 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'))
|
||||
await db.query(readFileSync(resolve('prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql'), 'utf8'))
|
||||
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
|
||||
mockService = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
|
||||
// Fixture creation necessarily binds the synthetic plaintext under the current
|
||||
// storage rule. The acceptance window begins after setup and covers reveal reads.
|
||||
sqlContainsPassword = false
|
||||
consoleContainsPassword = false
|
||||
}, 20000)
|
||||
|
||||
beforeEach(async () => { await db.query('TRUNCATE public."StoreAccountPasswordAccessAudit"') })
|
||||
@ -108,8 +140,27 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
|
||||
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}"`)
|
||||
if (databaseCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
|
||||
const target = new URL(adminUrl!)
|
||||
const dropped = spawnSync('docker', [
|
||||
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
|
||||
'dropdb', '-U', decodeURIComponent(target.username), dbName,
|
||||
], { encoding: 'utf8' })
|
||||
if (dropped.status !== 0) throw new Error('Failed to remove isolated local test database')
|
||||
} else if (databaseCreated) {
|
||||
await admin.query(`DROP DATABASE "${dbName}"`)
|
||||
}
|
||||
if (roleCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
|
||||
const target = new URL(adminUrl!)
|
||||
const droppedRole = spawnSync('docker', [
|
||||
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
|
||||
'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1),
|
||||
'-v', 'ON_ERROR_STOP=1', '-c', `DROP ROLE "${readerName}"`,
|
||||
], { encoding: 'utf8' })
|
||||
if (droppedRole.status !== 0) throw new Error('Failed to remove isolated local test role')
|
||||
} else if (roleCreated) {
|
||||
await admin.query(`DROP ROLE "${readerName}"`)
|
||||
}
|
||||
await admin.end()
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user