@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.
Files changed (216) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +804 -2
  3. package/alerts/verdeect-kit.rules.yml +133 -0
  4. package/dist/cli/commands/crypto.d.ts +9 -0
  5. package/dist/cli/commands/crypto.js +87 -0
  6. package/dist/cli/commands/db.d.ts +11 -0
  7. package/dist/cli/commands/db.js +151 -0
  8. package/dist/cli/commands/openapi.d.ts +9 -0
  9. package/dist/cli/commands/openapi.js +144 -0
  10. package/dist/cli/commands/rabbitmq.d.ts +11 -0
  11. package/dist/cli/commands/rabbitmq.js +124 -0
  12. package/dist/cli/commands/redis.d.ts +11 -0
  13. package/dist/cli/commands/redis.js +119 -0
  14. package/dist/cli/main.d.ts +2 -0
  15. package/dist/cli/main.js +31 -0
  16. package/dist/config/cli-schemas.d.ts +29 -0
  17. package/dist/config/cli-schemas.js +15 -0
  18. package/dist/config/define-env.d.ts +21 -0
  19. package/dist/config/define-env.js +43 -0
  20. package/dist/config/fragments.d.ts +105 -0
  21. package/dist/config/fragments.js +86 -0
  22. package/dist/config/helpers.d.ts +26 -0
  23. package/dist/config/helpers.js +62 -0
  24. package/dist/config/index.d.ts +7 -0
  25. package/dist/config/index.js +7 -0
  26. package/dist/config/kit-config.module.d.ts +20 -0
  27. package/dist/config/kit-config.module.js +23 -0
  28. package/dist/config/parse-env.d.ts +14 -0
  29. package/dist/config/parse-env.js +28 -0
  30. package/dist/crypto/cipher.d.ts +61 -0
  31. package/dist/crypto/cipher.js +136 -0
  32. package/dist/crypto/index.d.ts +2 -0
  33. package/dist/crypto/index.js +2 -0
  34. package/dist/database/batches.d.ts +42 -0
  35. package/dist/database/batches.js +49 -0
  36. package/dist/database/db.d.ts +93 -0
  37. package/dist/database/db.js +234 -0
  38. package/dist/database/if-newer.d.ts +9 -0
  39. package/dist/database/if-newer.js +15 -0
  40. package/dist/database/index.d.ts +17 -0
  41. package/dist/database/index.js +13 -0
  42. package/dist/database/kit-database.module.d.ts +47 -0
  43. package/dist/database/kit-database.module.js +181 -0
  44. package/dist/database/migrations.d.ts +42 -0
  45. package/dist/database/migrations.js +142 -0
  46. package/dist/database/pg.d.ts +20 -0
  47. package/dist/database/pg.js +19 -0
  48. package/dist/database/prepare-database.d.ts +39 -0
  49. package/dist/database/prepare-database.js +75 -0
  50. package/dist/database/run-exclusive.d.ts +19 -0
  51. package/dist/database/run-exclusive.js +62 -0
  52. package/dist/database/tenant.d.ts +34 -0
  53. package/dist/database/tenant.js +28 -0
  54. package/dist/http/api/api.decorator.d.ts +18 -0
  55. package/dist/http/api/api.decorator.js +9 -0
  56. package/dist/http/api/openapi-document.d.ts +40 -0
  57. package/dist/http/api/openapi-document.js +452 -0
  58. package/dist/http/bootstrap-service.d.ts +33 -0
  59. package/dist/http/bootstrap-service.js +107 -0
  60. package/dist/http/create-http-app.d.ts +38 -0
  61. package/dist/http/create-http-app.js +137 -0
  62. package/dist/http/errors.d.ts +118 -0
  63. package/dist/http/errors.js +94 -0
  64. package/dist/http/index.d.ts +12 -0
  65. package/dist/http/index.js +8 -0
  66. package/dist/http/kit-exception.filter.d.ts +18 -0
  67. package/dist/http/kit-exception.filter.js +151 -0
  68. package/dist/http/kit-http-core.module.d.ts +19 -0
  69. package/dist/http/kit-http-core.module.js +122 -0
  70. package/dist/http/pagination.d.ts +30 -0
  71. package/dist/http/pagination.js +73 -0
  72. package/dist/http/zod-validation.pipe.d.ts +15 -0
  73. package/dist/http/zod-validation.pipe.js +47 -0
  74. package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
  75. package/dist/idempotency/idempotency.interceptor.js +317 -0
  76. package/dist/idempotency/idempotent.decorator.d.ts +7 -0
  77. package/dist/idempotency/idempotent.decorator.js +16 -0
  78. package/dist/idempotency/index.d.ts +7 -0
  79. package/dist/idempotency/index.js +6 -0
  80. package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
  81. package/dist/idempotency/kit-idempotency.module.js +173 -0
  82. package/dist/idempotency/peer.d.ts +1 -0
  83. package/dist/idempotency/peer.js +4 -0
  84. package/dist/idempotency/table.d.ts +203 -0
  85. package/dist/idempotency/table.js +86 -0
  86. package/dist/internal/cleanup-result.d.ts +7 -0
  87. package/dist/internal/cleanup-result.js +12 -0
  88. package/dist/internal/ids.d.ts +13 -0
  89. package/dist/internal/ids.js +41 -0
  90. package/dist/internal/metadata.d.ts +12 -0
  91. package/dist/internal/metadata.js +3 -0
  92. package/dist/internal/outbox-policy.d.ts +8 -0
  93. package/dist/internal/outbox-policy.js +10 -0
  94. package/dist/internal/peer.d.ts +16 -0
  95. package/dist/internal/peer.js +56 -0
  96. package/dist/internal/pg-errors.d.ts +21 -0
  97. package/dist/internal/pg-errors.js +115 -0
  98. package/dist/internal/preparsed-env.d.ts +4 -0
  99. package/dist/internal/preparsed-env.js +14 -0
  100. package/dist/internal/rabbitmq-definitions.d.ts +85 -0
  101. package/dist/internal/rabbitmq-definitions.js +73 -0
  102. package/dist/internal/redis-acl.d.ts +21 -0
  103. package/dist/internal/redis-acl.js +30 -0
  104. package/dist/internal/service-name.d.ts +4 -0
  105. package/dist/internal/service-name.js +10 -0
  106. package/dist/internal/sql-identifier.d.ts +5 -0
  107. package/dist/internal/sql-identifier.js +12 -0
  108. package/dist/internal/tokens.d.ts +10 -0
  109. package/dist/internal/tokens.js +12 -0
  110. package/dist/internal/tx-context.d.ts +5 -0
  111. package/dist/internal/tx-context.js +9 -0
  112. package/dist/messaging/cleanup.d.ts +49 -0
  113. package/dist/messaging/cleanup.js +129 -0
  114. package/dist/messaging/config.d.ts +43 -0
  115. package/dist/messaging/config.js +8 -0
  116. package/dist/messaging/define-event.d.ts +29 -0
  117. package/dist/messaging/define-event.js +38 -0
  118. package/dist/messaging/envelope.d.ts +66 -0
  119. package/dist/messaging/envelope.js +49 -0
  120. package/dist/messaging/errors.d.ts +45 -0
  121. package/dist/messaging/errors.js +63 -0
  122. package/dist/messaging/inbox.d.ts +4 -0
  123. package/dist/messaging/inbox.js +9 -0
  124. package/dist/messaging/index.d.ts +13 -0
  125. package/dist/messaging/index.js +10 -0
  126. package/dist/messaging/kit-messaging.module.d.ts +39 -0
  127. package/dist/messaging/kit-messaging.module.js +309 -0
  128. package/dist/messaging/outbox-relay.d.ts +67 -0
  129. package/dist/messaging/outbox-relay.js +260 -0
  130. package/dist/messaging/outbox-store.d.ts +75 -0
  131. package/dist/messaging/outbox-store.js +126 -0
  132. package/dist/messaging/peer.d.ts +1 -0
  133. package/dist/messaging/peer.js +5 -0
  134. package/dist/messaging/rabbit-connection.d.ts +144 -0
  135. package/dist/messaging/rabbit-connection.js +472 -0
  136. package/dist/messaging/redelivery.d.ts +6 -0
  137. package/dist/messaging/redelivery.js +17 -0
  138. package/dist/messaging/subscriber.d.ts +102 -0
  139. package/dist/messaging/subscriber.js +412 -0
  140. package/dist/messaging/tables.d.ts +315 -0
  141. package/dist/messaging/tables.js +72 -0
  142. package/dist/messaging/topology.d.ts +56 -0
  143. package/dist/messaging/topology.js +56 -0
  144. package/dist/observability/fail-startup.d.ts +14 -0
  145. package/dist/observability/fail-startup.js +51 -0
  146. package/dist/observability/health-registry.d.ts +34 -0
  147. package/dist/observability/health-registry.js +81 -0
  148. package/dist/observability/index.d.ts +16 -0
  149. package/dist/observability/index.js +10 -0
  150. package/dist/observability/lifecycle.d.ts +52 -0
  151. package/dist/observability/lifecycle.js +151 -0
  152. package/dist/observability/logger-options.d.ts +54 -0
  153. package/dist/observability/logger-options.js +91 -0
  154. package/dist/observability/metrics-server.d.ts +33 -0
  155. package/dist/observability/metrics-server.js +108 -0
  156. package/dist/observability/metrics.d.ts +30 -0
  157. package/dist/observability/metrics.js +53 -0
  158. package/dist/observability/redaction.d.ts +23 -0
  159. package/dist/observability/redaction.js +65 -0
  160. package/dist/observability/sanitize-error.d.ts +47 -0
  161. package/dist/observability/sanitize-error.js +194 -0
  162. package/dist/observability/sentry.d.ts +64 -0
  163. package/dist/observability/sentry.js +233 -0
  164. package/dist/redis/channels.d.ts +34 -0
  165. package/dist/redis/channels.js +128 -0
  166. package/dist/redis/client.d.ts +42 -0
  167. package/dist/redis/client.js +85 -0
  168. package/dist/redis/index.d.ts +7 -0
  169. package/dist/redis/index.js +7 -0
  170. package/dist/redis/kit-redis.module.d.ts +18 -0
  171. package/dist/redis/kit-redis.module.js +131 -0
  172. package/dist/redis/peer.d.ts +1 -0
  173. package/dist/redis/peer.js +4 -0
  174. package/dist/redis/rate-limiter.d.ts +49 -0
  175. package/dist/redis/rate-limiter.js +139 -0
  176. package/dist/testing/count-queries.d.ts +30 -0
  177. package/dist/testing/count-queries.js +58 -0
  178. package/dist/testing/create-test-app.d.ts +26 -0
  179. package/dist/testing/create-test-app.js +32 -0
  180. package/dist/testing/expect-matches-api.d.ts +8 -0
  181. package/dist/testing/expect-matches-api.js +49 -0
  182. package/dist/testing/expect-problem.d.ts +24 -0
  183. package/dist/testing/expect-problem.js +23 -0
  184. package/dist/testing/images.d.ts +10 -0
  185. package/dist/testing/images.js +14 -0
  186. package/dist/testing/index.d.ts +23 -0
  187. package/dist/testing/index.js +17 -0
  188. package/dist/testing/launch-command.d.ts +52 -0
  189. package/dist/testing/launch-command.js +510 -0
  190. package/dist/testing/outbox-coverage.d.ts +26 -0
  191. package/dist/testing/outbox-coverage.js +98 -0
  192. package/dist/testing/rabbitmq-admin.d.ts +81 -0
  193. package/dist/testing/rabbitmq-admin.js +146 -0
  194. package/dist/testing/redis-worker.d.ts +10 -0
  195. package/dist/testing/redis-worker.js +15 -0
  196. package/dist/testing/repo-conventions.d.ts +10 -0
  197. package/dist/testing/repo-conventions.js +524 -0
  198. package/dist/testing/repo-files.d.ts +113 -0
  199. package/dist/testing/repo-files.js +401 -0
  200. package/dist/testing/scratch-database.d.ts +19 -0
  201. package/dist/testing/scratch-database.js +75 -0
  202. package/dist/testing/start-test-stack.d.ts +85 -0
  203. package/dist/testing/start-test-stack.js +342 -0
  204. package/dist/testing/tcp-proxy.d.ts +40 -0
  205. package/dist/testing/tcp-proxy.js +164 -0
  206. package/dist/testing/tenant-isolation.d.ts +31 -0
  207. package/dist/testing/tenant-isolation.js +205 -0
  208. package/dist/testing/test-events.d.ts +37 -0
  209. package/dist/testing/test-events.js +150 -0
  210. package/dist/testing/timeout-scale.d.ts +4 -0
  211. package/dist/testing/timeout-scale.js +17 -0
  212. package/dist/testing/timing-guard.d.ts +22 -0
  213. package/dist/testing/timing-guard.js +54 -0
  214. package/dist/testing/worker-scope.d.ts +31 -0
  215. package/dist/testing/worker-scope.js +68 -0
  216. package/package.json +177 -4
