@verdeect/kit 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.md +804 -2
- package/alerts/verdeect-kit.rules.yml +133 -0
- package/dist/cli/commands/crypto.d.ts +9 -0
- package/dist/cli/commands/crypto.js +87 -0
- package/dist/cli/commands/db.d.ts +11 -0
- package/dist/cli/commands/db.js +151 -0
- package/dist/cli/commands/openapi.d.ts +9 -0
- package/dist/cli/commands/openapi.js +144 -0
- package/dist/cli/commands/rabbitmq.d.ts +11 -0
- package/dist/cli/commands/rabbitmq.js +124 -0
- package/dist/cli/commands/redis.d.ts +11 -0
- package/dist/cli/commands/redis.js +119 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +31 -0
- package/dist/config/cli-schemas.d.ts +29 -0
- package/dist/config/cli-schemas.js +15 -0
- package/dist/config/define-env.d.ts +21 -0
- package/dist/config/define-env.js +43 -0
- package/dist/config/fragments.d.ts +105 -0
- package/dist/config/fragments.js +86 -0
- package/dist/config/helpers.d.ts +26 -0
- package/dist/config/helpers.js +62 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +7 -0
- package/dist/config/kit-config.module.d.ts +20 -0
- package/dist/config/kit-config.module.js +23 -0
- package/dist/config/parse-env.d.ts +14 -0
- package/dist/config/parse-env.js +28 -0
- package/dist/crypto/cipher.d.ts +61 -0
- package/dist/crypto/cipher.js +136 -0
- package/dist/crypto/index.d.ts +2 -0
- package/dist/crypto/index.js +2 -0
- package/dist/database/batches.d.ts +42 -0
- package/dist/database/batches.js +49 -0
- package/dist/database/db.d.ts +93 -0
- package/dist/database/db.js +234 -0
- package/dist/database/if-newer.d.ts +9 -0
- package/dist/database/if-newer.js +15 -0
- package/dist/database/index.d.ts +17 -0
- package/dist/database/index.js +13 -0
- package/dist/database/kit-database.module.d.ts +47 -0
- package/dist/database/kit-database.module.js +181 -0
- package/dist/database/migrations.d.ts +42 -0
- package/dist/database/migrations.js +142 -0
- package/dist/database/pg.d.ts +20 -0
- package/dist/database/pg.js +19 -0
- package/dist/database/prepare-database.d.ts +39 -0
- package/dist/database/prepare-database.js +75 -0
- package/dist/database/run-exclusive.d.ts +19 -0
- package/dist/database/run-exclusive.js +62 -0
- package/dist/database/tenant.d.ts +34 -0
- package/dist/database/tenant.js +28 -0
- package/dist/http/api/api.decorator.d.ts +18 -0
- package/dist/http/api/api.decorator.js +9 -0
- package/dist/http/api/openapi-document.d.ts +40 -0
- package/dist/http/api/openapi-document.js +452 -0
- package/dist/http/bootstrap-service.d.ts +33 -0
- package/dist/http/bootstrap-service.js +107 -0
- package/dist/http/create-http-app.d.ts +38 -0
- package/dist/http/create-http-app.js +137 -0
- package/dist/http/errors.d.ts +118 -0
- package/dist/http/errors.js +94 -0
- package/dist/http/index.d.ts +12 -0
- package/dist/http/index.js +8 -0
- package/dist/http/kit-exception.filter.d.ts +18 -0
- package/dist/http/kit-exception.filter.js +151 -0
- package/dist/http/kit-http-core.module.d.ts +19 -0
- package/dist/http/kit-http-core.module.js +122 -0
- package/dist/http/pagination.d.ts +30 -0
- package/dist/http/pagination.js +73 -0
- package/dist/http/zod-validation.pipe.d.ts +15 -0
- package/dist/http/zod-validation.pipe.js +47 -0
- package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
- package/dist/idempotency/idempotency.interceptor.js +317 -0
- package/dist/idempotency/idempotent.decorator.d.ts +7 -0
- package/dist/idempotency/idempotent.decorator.js +16 -0
- package/dist/idempotency/index.d.ts +7 -0
- package/dist/idempotency/index.js +6 -0
- package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
- package/dist/idempotency/kit-idempotency.module.js +173 -0
- package/dist/idempotency/peer.d.ts +1 -0
- package/dist/idempotency/peer.js +4 -0
- package/dist/idempotency/table.d.ts +203 -0
- package/dist/idempotency/table.js +86 -0
- package/dist/internal/cleanup-result.d.ts +7 -0
- package/dist/internal/cleanup-result.js +12 -0
- package/dist/internal/ids.d.ts +13 -0
- package/dist/internal/ids.js +41 -0
- package/dist/internal/metadata.d.ts +12 -0
- package/dist/internal/metadata.js +3 -0
- package/dist/internal/outbox-policy.d.ts +8 -0
- package/dist/internal/outbox-policy.js +10 -0
- package/dist/internal/peer.d.ts +16 -0
- package/dist/internal/peer.js +56 -0
- package/dist/internal/pg-errors.d.ts +21 -0
- package/dist/internal/pg-errors.js +115 -0
- package/dist/internal/preparsed-env.d.ts +4 -0
- package/dist/internal/preparsed-env.js +14 -0
- package/dist/internal/rabbitmq-definitions.d.ts +85 -0
- package/dist/internal/rabbitmq-definitions.js +73 -0
- package/dist/internal/redis-acl.d.ts +21 -0
- package/dist/internal/redis-acl.js +30 -0
- package/dist/internal/service-name.d.ts +4 -0
- package/dist/internal/service-name.js +10 -0
- package/dist/internal/sql-identifier.d.ts +5 -0
- package/dist/internal/sql-identifier.js +12 -0
- package/dist/internal/tokens.d.ts +10 -0
- package/dist/internal/tokens.js +12 -0
- package/dist/internal/tx-context.d.ts +5 -0
- package/dist/internal/tx-context.js +9 -0
- package/dist/messaging/cleanup.d.ts +49 -0
- package/dist/messaging/cleanup.js +129 -0
- package/dist/messaging/config.d.ts +43 -0
- package/dist/messaging/config.js +8 -0
- package/dist/messaging/define-event.d.ts +29 -0
- package/dist/messaging/define-event.js +38 -0
- package/dist/messaging/envelope.d.ts +66 -0
- package/dist/messaging/envelope.js +49 -0
- package/dist/messaging/errors.d.ts +45 -0
- package/dist/messaging/errors.js +63 -0
- package/dist/messaging/inbox.d.ts +4 -0
- package/dist/messaging/inbox.js +9 -0
- package/dist/messaging/index.d.ts +13 -0
- package/dist/messaging/index.js +10 -0
- package/dist/messaging/kit-messaging.module.d.ts +39 -0
- package/dist/messaging/kit-messaging.module.js +309 -0
- package/dist/messaging/outbox-relay.d.ts +67 -0
- package/dist/messaging/outbox-relay.js +260 -0
- package/dist/messaging/outbox-store.d.ts +75 -0
- package/dist/messaging/outbox-store.js +126 -0
- package/dist/messaging/peer.d.ts +1 -0
- package/dist/messaging/peer.js +5 -0
- package/dist/messaging/rabbit-connection.d.ts +144 -0
- package/dist/messaging/rabbit-connection.js +472 -0
- package/dist/messaging/redelivery.d.ts +6 -0
- package/dist/messaging/redelivery.js +17 -0
- package/dist/messaging/subscriber.d.ts +102 -0
- package/dist/messaging/subscriber.js +412 -0
- package/dist/messaging/tables.d.ts +315 -0
- package/dist/messaging/tables.js +72 -0
- package/dist/messaging/topology.d.ts +56 -0
- package/dist/messaging/topology.js +56 -0
- package/dist/observability/fail-startup.d.ts +14 -0
- package/dist/observability/fail-startup.js +51 -0
- package/dist/observability/health-registry.d.ts +34 -0
- package/dist/observability/health-registry.js +81 -0
- package/dist/observability/index.d.ts +16 -0
- package/dist/observability/index.js +10 -0
- package/dist/observability/lifecycle.d.ts +52 -0
- package/dist/observability/lifecycle.js +151 -0
- package/dist/observability/logger-options.d.ts +54 -0
- package/dist/observability/logger-options.js +91 -0
- package/dist/observability/metrics-server.d.ts +33 -0
- package/dist/observability/metrics-server.js +108 -0
- package/dist/observability/metrics.d.ts +30 -0
- package/dist/observability/metrics.js +53 -0
- package/dist/observability/redaction.d.ts +23 -0
- package/dist/observability/redaction.js +65 -0
- package/dist/observability/sanitize-error.d.ts +47 -0
- package/dist/observability/sanitize-error.js +194 -0
- package/dist/observability/sentry.d.ts +64 -0
- package/dist/observability/sentry.js +233 -0
- package/dist/redis/channels.d.ts +34 -0
- package/dist/redis/channels.js +128 -0
- package/dist/redis/client.d.ts +42 -0
- package/dist/redis/client.js +85 -0
- package/dist/redis/index.d.ts +7 -0
- package/dist/redis/index.js +7 -0
- package/dist/redis/kit-redis.module.d.ts +18 -0
- package/dist/redis/kit-redis.module.js +131 -0
- package/dist/redis/peer.d.ts +1 -0
- package/dist/redis/peer.js +4 -0
- package/dist/redis/rate-limiter.d.ts +49 -0
- package/dist/redis/rate-limiter.js +139 -0
- package/dist/testing/count-queries.d.ts +30 -0
- package/dist/testing/count-queries.js +58 -0
- package/dist/testing/create-test-app.d.ts +26 -0
- package/dist/testing/create-test-app.js +32 -0
- package/dist/testing/expect-matches-api.d.ts +8 -0
- package/dist/testing/expect-matches-api.js +49 -0
- package/dist/testing/expect-problem.d.ts +24 -0
- package/dist/testing/expect-problem.js +23 -0
- package/dist/testing/images.d.ts +10 -0
- package/dist/testing/images.js +14 -0
- package/dist/testing/index.d.ts +23 -0
- package/dist/testing/index.js +17 -0
- package/dist/testing/launch-command.d.ts +52 -0
- package/dist/testing/launch-command.js +510 -0
- package/dist/testing/outbox-coverage.d.ts +26 -0
- package/dist/testing/outbox-coverage.js +98 -0
- package/dist/testing/rabbitmq-admin.d.ts +81 -0
- package/dist/testing/rabbitmq-admin.js +146 -0
- package/dist/testing/redis-worker.d.ts +10 -0
- package/dist/testing/redis-worker.js +15 -0
- package/dist/testing/repo-conventions.d.ts +10 -0
- package/dist/testing/repo-conventions.js +524 -0
- package/dist/testing/repo-files.d.ts +113 -0
- package/dist/testing/repo-files.js +401 -0
- package/dist/testing/scratch-database.d.ts +19 -0
- package/dist/testing/scratch-database.js +75 -0
- package/dist/testing/start-test-stack.d.ts +85 -0
- package/dist/testing/start-test-stack.js +342 -0
- package/dist/testing/tcp-proxy.d.ts +40 -0
- package/dist/testing/tcp-proxy.js +164 -0
- package/dist/testing/tenant-isolation.d.ts +31 -0
- package/dist/testing/tenant-isolation.js +205 -0
- package/dist/testing/test-events.d.ts +37 -0
- package/dist/testing/test-events.js +150 -0
- package/dist/testing/timeout-scale.d.ts +4 -0
- package/dist/testing/timeout-scale.js +17 -0
- package/dist/testing/timing-guard.d.ts +22 -0
- package/dist/testing/timing-guard.js +54 -0
- package/dist/testing/worker-scope.d.ts +31 -0
- package/dist/testing/worker-scope.js +68 -0
- package/package.json +177 -4
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// assertAppRoleIsNotOwner, assertStrictTenantIsolation (prd.md §3.8, AC-4.6, AC-4.8). Проверки — по каталогу PostgreSQL
|
|
2
|
+
// под ролью приложения; pg не импортируется: db — объект с query. E5: политика outbox по OUTBOX_POLICY_SQL
|
|
3
|
+
// (createOutboxTable), имена — internal/outbox-policy (/testing не грузит /messaging).
|
|
4
|
+
import { OUTBOX_POLICY, OUTBOX_TABLE } from '../internal/outbox-policy.js';
|
|
5
|
+
const USER_SCHEMAS = `nspname not in ('pg_catalog', 'information_schema') and nspname not like 'pg\\_toast%' and nspname not like 'pg\\_temp%'`;
|
|
6
|
+
async function rows(db, text) {
|
|
7
|
+
return (await db.query(text)).rows;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Роль соединения — роль приложения, а не владелец (AC-4.6): не суперпользователь, без `BYPASSRLS`, не владеет
|
|
11
|
+
* схемами и таблицами, без `CREATE` на базе и схемах (KT-19 п. 1).
|
|
12
|
+
* @throws {Error} «assertAppRoleIsNotOwner: <роль>: <нарушения через «; »>».
|
|
13
|
+
*/
|
|
14
|
+
export async function assertAppRoleIsNotOwner(db) {
|
|
15
|
+
const violations = [];
|
|
16
|
+
const [role] = await rows(db, 'select rolname, rolsuper, rolbypassrls from pg_roles where rolname = current_user');
|
|
17
|
+
if (role?.rolsuper === true) {
|
|
18
|
+
violations.push('суперпользователь');
|
|
19
|
+
}
|
|
20
|
+
if (role?.rolbypassrls === true) {
|
|
21
|
+
violations.push('BYPASSRLS');
|
|
22
|
+
}
|
|
23
|
+
const owner = '(select oid from pg_roles where rolname = current_user)';
|
|
24
|
+
const schemas = await rows(db, `select nspname from pg_namespace where nspowner = ${owner} and ${USER_SCHEMAS} order by nspname`);
|
|
25
|
+
if (schemas.length > 0) {
|
|
26
|
+
violations.push(`владеет схемами ${schemas.map((row) => row.nspname).join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
const tables = await rows(db, `select n.nspname || '.' || c.relname as name from pg_class c
|
|
29
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
30
|
+
where c.relowner = ${owner} and ${USER_SCHEMAS.replaceAll('nspname', 'n.nspname')}
|
|
31
|
+
order by 1`);
|
|
32
|
+
if (tables.length > 0) {
|
|
33
|
+
violations.push(`владеет объектами ${tables.map((row) => row.name).join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
const [database] = await rows(db, `select current_database() as name, has_database_privilege(current_user, current_database(), 'CREATE') as create`);
|
|
36
|
+
if (database?.create === true) {
|
|
37
|
+
violations.push(`CREATE на базе ${database.name}`);
|
|
38
|
+
}
|
|
39
|
+
const creatable = await rows(db, `select nspname from pg_namespace where ${USER_SCHEMAS} and has_schema_privilege(current_user, oid, 'CREATE') order by nspname`);
|
|
40
|
+
for (const schema of creatable) {
|
|
41
|
+
violations.push(`CREATE на схеме ${schema.nspname}`);
|
|
42
|
+
}
|
|
43
|
+
if (violations.length > 0) {
|
|
44
|
+
throw new Error(`assertAppRoleIsNotOwner: ${role?.rolname ?? 'неизвестная роль'}: ${violations.join('; ')}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Формы политик PostgreSQL 17 (`pg_policies.qual`) для `tenantPolicy` и `createIdempotencyTable`. */
|
|
48
|
+
const TENANT_POLICY = "(tenant = current_setting('app.tenant'::text, true))";
|
|
49
|
+
const IDEMPOTENCY_POLICY = "(NOT (tenant IS DISTINCT FROM NULLIF(current_setting('app.tenant'::text, true), ''::text)))";
|
|
50
|
+
const IDEMPOTENCY_TABLE = 'idempotency_key';
|
|
51
|
+
/** Печатная форма PostgreSQL 17 выражения OUTBOX_POLICY_SQL (`createOutboxTable`). */
|
|
52
|
+
const OUTBOX_POLICY_PG = "((COALESCE(current_setting('app.tenant'::text, true), ''::text) = ''::text) OR (tenant = current_setting('app.tenant'::text, true)))";
|
|
53
|
+
/** Без внешних скобок и пробелов, в нижнем регистре. */
|
|
54
|
+
function normalize(expression) {
|
|
55
|
+
let text = (expression ?? '').replace(/\s+/g, '').toLowerCase();
|
|
56
|
+
while (text.startsWith('(') && text.endsWith(')') && balanced(text.slice(1, -1))) {
|
|
57
|
+
text = text.slice(1, -1);
|
|
58
|
+
}
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
function balanced(text) {
|
|
62
|
+
let depth = 0;
|
|
63
|
+
for (const char of text) {
|
|
64
|
+
depth += char === '(' ? 1 : char === ')' ? -1 : 0;
|
|
65
|
+
if (depth < 0) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return depth === 0;
|
|
70
|
+
}
|
|
71
|
+
function quote(name) {
|
|
72
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
73
|
+
}
|
|
74
|
+
/** Соединение для транзакции: клиент пула (`pg.Pool` — `connect` и `totalCount`) или само соединение. */
|
|
75
|
+
async function withConnection(db, work) {
|
|
76
|
+
const pool = db;
|
|
77
|
+
if (typeof pool.connect !== 'function' || !('totalCount' in db)) {
|
|
78
|
+
return work(db);
|
|
79
|
+
}
|
|
80
|
+
const client = await pool.connect();
|
|
81
|
+
try {
|
|
82
|
+
return await work(client);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
client.release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function policyViolations(db, schema, table) {
|
|
89
|
+
const policies = (await db.query('select policyname, roles, permissive, cmd, qual, with_check from pg_policies where schemaname = $1 and tablename = $2', [schema, table])).rows;
|
|
90
|
+
if (policies.length === 0) {
|
|
91
|
+
return [`${table}: нет политики`];
|
|
92
|
+
}
|
|
93
|
+
if (table === OUTBOX_TABLE) {
|
|
94
|
+
return outboxPolicyViolations(policies);
|
|
95
|
+
}
|
|
96
|
+
const expected = normalize(table === IDEMPOTENCY_TABLE ? IDEMPOTENCY_POLICY : TENANT_POLICY);
|
|
97
|
+
return policies
|
|
98
|
+
.filter((policy) => policy.permissive !== 'PERMISSIVE' ||
|
|
99
|
+
policy.cmd !== 'ALL' ||
|
|
100
|
+
normalize(policy.qual) !== expected ||
|
|
101
|
+
normalize(policy.with_check) !== expected)
|
|
102
|
+
.map((policy) => `${table}.${policy.policyname}: нестрогая политика`);
|
|
103
|
+
}
|
|
104
|
+
/** Исключение строгой модели — ровно политика `createOutboxTable`: другое выражение не проходит. */
|
|
105
|
+
function outboxPolicyViolations(policies) {
|
|
106
|
+
const expected = normalize(OUTBOX_POLICY_PG);
|
|
107
|
+
const [policy] = policies;
|
|
108
|
+
const roles = Array.isArray(policy?.roles)
|
|
109
|
+
? policy.roles.join(',')
|
|
110
|
+
: String(policy?.roles ?? '');
|
|
111
|
+
const valid = policies.length === 1 &&
|
|
112
|
+
policy?.policyname === OUTBOX_POLICY &&
|
|
113
|
+
policy.permissive === 'PERMISSIVE' &&
|
|
114
|
+
policy.cmd === 'ALL' &&
|
|
115
|
+
roles.replace(/[{}]/g, '') === 'public' &&
|
|
116
|
+
normalize(policy.qual) === expected &&
|
|
117
|
+
normalize(policy.with_check) === expected;
|
|
118
|
+
return valid ? [] : [`${OUTBOX_TABLE}: политика не по createOutboxTable (${OUTBOX_POLICY})`];
|
|
119
|
+
}
|
|
120
|
+
async function visibleRows(db, schema, table) {
|
|
121
|
+
const filter = table === IDEMPOTENCY_TABLE ? ' where tenant is not null' : '';
|
|
122
|
+
return withConnection(db, async (connection) => {
|
|
123
|
+
await connection.query('begin');
|
|
124
|
+
try {
|
|
125
|
+
await connection.query("select set_config('app.tenant', '', true)");
|
|
126
|
+
const { rows } = await connection.query(`select count(*)::int as count from ${quote(schema)}.${quote(table)}${filter}`);
|
|
127
|
+
const count = rows[0]?.count ?? 0;
|
|
128
|
+
return count > 0
|
|
129
|
+
? [`${table}: под ролью приложения с пустым тенантом видно ${count} строк`]
|
|
130
|
+
: [];
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error.code === '42501') {
|
|
134
|
+
return [`${table}: у роли приложения нет SELECT`];
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await connection.query('rollback');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Функция очистки — по точной сигнатуре `idempotency_purge(integer)`; другая перегрузка — «функции нет» (KT-22 п. 7). */
|
|
144
|
+
async function purgeViolations(db, schema) {
|
|
145
|
+
const name = `${schema}.idempotency_purge`;
|
|
146
|
+
const [fn] = (await db.query(`select p.prosecdef, pg_get_userbyid(p.proowner) as owner, p.proconfig,
|
|
147
|
+
has_function_privilege('public', p.oid, 'EXECUTE') as public_execute
|
|
148
|
+
from pg_proc p
|
|
149
|
+
where p.oid = to_regprocedure($1)`, [`${quote(schema)}.idempotency_purge(integer)`])).rows;
|
|
150
|
+
if (fn === undefined) {
|
|
151
|
+
return [`нет функции ${name} (idempotencyPurgeSql)`];
|
|
152
|
+
}
|
|
153
|
+
const violations = [];
|
|
154
|
+
if (!fn.prosecdef) {
|
|
155
|
+
violations.push(`${name}: не SECURITY DEFINER`);
|
|
156
|
+
}
|
|
157
|
+
if (fn.owner !== `${schema}_directory`) {
|
|
158
|
+
violations.push(`${name}: владелец ${fn.owner}, нужен ${schema}_directory`);
|
|
159
|
+
}
|
|
160
|
+
if (!(fn.proconfig ?? []).some((item) => item.startsWith('search_path='))) {
|
|
161
|
+
violations.push(`${name}: search_path не задан`);
|
|
162
|
+
}
|
|
163
|
+
if (fn.public_execute) {
|
|
164
|
+
violations.push(`${name}: EXECUTE у PUBLIC`);
|
|
165
|
+
}
|
|
166
|
+
return violations;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Строгий RLS схемы сервиса (§3.8, AC-4.8) — соединение **роли приложения**: каждая таблица с колонкой `tenant` — RLS,
|
|
170
|
+
* `FORCE`, строгая политика (`tenantPolicy`; `idempotency_key` — `createIdempotencyTable`); `outbox` — исключение строгой
|
|
171
|
+
* модели: политика `createOutboxTable` (пустой тенант — все строки), RLS и FORCE обязательны; под пустым тенантом 0 строк (вызывать после вставки данных фабриками); при таблице идемпотентности —
|
|
172
|
+
* функция `idempotency_purge` `SECURITY DEFINER`, владелец `<svc>_directory`, `search_path`, без `EXECUTE` у `PUBLIC`.
|
|
173
|
+
* @throws {Error} «assertStrictTenantIsolation(<схема>): <нарушения через «; »>».
|
|
174
|
+
*/
|
|
175
|
+
export async function assertStrictTenantIsolation(db, options) {
|
|
176
|
+
const { schema } = options;
|
|
177
|
+
const except = new Set(options.except ?? []);
|
|
178
|
+
const tables = (await db.query(`select c.relname, c.relrowsecurity, c.relforcerowsecurity
|
|
179
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
180
|
+
join pg_attribute a on a.attrelid = c.oid and a.attname = 'tenant' and not a.attisdropped
|
|
181
|
+
where n.nspname = $1 and c.relkind in ('r', 'p') order by c.relname collate "C"`, [schema])).rows;
|
|
182
|
+
const violations = [];
|
|
183
|
+
if (tables.length === 0) {
|
|
184
|
+
violations.push('в схеме нет таблиц с колонкой tenant');
|
|
185
|
+
}
|
|
186
|
+
for (const table of tables.filter((item) => !except.has(item.relname))) {
|
|
187
|
+
const name = table.relname;
|
|
188
|
+
if (!table.relrowsecurity) {
|
|
189
|
+
violations.push(`${name}: RLS не включён`);
|
|
190
|
+
}
|
|
191
|
+
if (!table.relforcerowsecurity) {
|
|
192
|
+
violations.push(`${name}: нет FORCE ROW LEVEL SECURITY`);
|
|
193
|
+
}
|
|
194
|
+
violations.push(...(await policyViolations(db, schema, name)));
|
|
195
|
+
if (name !== OUTBOX_TABLE) {
|
|
196
|
+
violations.push(...(await visibleRows(db, schema, name)));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (tables.some((item) => item.relname === IDEMPOTENCY_TABLE && !except.has(item.relname))) {
|
|
200
|
+
violations.push(...(await purgeViolations(db, schema)));
|
|
201
|
+
}
|
|
202
|
+
if (violations.length > 0) {
|
|
203
|
+
throw new Error(`assertStrictTenantIsolation(${schema}): ${violations.join('; ')}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { KitEvent } from '../messaging/define-event.js';
|
|
3
|
+
import { type Envelope } from '../messaging/envelope.js';
|
|
4
|
+
/** Опции `publishTestEvent`. */
|
|
5
|
+
export interface KitPublishTestEventOptions {
|
|
6
|
+
/** id события; по умолчанию `evt_` + ULID. */
|
|
7
|
+
readonly id?: string;
|
|
8
|
+
/** Тенант; по умолчанию `workerScope().tenant` для `'required'`, `null` для `'none'`; может нарушать признак. */
|
|
9
|
+
readonly tenant?: string | null;
|
|
10
|
+
/** id субъекта; по умолчанию `tst_` + ULID. */
|
|
11
|
+
readonly subjectId?: string;
|
|
12
|
+
/** Время события; по умолчанию — сейчас. */
|
|
13
|
+
readonly occurredAt?: Date;
|
|
14
|
+
/** Актор; по умолчанию `{channel: 'system'}`. */
|
|
15
|
+
readonly actor?: Record<string, unknown> | null;
|
|
16
|
+
/** traceId; по умолчанию `null`. */
|
|
17
|
+
readonly traceId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
/** @internal Адрес вместо `adminUrl` воркера (тест пакета: обрыв через tcpProxy); `undefined` — сброс. */
|
|
20
|
+
export declare function setTestEventsUrl(url: string | undefined): void;
|
|
21
|
+
/** @internal Закрытие текущего соединения `publishTestEvent` (сигнал теста пакета); нет соединения — `undefined`. */
|
|
22
|
+
export declare function testEventsClosed(): Promise<void> | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Публикует конверт `01` §7.1 в `verdeect.events` от имени администратора vhost воркера — как relay другого сервиса;
|
|
25
|
+
* ждёт подтверждения брокера.
|
|
26
|
+
* @returns опубликованный конверт.
|
|
27
|
+
* @throws {ZodError} `data` не по схеме события.
|
|
28
|
+
*/
|
|
29
|
+
export declare function publishTestEvent<E extends KitEvent>(event: E, data: z.input<E['data']>, opts?: KitPublishTestEventOptions): Promise<Envelope<E>>;
|
|
30
|
+
/**
|
|
31
|
+
* Ждёт строку `id` в `<svc>.inbox` (обработка подписчиком с `dedupe: 'inbox'`) с шагом 20 мс до
|
|
32
|
+
* `timeoutMs × TEST_TIMEOUT_SCALE` — без `sleep` в тесте. Для `dedupe: 'none'` ждать побочный эффект обработчика.
|
|
33
|
+
* @throws {Error} срок вышел — «waitForInbox: событие <id> не обработано за <timeoutMs> мс».
|
|
34
|
+
*/
|
|
35
|
+
export declare function waitForInbox(id: string, options: {
|
|
36
|
+
readonly timeoutMs: number;
|
|
37
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { newId } from '../internal/ids.js';
|
|
2
|
+
import { loadPeer } from '../internal/peer.js';
|
|
3
|
+
import { EVENTS_EXCHANGE } from '../internal/rabbitmq-definitions.js';
|
|
4
|
+
import { quoteIdentifier } from '../internal/sql-identifier.js';
|
|
5
|
+
import { EnvelopeSchema } from '../messaging/envelope.js';
|
|
6
|
+
import { TEST_TIMEOUT_SCALE } from './timeout-scale.js';
|
|
7
|
+
import { workerScope } from './worker-scope.js';
|
|
8
|
+
import { INBOX_TABLE } from '../internal/outbox-policy.js';
|
|
9
|
+
let channel;
|
|
10
|
+
let closed;
|
|
11
|
+
let model;
|
|
12
|
+
let urlOverride;
|
|
13
|
+
/** @internal Адрес вместо `adminUrl` воркера (тест пакета: обрыв через tcpProxy); `undefined` — сброс. */
|
|
14
|
+
export function setTestEventsUrl(url) {
|
|
15
|
+
urlOverride = url;
|
|
16
|
+
// Другой адрес — другое соединение: прежнее закрывается, следующий вызов откроет новое.
|
|
17
|
+
channel = undefined;
|
|
18
|
+
void model?.close().catch(() => undefined);
|
|
19
|
+
model = undefined;
|
|
20
|
+
}
|
|
21
|
+
/** @internal Закрытие текущего соединения `publishTestEvent` (сигнал теста пакета); нет соединения — `undefined`. */
|
|
22
|
+
export function testEventsClosed() {
|
|
23
|
+
return closed;
|
|
24
|
+
}
|
|
25
|
+
async function adminUrl() {
|
|
26
|
+
if (urlOverride !== undefined) {
|
|
27
|
+
return urlOverride;
|
|
28
|
+
}
|
|
29
|
+
const { inject } = await loadPeer('vitest', 'testing');
|
|
30
|
+
const pool = Number(process.env.VITEST_POOL_ID ?? '1');
|
|
31
|
+
const worker = inject('kitRabbitmqWorkers')?.[pool - 1];
|
|
32
|
+
if (worker === undefined) {
|
|
33
|
+
throw new TypeError('publishTestEvent: startTestStack запущен без rabbitmq: true');
|
|
34
|
+
}
|
|
35
|
+
return worker.adminUrl;
|
|
36
|
+
}
|
|
37
|
+
async function openChannel() {
|
|
38
|
+
const amqp = await loadPeer('amqplib', 'testing');
|
|
39
|
+
const connection = await amqp.connect(await adminUrl());
|
|
40
|
+
let created;
|
|
41
|
+
try {
|
|
42
|
+
created = await connection.createConfirmChannel();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Канал не открылся — соединение закрывается: повторные вызовы не копят открытые соединения.
|
|
46
|
+
await connection.close().catch(() => undefined);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
model = connection;
|
|
50
|
+
const current = channel;
|
|
51
|
+
// Обрыв соединения или закрытие канала — следующий вызов открывает новое соединение.
|
|
52
|
+
const forget = () => {
|
|
53
|
+
if (channel === current) {
|
|
54
|
+
channel = undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
closed = new Promise((resolve) => {
|
|
58
|
+
connection.once('close', () => {
|
|
59
|
+
forget();
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
connection.on('error', forget);
|
|
64
|
+
created.on('error', forget);
|
|
65
|
+
created.once('close', forget);
|
|
66
|
+
return created;
|
|
67
|
+
}
|
|
68
|
+
/** Confirm-канал администратора vhost воркера, один на воркер; после обрыва пересоздаётся при следующем вызове. */
|
|
69
|
+
function workerChannel() {
|
|
70
|
+
if (channel === undefined) {
|
|
71
|
+
const opening = openChannel();
|
|
72
|
+
channel = opening;
|
|
73
|
+
// Неудачное подключение не запоминается: следующий вызов пробует снова.
|
|
74
|
+
opening.catch(() => {
|
|
75
|
+
if (channel === opening) {
|
|
76
|
+
channel = undefined;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return channel;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Публикует конверт `01` §7.1 в `verdeect.events` от имени администратора vhost воркера — как relay другого сервиса;
|
|
84
|
+
* ждёт подтверждения брокера.
|
|
85
|
+
* @returns опубликованный конверт.
|
|
86
|
+
* @throws {ZodError} `data` не по схеме события.
|
|
87
|
+
*/
|
|
88
|
+
export async function publishTestEvent(event, data, opts = {}) {
|
|
89
|
+
const parsed = event.data.parse(data);
|
|
90
|
+
const tenant = opts.tenant !== undefined
|
|
91
|
+
? opts.tenant
|
|
92
|
+
: event.tenant === 'required'
|
|
93
|
+
? workerScope().tenant
|
|
94
|
+
: null;
|
|
95
|
+
const occurredAt = opts.occurredAt ?? new Date();
|
|
96
|
+
const envelope = EnvelopeSchema.parse({
|
|
97
|
+
id: opts.id ?? newId('evt'),
|
|
98
|
+
type: event.type,
|
|
99
|
+
version: event.version,
|
|
100
|
+
occurredAt: occurredAt.toISOString(),
|
|
101
|
+
tenant,
|
|
102
|
+
actor: opts.actor === undefined ? { channel: 'system' } : opts.actor,
|
|
103
|
+
subject: { kind: event.subject, id: opts.subjectId ?? newId('tst') },
|
|
104
|
+
data: parsed,
|
|
105
|
+
traceId: opts.traceId ?? null,
|
|
106
|
+
});
|
|
107
|
+
const confirm = await workerChannel();
|
|
108
|
+
confirm.publish(EVENTS_EXCHANGE, event.type, Buffer.from(JSON.stringify(envelope)), {
|
|
109
|
+
persistent: true,
|
|
110
|
+
messageId: envelope.id,
|
|
111
|
+
type: event.type,
|
|
112
|
+
contentType: 'application/json',
|
|
113
|
+
timestamp: Math.floor(occurredAt.getTime() / 1000),
|
|
114
|
+
});
|
|
115
|
+
await confirm.waitForConfirms();
|
|
116
|
+
return envelope;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Ждёт строку `id` в `<svc>.inbox` (обработка подписчиком с `dedupe: 'inbox'`) с шагом 20 мс до
|
|
120
|
+
* `timeoutMs × TEST_TIMEOUT_SCALE` — без `sleep` в тесте. Для `dedupe: 'none'` ждать побочный эффект обработчика.
|
|
121
|
+
* @throws {Error} срок вышел — «waitForInbox: событие <id> не обработано за <timeoutMs> мс».
|
|
122
|
+
*/
|
|
123
|
+
export async function waitForInbox(id, options) {
|
|
124
|
+
const { inject } = await loadPeer('vitest', 'testing');
|
|
125
|
+
const schema = inject('kitDatabaseService');
|
|
126
|
+
const url = inject('kitDatabaseUrl');
|
|
127
|
+
if (schema === undefined || url === undefined) {
|
|
128
|
+
throw new TypeError('waitForInbox: startTestStack запущен без postgres');
|
|
129
|
+
}
|
|
130
|
+
const pg = (await loadPeer('pg', 'testing')).default;
|
|
131
|
+
const client = new pg.Client({ connectionString: url });
|
|
132
|
+
client.on('error', () => undefined);
|
|
133
|
+
await client.connect();
|
|
134
|
+
const text = `select 1 from ${quoteIdentifier(schema)}.${quoteIdentifier(INBOX_TABLE)} where id = $1`;
|
|
135
|
+
const deadline = Date.now() + options.timeoutMs * TEST_TIMEOUT_SCALE;
|
|
136
|
+
try {
|
|
137
|
+
for (;;) {
|
|
138
|
+
if ((await client.query(text, [id])).rowCount !== 0) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (Date.now() > deadline) {
|
|
142
|
+
throw new Error(`waitForInbox: событие ${id} не обработано за ${options.timeoutMs} мс (нет строки в ${schema}.${INBOX_TABLE})`);
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await client.end();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Множитель testTimeout/hookTimeout из TEST_TIMEOUT_SCALE; пусто — 1. */
|
|
2
|
+
export declare function readTimeoutScale(env: Readonly<Record<string, string | undefined>>): number;
|
|
3
|
+
/** Множитель тайм-аутов тестов текущего процесса (§3.8). */
|
|
4
|
+
export declare const TEST_TIMEOUT_SCALE: number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Множитель тайм-аутов тестов (prd.md §3.8, E1); эталон — verdeect-platform-client test/support/timeout-scale.ts.
|
|
2
|
+
// По умолчанию 1. Задаётся вручную на загруженной машине (load average > числа ядер — 2–3).
|
|
3
|
+
// Сообщение в консоль из O (scripts/timeout-scale.mjs) не перенесено: NFR-8.
|
|
4
|
+
/** Множитель testTimeout/hookTimeout из TEST_TIMEOUT_SCALE; пусто — 1. */
|
|
5
|
+
export function readTimeoutScale(env) {
|
|
6
|
+
const raw = env.TEST_TIMEOUT_SCALE?.trim() ?? '';
|
|
7
|
+
if (raw === '') {
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
const scale = Number(raw);
|
|
11
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
12
|
+
throw new Error(`TEST_TIMEOUT_SCALE=${raw}: нужно число > 0`);
|
|
13
|
+
}
|
|
14
|
+
return scale;
|
|
15
|
+
}
|
|
16
|
+
/** Множитель тайм-аутов тестов текущего процесса (§3.8). */
|
|
17
|
+
export const TEST_TIMEOUT_SCALE = readTimeoutScale(process.env);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Зависимости runTimingGuard (подменяются в тестах). */
|
|
2
|
+
export interface TimingGuardDeps {
|
|
3
|
+
/** Load average за минуту. */
|
|
4
|
+
readonly load: () => number;
|
|
5
|
+
/** Число ядер. */
|
|
6
|
+
readonly cores: () => number;
|
|
7
|
+
/** Имена compose-проектов запущенных контейнеров; null — docker недоступен. */
|
|
8
|
+
readonly composeProjects: () => Promise<readonly string[] | null>;
|
|
9
|
+
/** Вывод строки (stderr). */
|
|
10
|
+
readonly write: (line: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/** Запуск внешней команды: stdout. */
|
|
13
|
+
type Exec = (file: string, args: readonly string[]) => Promise<{
|
|
14
|
+
stdout: string;
|
|
15
|
+
}>;
|
|
16
|
+
/** Compose-проекты через `docker ps`; любая неудача (нет CLI, демон остановлен, нет прав) → null. */
|
|
17
|
+
export declare function defaultComposeProjects(exec: Exec): Promise<readonly string[] | null>;
|
|
18
|
+
/** Внутренняя логика timingGuard; экспорт для тестов, не из index.ts. */
|
|
19
|
+
export declare function runTimingGuard(deps: TimingGuardDeps): Promise<void>;
|
|
20
|
+
/** globalSetup уровня T: нагрузка, ядра, запрет при запущенном e2e-контуре verdeect-*-e2e (§3.8). */
|
|
21
|
+
export declare function timingGuard(): Promise<void>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Сторож замеров времени для globalSetup уровня T (prd.md §3.8, E1); эталон — O test/support/timing-guard.ts (029c25c).
|
|
2
|
+
// Единственные строки пакета в stderr в E1: назначение функции — сообщать о среде (process.stderr, не console — NFR-8).
|
|
3
|
+
// Vite теряет текст ошибок globalSetup, поэтому сообщение пишется в stderr до throw.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { availableParallelism, loadavg } from 'node:os';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const E2E_PROJECT = /^verdeect-.*-e2e$/;
|
|
8
|
+
/** Compose-проекты через `docker ps`; любая неудача (нет CLI, демон остановлен, нет прав) → null. */
|
|
9
|
+
export async function defaultComposeProjects(exec) {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout } = await exec('docker', [
|
|
12
|
+
'ps',
|
|
13
|
+
'--format',
|
|
14
|
+
'{{.Label "com.docker.compose.project"}}',
|
|
15
|
+
]);
|
|
16
|
+
const names = stdout.split('\n').map((line) => line.trim());
|
|
17
|
+
return [...new Set(names.filter((name) => name !== ''))];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Внутренняя логика timingGuard; экспорт для тестов, не из index.ts. */
|
|
24
|
+
export async function runTimingGuard(deps) {
|
|
25
|
+
const load = deps.load();
|
|
26
|
+
const cores = deps.cores();
|
|
27
|
+
deps.write(`[timingGuard] load=${load.toFixed(2)} cores=${cores}\n`);
|
|
28
|
+
if (load > cores) {
|
|
29
|
+
deps.write('[timingGuard] WARN: нагрузка выше числа ядер — замеры времени ненадёжны\n');
|
|
30
|
+
}
|
|
31
|
+
const projects = await deps.composeProjects();
|
|
32
|
+
if (projects === null) {
|
|
33
|
+
deps.write('[timingGuard] docker недоступен — проверка e2e-контура пропущена\n');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const running = projects.filter((name) => E2E_PROJECT.test(name));
|
|
37
|
+
if (running.length > 0) {
|
|
38
|
+
const message = `[timingGuard] запущен e2e-контур ${running.join(', ')} — остановите его перед замерами времени`;
|
|
39
|
+
deps.write(`${message}\n`);
|
|
40
|
+
throw new Error(message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** globalSetup уровня T: нагрузка, ядра, запрет при запущенном e2e-контуре verdeect-*-e2e (§3.8). */
|
|
44
|
+
export function timingGuard() {
|
|
45
|
+
const exec = promisify(execFile);
|
|
46
|
+
return runTimingGuard({
|
|
47
|
+
load: () => loadavg()[0] ?? 0,
|
|
48
|
+
cores: () => availableParallelism(),
|
|
49
|
+
composeProjects: () => defaultComposeProjects((file, args) => exec(file, [...args])),
|
|
50
|
+
write: (line) => {
|
|
51
|
+
process.stderr.write(line);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { KitRabbitWorker } from './rabbitmq-admin.js';
|
|
2
|
+
/** Область воркера Vitest (§3.8). */
|
|
3
|
+
export interface KitWorkerScope {
|
|
4
|
+
/**
|
|
5
|
+
* Слаг компании воркера `w<VITEST_POOL_ID>-<8 hex>` — значение `DbContext.tenant` и claim `tenant` токена. Строку
|
|
6
|
+
* компании, если нужна, создаёт фабрика сервиса; `tenantId` — фабрика сервиса.
|
|
7
|
+
*/
|
|
8
|
+
readonly tenant: string;
|
|
9
|
+
/**
|
|
10
|
+
* vhost воркера с пользователем по правам M3 сервиса из `startTestStack({rabbitmq: true})`: `url` — значение
|
|
11
|
+
* `RABBITMQ_URL` тестов. Удаляется в teardown прогона (AC-5.18).
|
|
12
|
+
* @throws {TypeError} стек без `rabbitmq: true` или нет записи для воркера (при обращении).
|
|
13
|
+
*/
|
|
14
|
+
readonly rabbitmq: {
|
|
15
|
+
readonly vhost: string;
|
|
16
|
+
readonly url: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Префикс ключей и каналов Redis воркера `<svc>:w<VITEST_POOL_ID>:` — лежит внутри прав ACL сервиса
|
|
20
|
+
* (`startTestStack({redis: true})`, AC-8.5); подставляется в приложение через `redisWorkerOverride()`.
|
|
21
|
+
* @throws {TypeError} стек без `redis: true` (при обращении).
|
|
22
|
+
*/
|
|
23
|
+
readonly redisPrefix: string;
|
|
24
|
+
}
|
|
25
|
+
type ReadWorkers = () => readonly KitRabbitWorker[] | undefined;
|
|
26
|
+
type ReadRedisService = () => string | undefined;
|
|
27
|
+
/** `workerScope` с источниками vhost и имени сервиса Redis (внутренняя: тесты подменяют `inject`). */
|
|
28
|
+
export declare function workerScopeWith(readWorkers: ReadWorkers, readRedisService?: ReadRedisService): KitWorkerScope;
|
|
29
|
+
/** Область текущего воркера: одно значение на воркер, разные воркеры — разные. */
|
|
30
|
+
export declare function workerScope(): KitWorkerScope;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// workerScope (prd.md §3.8 v1.14, UC-14): E4 — tenant; E5 — rabbitmq (vhost воркера из startTestStack, AC-5.18);
|
|
2
|
+
// E8 — redisPrefix <svc>:w<N>: (AC-8.5). Записей в таблицы не создаёт (N2, CE-4).
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { missingPeerMessage } from '../internal/peer.js';
|
|
5
|
+
// Без vitest /testing импортируется; геттер rabbitmq синхронный и не может ждать loadPeer.
|
|
6
|
+
const vitest = await import('vitest').catch(() => undefined);
|
|
7
|
+
/** Слаг компании: формат claim `tenant` (01 §5.2; TENANT_SLUG O без ведущего `_` служебных компаний). */
|
|
8
|
+
const TENANT_SLUG = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
9
|
+
let scope;
|
|
10
|
+
function readProvided() {
|
|
11
|
+
if (vitest === undefined) {
|
|
12
|
+
throw new Error(missingPeerMessage('vitest', 'testing'));
|
|
13
|
+
}
|
|
14
|
+
return vitest.inject('kitRabbitmqWorkers');
|
|
15
|
+
}
|
|
16
|
+
function readProvidedRedisService() {
|
|
17
|
+
if (vitest === undefined) {
|
|
18
|
+
throw new Error(missingPeerMessage('vitest', 'testing'));
|
|
19
|
+
}
|
|
20
|
+
return vitest.inject('kitRedisService');
|
|
21
|
+
}
|
|
22
|
+
/** `workerScope` с источниками vhost и имени сервиса Redis (внутренняя: тесты подменяют `inject`). */
|
|
23
|
+
export function workerScopeWith(readWorkers, readRedisService = readProvidedRedisService) {
|
|
24
|
+
const tenant = `w${process.env.VITEST_POOL_ID ?? '0'}-${randomBytes(4).toString('hex')}`;
|
|
25
|
+
if (!TENANT_SLUG.test(tenant)) {
|
|
26
|
+
throw new Error(`workerScope: tenant «${tenant}» вне формата ${TENANT_SLUG.source}`);
|
|
27
|
+
}
|
|
28
|
+
let rabbitmq;
|
|
29
|
+
const result = { tenant };
|
|
30
|
+
Object.defineProperty(result, 'rabbitmq', {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: () => {
|
|
33
|
+
rabbitmq ??= rabbitmqOf(readWorkers());
|
|
34
|
+
return rabbitmq;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
let redisPrefix;
|
|
38
|
+
Object.defineProperty(result, 'redisPrefix', {
|
|
39
|
+
enumerable: true,
|
|
40
|
+
get: () => {
|
|
41
|
+
redisPrefix ??= redisPrefixOf(readRedisService());
|
|
42
|
+
return redisPrefix;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function rabbitmqOf(workers) {
|
|
48
|
+
if (workers === undefined) {
|
|
49
|
+
throw new TypeError('workerScope().rabbitmq: startTestStack запущен без rabbitmq: true');
|
|
50
|
+
}
|
|
51
|
+
const pool = Number(process.env.VITEST_POOL_ID ?? '1');
|
|
52
|
+
const worker = workers[pool - 1];
|
|
53
|
+
if (worker === undefined) {
|
|
54
|
+
throw new TypeError(`workerScope().rabbitmq: нет vhost для воркера ${pool} (vhost в стеке: ${workers.length})`);
|
|
55
|
+
}
|
|
56
|
+
return { vhost: worker.vhost, url: worker.url };
|
|
57
|
+
}
|
|
58
|
+
function redisPrefixOf(service) {
|
|
59
|
+
if (service === undefined) {
|
|
60
|
+
throw new TypeError('workerScope().redisPrefix: startTestStack запущен без redis: true');
|
|
61
|
+
}
|
|
62
|
+
return `${service}:w${process.env.VITEST_POOL_ID ?? '1'}:`;
|
|
63
|
+
}
|
|
64
|
+
/** Область текущего воркера: одно значение на воркер, разные воркеры — разные. */
|
|
65
|
+
export function workerScope() {
|
|
66
|
+
scope ??= workerScopeWith(readProvided);
|
|
67
|
+
return scope;
|
|
68
|
+
}
|