@@ -0,0 +1,342 @@
1
+ // startTestStack — globalSetup Vitest: контейнеры TEST_IMAGES раз на прогон, адреса через provide (prd.md §3.8, E1;
2
+ // AC-1.12, AC-1.13). Эталоны — O test/support/global-setup.ts (minio, provide, teardown), I global-setup.ts (mailpit),
3
+ // O setup-error.ts (текст ошибки в stderr до throw: Vite теряет текст ошибок globalSetup).
4
+ // E4: при postgres — prepareDatabase и шаблон <svc>_template; migrations — runMigrations до копии.
5
+ // Код БД — динамический import() (без pg /testing импортируется).
6
+ // E5: rabbitmq — definitions.json (internal/rabbitmq-definitions, как CLI), vhost и пользователь на воркер (эталон B
7
+ // test/support/broker.ts), удаление по префиксу <svc>-test-.
8
+ // E8: redis — aclfile из internal/redis-acl (как CLI redis acl) + администратор теста; пользователей на воркер нет.
9
+ import { randomBytes } from 'node:crypto';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { availableParallelism, tmpdir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
13
+ import { loadPeer } from '../internal/peer.js';
14
+ import { assertServiceName } from '../internal/service-name.js';
15
+ import { buildRedisAclFile, redisPasswordHash } from '../internal/redis-acl.js';
16
+ import { quoteIdentifier } from '../internal/sql-identifier.js';
17
+ import { TEST_IMAGES } from './images.js';
18
+ import { assertOutboxCoverage, setCoverageDir } from './outbox-coverage.js';
19
+ import { deleteTestVhosts, prepareWorkerVhosts, RabbitAdmin, } from './rabbitmq-admin.js';
20
+ const { GenericContainer, Wait } = await loadPeer('testcontainers', 'testing');
21
+ const MINIO_USER = 'kit-test';
22
+ const RABBIT_ADMIN = 'kit-admin';
23
+ const REDIS_ADMIN = 'kit-test-admin';
24
+ const REDIS_ACL_PATH = '/usr/local/etc/redis/users.acl';
25
+ /** Ошибка шага подготовки БД: в stderr — имя шага и код, без текста pg (адрес, имя роли); у migrate — и текст SQL. */
26
+ class PrepareStepError extends Error {
27
+ constructor(step, cause) {
28
+ const code = cause?.code;
29
+ const detail = step === 'migrate' ? `: ${errorMessage(cause)}` : '';
30
+ super(`шаг ${step}, код ${typeof code === 'string' ? code : 'нет'}${detail}`, { cause });
31
+ }
32
+ }
33
+ async function step(name, work) {
34
+ try {
35
+ return await work();
36
+ }
37
+ catch (error) {
38
+ throw new PrepareStepError(name, error);
39
+ }
40
+ }
41
+ function withDatabase(base, database, user, password) {
42
+ const url = new URL(base);
43
+ url.pathname = `/${database}`;
44
+ if (user !== undefined && password !== undefined) {
45
+ url.username = user;
46
+ url.password = password;
47
+ }
48
+ return url.toString();
49
+ }
50
+ async function adminExec(pg, url, sql) {
51
+ const client = new pg.Client({ connectionString: url });
52
+ client.on('error', () => undefined);
53
+ await client.connect();
54
+ try {
55
+ await client.query(sql);
56
+ }
57
+ finally {
58
+ await client.end();
59
+ }
60
+ }
61
+ /**
62
+ * Подготовка БД стека (§3.8, AC-4.18): шаблон `<svc>_template` — prepareDatabase, миграции; копия `<svc>` — права
63
+ * уровня БД заново (при копировании не переносятся).
64
+ */
65
+ async function prepareStackDatabase(adminUrl, service, migrations, project) {
66
+ const pg = (await loadPeer('pg', 'testing')).default;
67
+ const [{ prepareDatabase, grantDatabaseAccess }, { runMigrations }] = await Promise.all([
68
+ import('../database/prepare-database.js'),
69
+ import('../database/migrations.js'),
70
+ ]);
71
+ const ownerPassword = randomBytes(16).toString('hex');
72
+ const appPassword = randomBytes(16).toString('hex');
73
+ const template = `${service}_template`;
74
+ const templateAdmin = withDatabase(adminUrl, template);
75
+ await step('create-template', () => adminExec(pg, adminUrl, `CREATE DATABASE ${quoteIdentifier(template)}`));
76
+ await step('prepare', () => prepareDatabase({ service, adminUrl: templateAdmin, ownerPassword, appPassword }));
77
+ if (migrations !== undefined) {
78
+ await step('migrate', () => runMigrations({
79
+ url: withDatabase(adminUrl, template, `${service}_owner`, ownerPassword),
80
+ folder: resolve(migrations),
81
+ schema: service,
82
+ }));
83
+ }
84
+ await step('copy', async () => {
85
+ await adminExec(pg, adminUrl, `CREATE DATABASE ${quoteIdentifier(service)} TEMPLATE ${quoteIdentifier(template)}`);
86
+ const client = new pg.Client({ connectionString: adminUrl });
87
+ client.on('error', () => undefined);
88
+ await client.connect();
89
+ try {
90
+ await grantDatabaseAccess(client, service, service);
91
+ }
92
+ finally {
93
+ await client.end();
94
+ }
95
+ });
96
+ project.provide('kitDatabaseUrl', withDatabase(adminUrl, service, `${service}_app`, appPassword));
97
+ project.provide('kitDatabaseOwnerUrl', withDatabase(adminUrl, service, `${service}_owner`, ownerPassword));
98
+ project.provide('kitDatabaseAdminUrl', withDatabase(adminUrl, service));
99
+ project.provide('kitDatabaseService', service);
100
+ project.provide('kitDatabaseTemplate', template);
101
+ }
102
+ function postgresPart(labels, service, migrations) {
103
+ const password = randomBytes(16).toString('hex');
104
+ return {
105
+ name: 'postgres',
106
+ start: () => new GenericContainer(TEST_IMAGES.postgres)
107
+ .withLabels(labels)
108
+ .withEnvironment({
109
+ POSTGRES_USER: 'postgres',
110
+ POSTGRES_PASSWORD: password,
111
+ POSTGRES_DB: 'postgres',
112
+ })
113
+ .withExposedPorts(5432)
114
+ .withTmpFs({ '/var/lib/postgresql/data': 'rw' })
115
+ .withCommand([
116
+ 'postgres',
117
+ '-c',
118
+ 'fsync=off',
119
+ '-c',
120
+ 'synchronous_commit=off',
121
+ '-c',
122
+ 'full_page_writes=off',
123
+ ])
124
+ // Сообщение готовности выводится дважды: init-скрипт и рабочий старт.
125
+ .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2))
126
+ .withStartupTimeout(120_000)
127
+ .start(),
128
+ provide: async (container, project) => {
129
+ const host = container.getHost();
130
+ const port = container.getMappedPort(5432);
131
+ const url = `postgres://postgres:${password}@${host}:${port}/postgres`;
132
+ project.provide('kitPostgresUrl', url);
133
+ await prepareStackDatabase(url, service, migrations, project);
134
+ },
135
+ };
136
+ }
137
+ function mailpitPart(labels) {
138
+ return {
139
+ name: 'mailpit',
140
+ start: () => new GenericContainer(TEST_IMAGES.mailpit)
141
+ .withLabels(labels)
142
+ .withExposedPorts(1025, 8025)
143
+ .withWaitStrategy(Wait.forHttp('/livez', 8025).forStatusCode(200))
144
+ .withStartupTimeout(120_000)
145
+ .start(),
146
+ provide: (container, project) => {
147
+ const host = container.getHost();
148
+ project.provide('kitSmtpUrl', `smtp://${host}:${container.getMappedPort(1025)}`);
149
+ project.provide('kitMailpitUrl', `http://${host}:${container.getMappedPort(8025)}`);
150
+ },
151
+ };
152
+ }
153
+ function minioPart(labels) {
154
+ const password = randomBytes(16).toString('hex');
155
+ return {
156
+ name: 'minio',
157
+ start: () => new GenericContainer(TEST_IMAGES.minio)
158
+ .withLabels(labels)
159
+ .withEnvironment({ MINIO_ROOT_USER: MINIO_USER, MINIO_ROOT_PASSWORD: password })
160
+ .withExposedPorts(9000)
161
+ .withCommand(['server', '/data'])
162
+ .withWaitStrategy(Wait.forHttp('/minio/health/live', 9000))
163
+ .withStartupTimeout(120_000)
164
+ .start(),
165
+ provide: (container, project) => {
166
+ project.provide('kitS3Endpoint', `http://${container.getHost()}:${container.getMappedPort(9000)}`);
167
+ project.provide('kitS3AccessKey', MINIO_USER);
168
+ project.provide('kitS3SecretKey', password);
169
+ },
170
+ };
171
+ }
172
+ /** Число воркеров прогона: `maxWorkers` проектов (число ≥ 1), иначе `os.availableParallelism()`. */
173
+ export function maxWorkersOf(configs) {
174
+ const counts = configs
175
+ .map(({ maxWorkers }) => maxWorkers)
176
+ .filter((value) => typeof value === 'number' && value >= 1);
177
+ return counts.length === 0 ? Math.max(1, availableParallelism()) : Math.max(...counts);
178
+ }
179
+ function rabbitmqPart(labels, service) {
180
+ const password = randomBytes(16).toString('hex');
181
+ const prefix = `${service}-test-`;
182
+ let admin;
183
+ return {
184
+ name: 'rabbitmq',
185
+ start: () => new GenericContainer(TEST_IMAGES.rabbitmq)
186
+ .withLabels(labels)
187
+ .withEnvironment({
188
+ RABBITMQ_DEFAULT_USER: RABBIT_ADMIN,
189
+ RABBITMQ_DEFAULT_PASS: password,
190
+ })
191
+ .withExposedPorts(5672, 15672)
192
+ .withWaitStrategy(Wait.forLogMessage(/Server startup complete/))
193
+ .withStartupTimeout(120_000)
194
+ .start(),
195
+ provide: async (container, project) => {
196
+ const host = container.getHost();
197
+ const url = `amqp://${RABBIT_ADMIN}:${password}@${host}:${container.getMappedPort(5672)}`;
198
+ const managementUrl = `http://${host}:${container.getMappedPort(15672)}/`;
199
+ admin = new RabbitAdmin(managementUrl, RABBIT_ADMIN, password);
200
+ await admin.waitReady();
201
+ const workers = maxWorkersOf([
202
+ project.config,
203
+ ...project.vitest.projects.map((item) => item.config),
204
+ ]);
205
+ const prepared = await prepareWorkerVhosts(admin, {
206
+ service,
207
+ workers,
208
+ runId: randomBytes(4).toString('hex'),
209
+ amqpAdminUrl: url,
210
+ });
211
+ if (prepared.removed.length > 0) {
212
+ process.stderr.write(`[startTestStack] удалены vhost прошлых прогонов: ${prepared.removed.join(', ')}\n`);
213
+ }
214
+ project.provide('kitRabbitmqUrl', url);
215
+ project.provide('kitRabbitmqManagementUrl', managementUrl);
216
+ project.provide('kitRabbitmqWorkers', prepared.workers);
217
+ },
218
+ teardown: async () => {
219
+ if (admin === undefined) {
220
+ return;
221
+ }
222
+ const { vhosts, users } = await deleteTestVhosts(admin, prefix);
223
+ process.stderr.write(`[startTestStack] RabbitMQ: удалено vhost ${vhosts}, пользователей ${users}\n`);
224
+ },
225
+ };
226
+ }
227
+ function redisPart(labels, service) {
228
+ const password = randomBytes(16).toString('hex');
229
+ const adminPassword = randomBytes(16).toString('hex');
230
+ const aclfile = buildRedisAclFile({
231
+ users: [{ service, passwordHash: redisPasswordHash(password) }],
232
+ extra: [`user ${REDIS_ADMIN} on #${redisPasswordHash(adminPassword)} ~* &* +@all`],
233
+ });
234
+ return {
235
+ name: 'redis',
236
+ start: () => new GenericContainer(TEST_IMAGES.redis)
237
+ .withLabels(labels)
238
+ .withCopyContentToContainer([
239
+ { content: aclfile, target: REDIS_ACL_PATH, mode: 0o644 },
240
+ ])
241
+ .withCommand([
242
+ 'redis-server',
243
+ '--aclfile',
244
+ REDIS_ACL_PATH,
245
+ '--save',
246
+ '',
247
+ '--appendonly',
248
+ 'no',
249
+ ])
250
+ .withExposedPorts(6379)
251
+ .withWaitStrategy(Wait.forLogMessage(/Ready to accept connections/))
252
+ .withStartupTimeout(120_000)
253
+ .start(),
254
+ provide: (container, project) => {
255
+ const address = `${container.getHost()}:${container.getMappedPort(6379)}`;
256
+ project.provide('kitRedisUrl', `redis://${service}:${password}@${address}`);
257
+ project.provide('kitRedisAdminUrl', `redis://${REDIS_ADMIN}:${adminPassword}@${address}`);
258
+ project.provide('kitRedisService', service);
259
+ },
260
+ };
261
+ }
262
+ function errorMessage(reason) {
263
+ return reason instanceof Error ? reason.message : String(reason);
264
+ }
265
+ /** globalSetup Vitest: `export default startTestStack({...})`; teardown останавливает контейнеры (§3.8). */
266
+ export function startTestStack(options) {
267
+ assertServiceName(options.service, 'startTestStack');
268
+ if (options.migrations !== undefined && options.postgres !== true) {
269
+ throw new TypeError('startTestStack: migrations требует postgres: true');
270
+ }
271
+ const labels = { 'verdeect.kit.service': options.service };
272
+ return async (project) => {
273
+ const parts = [
274
+ ...(options.postgres === true
275
+ ? [postgresPart(labels, options.service, options.migrations)]
276
+ : []),
277
+ ...(options.mailpit === true ? [mailpitPart(labels)] : []),
278
+ ...(options.minio === true ? [minioPart(labels)] : []),
279
+ ...(options.rabbitmq === true ? [rabbitmqPart(labels, options.service)] : []),
280
+ ...(options.redis === true ? [redisPart(labels, options.service)] : []),
281
+ ];
282
+ const results = await Promise.allSettled(parts.map((part) => part.start()));
283
+ const started = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
284
+ const failed = parts.filter((_, index) => results[index]?.status === 'rejected');
285
+ const firstFailure = results.find((result) => result.status === 'rejected');
286
+ if (firstFailure !== undefined) {
287
+ await Promise.allSettled(started.map((container) => container.stop()));
288
+ const names = failed.map((part) => part.name).join(', ');
289
+ const message = `[startTestStack] не удалось запустить ${names}: ${errorMessage(firstFailure.reason)}`;
290
+ process.stderr.write(`${message}\n`);
291
+ throw new Error(message, { cause: firstFailure.reason });
292
+ }
293
+ let preparing = '';
294
+ try {
295
+ for (const [index, part] of parts.entries()) {
296
+ const container = started[index];
297
+ if (container !== undefined) {
298
+ preparing = part.name;
299
+ await part.provide(container, project);
300
+ }
301
+ }
302
+ }
303
+ catch (error) {
304
+ await Promise.allSettled(started.map((container) => container.stop()));
305
+ const message = `[startTestStack] не удалось подготовить ${preparing}: ${errorMessage(error)}`;
306
+ process.stderr.write(`${message}\n`);
307
+ throw new Error(message, {
308
+ cause: error,
309
+ });
310
+ }
311
+ const coverage = options.outboxEvents === undefined
312
+ ? undefined
313
+ : mkdtempSync(join(tmpdir(), `kit-outbox-${options.service}-`));
314
+ if (coverage !== undefined) {
315
+ setCoverageDir(coverage);
316
+ project.provide('kitOutboxCoverageDir', coverage);
317
+ }
318
+ return async () => {
319
+ try {
320
+ await stopParts();
321
+ // После освобождения ресурсов: ошибка покрытия выходит из teardown.
322
+ if (options.outboxEvents !== undefined && process.env.KIT_OUTBOX_COVERAGE === '1') {
323
+ assertOutboxCoverage(options.outboxEvents);
324
+ }
325
+ }
326
+ finally {
327
+ setCoverageDir(undefined);
328
+ if (coverage !== undefined) {
329
+ rmSync(coverage, { recursive: true, force: true });
330
+ }
331
+ }
332
+ };
333
+ async function stopParts() {
334
+ for (const part of parts) {
335
+ await part.teardown?.().catch((error) => {
336
+ process.stderr.write(`[startTestStack] очистка ${part.name} не удалась: ${errorMessage(error)}\n`);
337
+ });
338
+ }
339
+ await Promise.all(started.map((container) => container.stop()));
340
+ }
341
+ };
342
+ }
@@ -0,0 +1,40 @@
1
+ /** TCP-прокси с постоянным локальным портом на 127.0.0.1 (§3.8). */
2
+ export declare class TcpProxy {
3
+ private server;
4
+ private readonly sockets;
5
+ /** Пары клиент → upstream с пересылкой (для freeze). */
6
+ private readonly links;
7
+ private port;
8
+ private blackholed;
9
+ private trigger;
10
+ private delay;
11
+ private readonly target;
12
+ /** upstreamUrl — URL цели с портом или схемой из DEFAULT_PORTS. */
13
+ constructor(upstreamUrl: string);
14
+ /** Исходный URL с хостом и портом прокси (учётные данные — как у исходного). */
15
+ get url(): string;
16
+ /** Начать слушать (порт выбирает ОС при первом запуске). */
17
+ start(): Promise<this>;
18
+ /** Разорвать все соединения и перестать слушать; новые соединения отклоняются. */
19
+ break(): Promise<void>;
20
+ /** Потеря пакетов: соединение устанавливается, но ответа нет (не «connection refused»). */
21
+ blackhole(): void;
22
+ /** Перестать пересылать байты, не закрывая соединений. */
23
+ freeze(): void;
24
+ /** blackhole() в момент, когда клиент отправит пакет, содержащий pattern (один раз). */
25
+ blackholeOn(pattern: RegExp): void;
26
+ /**
27
+ * Задержать пакеты клиента с совпадением на ms (первые count раз); для соединений после вызова:
28
+ * режим пересылки выбирается при приёме соединения, уже открытые соединения не меняются.
29
+ */
30
+ delayOn(pattern: RegExp, ms: number, count?: number): void;
31
+ /** Снять blackhole и обрыв, снова слушать тот же порт. */
32
+ restore(): Promise<void>;
33
+ /** Закрыть прокси и освободить порт. */
34
+ close(): Promise<void>;
35
+ private delayFor;
36
+ private destroySockets;
37
+ private listen;
38
+ }
39
+ /** Запущенный TCP-прокси к target (URL с портом или схемой из DEFAULT_PORTS). */
40
+ export declare function tcpProxy(target: string): Promise<TcpProxy>;
@@ -0,0 +1,164 @@
1
+ // TCP-прокси для обрывов и задержек в тестах (prd.md §3.8, E1); эталон — O test/support/tcp-proxy.ts (029c25c).
2
+ // Без BrokerState/brokerReconnected и waitFor (только для брокера, при необходимости — E5).
3
+ // break() рвёт все соединения и перестаёт слушать, restore() — снова слушает тот же порт. blackhole() — потеря пакетов:
4
+ // рвёт текущие соединения, новые принимает, но ничего не передаёт. blackholeOn(pattern) — обрыв в момент запроса.
5
+ // delayOn(pattern, ms, count) — пакет клиента с совпадением уходит на сервер через ms (первые count раз); действует на
6
+ // соединения, принятые после вызова. freeze() — соединения остаются открытыми, но байты больше не пересылаются.
7
+ import { createServer, connect as tcpConnect } from 'node:net';
8
+ /** Порт цели по схеме URL, если порт не указан. */
9
+ const DEFAULT_PORTS = {
10
+ 'postgres:': 5432,
11
+ 'postgresql:': 5432,
12
+ 'amqp:': 5672,
13
+ 'redis:': 6379,
14
+ 'http:': 80,
15
+ };
16
+ /** TCP-прокси с постоянным локальным портом на 127.0.0.1 (§3.8). */
17
+ export class TcpProxy {
18
+ server = null;
19
+ sockets = new Set();
20
+ /** Пары клиент → upstream с пересылкой (для freeze). */
21
+ links = new Set();
22
+ port = 0;
23
+ blackholed = false;
24
+ trigger = null;
25
+ delay = null;
26
+ target;
27
+ /** upstreamUrl — URL цели с портом или схемой из DEFAULT_PORTS. */
28
+ constructor(upstreamUrl) {
29
+ this.target = new URL(upstreamUrl);
30
+ }
31
+ /** Исходный URL с хостом и портом прокси (учётные данные — как у исходного). */
32
+ get url() {
33
+ const url = new URL(this.target.href);
34
+ url.hostname = '127.0.0.1';
35
+ url.port = String(this.port);
36
+ return url.href;
37
+ }
38
+ /** Начать слушать (порт выбирает ОС при первом запуске). */
39
+ async start() {
40
+ await this.listen(this.port);
41
+ return this;
42
+ }
43
+ /** Разорвать все соединения и перестать слушать; новые соединения отклоняются. */
44
+ async break() {
45
+ const server = this.server;
46
+ this.server = null;
47
+ this.destroySockets();
48
+ if (server !== null) {
49
+ await new Promise((resolve) => server.close(() => resolve()));
50
+ }
51
+ }
52
+ /** Потеря пакетов: соединение устанавливается, но ответа нет (не «connection refused»). */
53
+ blackhole() {
54
+ this.blackholed = true;
55
+ this.destroySockets();
56
+ }
57
+ /** Перестать пересылать байты, не закрывая соединений. */
58
+ freeze() {
59
+ this.blackholed = true;
60
+ for (const [client, upstream] of this.links) {
61
+ client.unpipe(upstream);
62
+ upstream.unpipe(client);
63
+ }
64
+ this.links.clear();
65
+ }
66
+ /** blackhole() в момент, когда клиент отправит пакет, содержащий pattern (один раз). */
67
+ blackholeOn(pattern) {
68
+ this.trigger = pattern;
69
+ }
70
+ /**
71
+ * Задержать пакеты клиента с совпадением на ms (первые count раз); для соединений после вызова:
72
+ * режим пересылки выбирается при приёме соединения, уже открытые соединения не меняются.
73
+ */
74
+ delayOn(pattern, ms, count = 1) {
75
+ this.delay = { pattern, ms, left: count };
76
+ }
77
+ /** Снять blackhole и обрыв, снова слушать тот же порт. */
78
+ async restore() {
79
+ this.blackholed = false;
80
+ this.trigger = null;
81
+ if (this.server === null) {
82
+ await this.listen(this.port);
83
+ }
84
+ }
85
+ /** Закрыть прокси и освободить порт. */
86
+ async close() {
87
+ await this.break();
88
+ }
89
+ delayFor(chunk) {
90
+ const rule = this.delay;
91
+ if (rule === null || rule.left <= 0 || !rule.pattern.test(chunk.toString('latin1'))) {
92
+ return 0;
93
+ }
94
+ rule.left -= 1;
95
+ return rule.ms;
96
+ }
97
+ destroySockets() {
98
+ for (const socket of this.sockets) {
99
+ socket.destroy();
100
+ }
101
+ this.sockets.clear();
102
+ this.links.clear();
103
+ }
104
+ async listen(port) {
105
+ const upstreamPort = Number(this.target.port) || (DEFAULT_PORTS[this.target.protocol] ?? 0);
106
+ const server = createServer((client) => {
107
+ if (this.blackholed) {
108
+ this.sockets.add(client);
109
+ client.on('error', () => client.destroy());
110
+ return;
111
+ }
112
+ const upstream = tcpConnect(upstreamPort, this.target.hostname);
113
+ this.sockets.add(client);
114
+ this.sockets.add(upstream);
115
+ const link = [client, upstream];
116
+ this.links.add(link);
117
+ if (this.delay === null) {
118
+ client.pipe(upstream).pipe(client);
119
+ }
120
+ else {
121
+ // Ручная пересылка в исходном порядке: задержанный пакет держит и следующие за ним.
122
+ upstream.pipe(client);
123
+ let queue = Promise.resolve();
124
+ client.on('data', (chunk) => {
125
+ const ms = this.delayFor(chunk);
126
+ queue = queue.then(async () => {
127
+ if (ms > 0) {
128
+ await new Promise((resolve) => setTimeout(resolve, ms));
129
+ }
130
+ if (this.links.has(link) && !upstream.destroyed) {
131
+ upstream.write(chunk);
132
+ }
133
+ });
134
+ });
135
+ }
136
+ // pipe уже передал пакет: сокеты закрываются синхронно, до ответа сервера.
137
+ client.on('data', (chunk) => {
138
+ if (this.trigger !== null && this.trigger.test(chunk.toString('latin1'))) {
139
+ this.trigger = null;
140
+ this.blackhole();
141
+ }
142
+ });
143
+ const drop = () => {
144
+ client.destroy();
145
+ upstream.destroy();
146
+ this.sockets.delete(client);
147
+ this.sockets.delete(upstream);
148
+ this.links.delete(link);
149
+ };
150
+ client.on('error', drop).on('close', drop);
151
+ upstream.on('error', drop).on('close', drop);
152
+ });
153
+ await new Promise((resolve, reject) => {
154
+ server.once('error', reject);
155
+ server.listen(port, '127.0.0.1', () => resolve());
156
+ });
157
+ this.port = server.address().port;
158
+ this.server = server;
159
+ }
160
+ }
161
+ /** Запущенный TCP-прокси к target (URL с портом или схемой из DEFAULT_PORTS). */
162
+ export async function tcpProxy(target) {
163
+ return new TcpProxy(target).start();
164
+ }
@@ -0,0 +1,31 @@
1
+ /** Соединение с методом `query` (`pg.Pool`, `pg.Client`, `PoolClient`). */
2
+ export interface KitQueryable {
3
+ /** Запрос с параметрами; строки результата. */
4
+ query(text: string, values?: unknown[]): Promise<{
5
+ rows: unknown[];
6
+ }>;
7
+ }
8
+ /**
9
+ * Роль соединения — роль приложения, а не владелец (AC-4.6): не суперпользователь, без `BYPASSRLS`, не владеет
10
+ * схемами и таблицами, без `CREATE` на базе и схемах (KT-19 п. 1).
11
+ * @throws {Error} «assertAppRoleIsNotOwner: <роль>: <нарушения через «; »>».
12
+ */
13
+ export declare function assertAppRoleIsNotOwner(db: KitQueryable): Promise<void>;
14
+ /** Опции `assertStrictTenantIsolation`. */
15
+ export interface KitTenantIsolationOptions {
16
+ /** Схема сервиса. */
17
+ readonly schema: string;
18
+ /**
19
+ * Таблицы, которые проверка пропускает целиком: только таблицы, где колонка `tenant` не означает компанию; каждое
20
+ * исключение — с причиной в тесте сервиса.
21
+ */
22
+ readonly except?: readonly string[];
23
+ }
24
+ /**
25
+ * Строгий RLS схемы сервиса (§3.8, AC-4.8) — соединение **роли приложения**: каждая таблица с колонкой `tenant` — RLS,
26
+ * `FORCE`, строгая политика (`tenantPolicy`; `idempotency_key` — `createIdempotencyTable`); `outbox` — исключение строгой
27
+ * модели: политика `createOutboxTable` (пустой тенант — все строки), RLS и FORCE обязательны; под пустым тенантом 0 строк (вызывать после вставки данных фабриками); при таблице идемпотентности —
28
+ * функция `idempotency_purge` `SECURITY DEFINER`, владелец `<svc>_directory`, `search_path`, без `EXECUTE` у `PUBLIC`.
29
+ * @throws {Error} «assertStrictTenantIsolation(<схема>): <нарушения через «; »>».
30
+ */
31
+ export declare function assertStrictTenantIsolation(db: KitQueryable, options: KitTenantIsolationOptions): Promise<void>;