feat(M02-A): 建立多小程序租户配置模型
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -4,9 +4,14 @@ import rateLimit from '@fastify/rate-limit';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { loadConfig, type AppConfig } from './config.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import {
|
||||
registerPlatformBootstrapRoutes,
|
||||
type PlatformConfigResolver
|
||||
} from './routes/platform-bootstrap.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
platformConfigRepository?: PlatformConfigResolver;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -49,6 +54,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
});
|
||||
|
||||
await registerHealthRoutes(app, config);
|
||||
if (options.platformConfigRepository) {
|
||||
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,16 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
up: [
|
||||
'database/migrations/2026061601_m01b_core_schema.up.sql',
|
||||
'database/migrations/2026061802_m01c_async_tasks.up.sql'
|
||||
'database/migrations/2026061802_m01c_async_tasks.up.sql',
|
||||
'database/migrations/2026061803_m02a_tenant_apps.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
'database/migrations/2026061802_m01c_async_tasks.verify.sql'
|
||||
'database/migrations/2026061802_m01c_async_tasks.verify.sql',
|
||||
'database/migrations/2026061803_m02a_tenant_apps.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026061803_m02a_tenant_apps.down.sql',
|
||||
'database/migrations/2026061802_m01c_async_tasks.down.sql',
|
||||
'database/migrations/2026061601_m01b_core_schema.down.sql'
|
||||
]
|
||||
@@ -146,7 +149,7 @@ export async function executeMigrationPlan(
|
||||
for (const [index, statement] of plan.statements.entries()) {
|
||||
const [result] = await pool.query(statement);
|
||||
if (plan.direction === 'verify') {
|
||||
const minimumRows = [10, 26, 1, 2, 5, 1][index] ?? 1;
|
||||
const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AmbiguousAppTenantError,
|
||||
type PlatformBootstrap
|
||||
} from '../tenancy/platform-config-repository.js';
|
||||
|
||||
const headerSchema = z.object({
|
||||
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
|
||||
export interface PlatformConfigResolver {
|
||||
resolveBootstrap(appId: string, tenantId?: string): Promise<PlatformBootstrap | null>;
|
||||
}
|
||||
|
||||
export async function registerPlatformBootstrapRoutes(
|
||||
app: FastifyInstance,
|
||||
repository: PlatformConfigResolver
|
||||
): Promise<void> {
|
||||
app.get('/app-api/bootstrap', async (request, reply) => {
|
||||
const parsed = headerSchema.safeParse(request.headers);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_APP_CONTEXT',
|
||||
message: 'x-wechat-appid is required and tenant-id must be a positive integer.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const bootstrap = await repository.resolveBootstrap(
|
||||
parsed.data['x-wechat-appid'],
|
||||
parsed.data['tenant-id']
|
||||
);
|
||||
if (!bootstrap) {
|
||||
return reply.status(404).send({
|
||||
code: 'APP_TENANT_NOT_FOUND',
|
||||
message: 'The application and tenant binding is not active.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return { code: 0, data: bootstrap, traceId: request.traceId };
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousAppTenantError) {
|
||||
return reply.status(409).send({
|
||||
code: 'TENANT_SELECTION_REQUIRED',
|
||||
message: error.message,
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -1,8 +1,17 @@
|
||||
import { buildApp } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { closeMySqlPool, createMySqlPool } from './db/mysql.js';
|
||||
import { PlatformConfigRepository } from './tenancy/platform-config-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = await buildApp({ config });
|
||||
const pool = createMySqlPool(config);
|
||||
const app = await buildApp({
|
||||
config,
|
||||
platformConfigRepository: new PlatformConfigRepository(pool)
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
await closeMySqlPool(pool);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export interface PlatformBootstrap {
|
||||
appId: string;
|
||||
tenantId: string;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
brand: {
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
themeColor: string;
|
||||
servicePhone: string;
|
||||
franchisePhone: string;
|
||||
shareTitle: string;
|
||||
shareImageUrl: string;
|
||||
};
|
||||
defaultStoreId: string | null;
|
||||
}
|
||||
|
||||
interface PlatformBootstrapRow extends RowDataPacket {
|
||||
appId: string;
|
||||
tenantId: string;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
brandName: string;
|
||||
logoUrl: string;
|
||||
themeColor: string;
|
||||
servicePhone: string;
|
||||
franchisePhone: string;
|
||||
shareTitle: string;
|
||||
shareImageUrl: string;
|
||||
defaultStoreId: string | null;
|
||||
}
|
||||
|
||||
export class AmbiguousAppTenantError extends Error {
|
||||
constructor(appId: string) {
|
||||
super(`Application ${appId} is bound to multiple tenants; tenant-id is required.`);
|
||||
this.name = 'AmbiguousAppTenantError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PlatformConfigRepository {
|
||||
constructor(private readonly pool: Pick<MySqlPool, 'execute'>) {}
|
||||
|
||||
async resolveBootstrap(appId: string, tenantId?: string): Promise<PlatformBootstrap | null> {
|
||||
const tenantFilter = tenantId ? 'AND ta.tenant_id = ?' : '';
|
||||
const params = tenantId ? [appId, tenantId] : [appId];
|
||||
const [rows] = await this.pool.execute<PlatformBootstrapRow[]>(
|
||||
`SELECT pa.appid AS appId,
|
||||
ta.tenant_id AS tenantId,
|
||||
t.code AS tenantCode,
|
||||
t.name AS tenantName,
|
||||
tc.brand_name AS brandName,
|
||||
tc.logo_url AS logoUrl,
|
||||
tc.theme_color AS themeColor,
|
||||
tc.service_phone AS servicePhone,
|
||||
tc.franchise_phone AS franchisePhone,
|
||||
tc.share_title AS shareTitle,
|
||||
tc.share_image_url AS shareImageUrl,
|
||||
tc.default_store_id AS defaultStoreId
|
||||
FROM qipai_platform_apps pa
|
||||
INNER JOIN qipai_tenant_apps ta
|
||||
ON ta.platform_app_id = pa.id
|
||||
AND ta.status = 'ACTIVE'
|
||||
AND ta.deleted_at IS NULL
|
||||
INNER JOIN qipai_tenants t
|
||||
ON t.id = ta.tenant_id
|
||||
AND t.status = 'ACTIVE'
|
||||
AND t.deleted_at IS NULL
|
||||
INNER JOIN qipai_tenant_configs tc
|
||||
ON tc.platform_app_id = pa.id
|
||||
AND tc.tenant_id = ta.tenant_id
|
||||
AND tc.deleted_at IS NULL
|
||||
WHERE pa.appid = ?
|
||||
AND pa.status = 'ACTIVE'
|
||||
AND pa.deleted_at IS NULL
|
||||
${tenantFilter}
|
||||
ORDER BY ta.is_default DESC, ta.tenant_id ASC
|
||||
LIMIT 2`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!tenantId && rows.length > 1) {
|
||||
throw new AmbiguousAppTenantError(appId);
|
||||
}
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
appId: row.appId,
|
||||
tenantId: String(row.tenantId),
|
||||
tenantCode: row.tenantCode,
|
||||
tenantName: row.tenantName,
|
||||
brand: {
|
||||
name: row.brandName,
|
||||
logoUrl: row.logoUrl,
|
||||
themeColor: row.themeColor,
|
||||
servicePhone: row.servicePhone,
|
||||
franchisePhone: row.franchisePhone,
|
||||
shareTitle: row.shareTitle,
|
||||
shareImageUrl: row.shareImageUrl
|
||||
},
|
||||
defaultStoreId: row.defaultStoreId === null ? null : String(row.defaultStoreId)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ const legacyFixtureSql = read('database/fixtures/2026061801_m01b_legacy_schema.s
|
||||
const asyncUpSql = read('database/migrations/2026061802_m01c_async_tasks.up.sql');
|
||||
const asyncDownSql = read('database/migrations/2026061802_m01c_async_tasks.down.sql');
|
||||
const asyncVerifySql = read('database/migrations/2026061802_m01c_async_tasks.verify.sql');
|
||||
const tenantAppsUpSql = read('database/migrations/2026061803_m02a_tenant_apps.up.sql');
|
||||
const tenantAppsDownSql = read('database/migrations/2026061803_m02a_tenant_apps.down.sql');
|
||||
const tenantAppsVerifySql = read('database/migrations/2026061803_m02a_tenant_apps.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -86,4 +89,14 @@ assert.match(asyncUpSql, /UNIQUE KEY uq_qipai_async_tasks_idempotency/);
|
||||
assert.match(asyncUpSql, /lease_expires_at DATETIME\(3\)/);
|
||||
assert.match(asyncUpSql, /COMPENSATION_REQUIRED|status VARCHAR/);
|
||||
|
||||
console.log('PASS: M01-B/M01-C migration contracts are present.');
|
||||
for (const table of ['qipai_platform_apps', 'qipai_tenant_apps', 'qipai_tenant_configs']) {
|
||||
assert.match(tenantAppsUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(tenantAppsDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(tenantAppsVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(tenantAppsUpSql, /UNIQUE KEY uq_qipai_tenant_apps_tenant_app \(tenant_id, platform_app_id\)/);
|
||||
assert.match(tenantAppsUpSql, /UNIQUE KEY uq_qipai_tenant_configs_tenant_app \(tenant_id, platform_app_id\)/);
|
||||
assert.match(tenantAppsUpSql, /brand_name VARCHAR/);
|
||||
assert.match(tenantAppsUpSql, /theme_color VARCHAR/);
|
||||
|
||||
console.log('PASS: M01-B through M02-A migration contracts are present.');
|
||||
|
||||
@@ -13,7 +13,8 @@ assert.deepEqual(
|
||||
const plan = await loadMigrationPlan('up');
|
||||
assert.equal(plan.direction, 'up');
|
||||
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
|
||||
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql$/);
|
||||
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
|
||||
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import { loadConfig } from '../dist/config.js';
|
||||
import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js';
|
||||
import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js';
|
||||
import { TaskRepository } from '../dist/tasks/task-repository.js';
|
||||
import {
|
||||
AmbiguousAppTenantError,
|
||||
PlatformConfigRepository
|
||||
} from '../dist/tenancy/platform-config-repository.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -21,9 +25,12 @@ const expectedTables = [
|
||||
'qipai_orders',
|
||||
'qipai_outbox_events',
|
||||
'qipai_payments',
|
||||
'qipai_platform_apps',
|
||||
'qipai_rooms',
|
||||
'qipai_schema_migrations',
|
||||
'qipai_stores',
|
||||
'qipai_tenant_apps',
|
||||
'qipai_tenant_configs',
|
||||
'qipai_tenants'
|
||||
];
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -45,9 +52,9 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?)
|
||||
WHERE version IN (?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802']
|
||||
['2026061601', '2026061802', '2026061803']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -120,6 +127,48 @@ async function assertTaskDurability(pool) {
|
||||
assert.deepEqual(rows, [{ status: 'SUCCEEDED', attempts: 1 }]);
|
||||
}
|
||||
|
||||
async function assertPlatformTenantIsolation(pool) {
|
||||
const [tenantResult] = await pool.query(
|
||||
`INSERT INTO qipai_tenants (code, name)
|
||||
VALUES ('M02A-A', 'M02A tenant A'), ('M02A-B', 'M02A tenant B')`
|
||||
);
|
||||
const firstTenantId = Number(tenantResult.insertId);
|
||||
const secondTenantId = firstTenantId + 1;
|
||||
const [appResult] = await pool.query(
|
||||
`INSERT INTO qipai_platform_apps (appid, name)
|
||||
VALUES ('wx-m02a-shared', 'M02A shared app')`
|
||||
);
|
||||
const platformAppId = Number(appResult.insertId);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_tenant_apps
|
||||
(tenant_id, platform_app_id, is_default)
|
||||
VALUES (?, ?, 1), (?, ?, 0)`,
|
||||
[firstTenantId, platformAppId, secondTenantId, platformAppId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_tenant_configs
|
||||
(tenant_id, platform_app_id, brand_name, theme_color)
|
||||
VALUES (?, ?, 'Tenant A Brand', '#111111'),
|
||||
(?, ?, 'Tenant B Brand', '#222222')`,
|
||||
[firstTenantId, platformAppId, secondTenantId, platformAppId]
|
||||
);
|
||||
|
||||
const repository = new PlatformConfigRepository(pool);
|
||||
const tenantA = await repository.resolveBootstrap('wx-m02a-shared', String(firstTenantId));
|
||||
const tenantB = await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId));
|
||||
assert.equal(tenantA?.brand.name, 'Tenant A Brand');
|
||||
assert.equal(tenantB?.brand.name, 'Tenant B Brand');
|
||||
assert.equal(
|
||||
await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId + 999)),
|
||||
null
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.resolveBootstrap('wx-m02a-shared'),
|
||||
AmbiguousAppTenantError
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -145,11 +194,13 @@ try {
|
||||
assert.deepEqual(await readCoreTables(pool), expectedTables);
|
||||
assert.deepEqual(await readMigrationVersions(pool), [
|
||||
{ version: '2026061601', name: 'm01b_core_schema' },
|
||||
{ version: '2026061802', name: 'm01c_async_tasks' }
|
||||
{ version: '2026061802', name: 'm01c_async_tasks' },
|
||||
{ version: '2026061803', name: 'm02a_tenant_apps' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
await assertPlatformTenantIsolation(pool);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify and durable task restart check completed.');
|
||||
console.log('PASS: first up, verify, durable task and tenant isolation checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
@@ -161,7 +212,8 @@ try {
|
||||
assert.deepEqual(await readCoreTables(pool), expectedTables);
|
||||
assert.deepEqual(await readMigrationVersions(pool), [
|
||||
{ version: '2026061601', name: 'm01b_core_schema' },
|
||||
{ version: '2026061802', name: 'm01c_async_tasks' }
|
||||
{ version: '2026061802', name: 'm01c_async_tasks' },
|
||||
{ version: '2026061803', name: 'm02a_tenant_apps' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -187,7 +239,9 @@ try {
|
||||
'orders',
|
||||
'devices',
|
||||
'tenant isolation',
|
||||
'decimal cents'
|
||||
'decimal cents',
|
||||
'app-to-tenant binding',
|
||||
'cross-tenant bootstrap rejection'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import {
|
||||
AmbiguousAppTenantError,
|
||||
PlatformConfigRepository
|
||||
} from '../dist/tenancy/platform-config-repository.js';
|
||||
|
||||
const calls = [];
|
||||
const repository = new PlatformConfigRepository({
|
||||
async execute(sql, params) {
|
||||
calls.push([sql, params]);
|
||||
return [[{
|
||||
appId: 'wx-test-app',
|
||||
tenantId: 7,
|
||||
tenantCode: 'tenant-seven',
|
||||
tenantName: 'Tenant Seven',
|
||||
brandName: 'Seven棋牌',
|
||||
logoUrl: 'https://api.txyundm.cn/uploads/tenant-7/logo.png',
|
||||
themeColor: '#1677ff',
|
||||
servicePhone: '4000000000',
|
||||
franchisePhone: '',
|
||||
shareTitle: 'Seven棋牌',
|
||||
shareImageUrl: '',
|
||||
defaultStoreId: 9
|
||||
}], []];
|
||||
}
|
||||
});
|
||||
|
||||
const bootstrap = await repository.resolveBootstrap('wx-test-app', '7');
|
||||
assert.equal(bootstrap.tenantId, '7');
|
||||
assert.equal(bootstrap.defaultStoreId, '9');
|
||||
assert.equal(bootstrap.brand.name, 'Seven棋牌');
|
||||
assert.match(calls[0][0], /tc\.tenant_id = ta\.tenant_id/);
|
||||
assert.match(calls[0][0], /AND ta\.tenant_id = \?/);
|
||||
assert.deepEqual(calls[0][1], ['wx-test-app', '7']);
|
||||
|
||||
const ambiguousRepository = new PlatformConfigRepository({
|
||||
async execute() {
|
||||
return [[
|
||||
{ tenantId: 1 },
|
||||
{ tenantId: 2 }
|
||||
], []];
|
||||
}
|
||||
});
|
||||
await assert.rejects(
|
||||
() => ambiguousRepository.resolveBootstrap('wx-multi-app'),
|
||||
AmbiguousAppTenantError
|
||||
);
|
||||
|
||||
const app = await buildApp({
|
||||
platformConfigRepository: {
|
||||
async resolveBootstrap(appId, tenantId) {
|
||||
assert.equal(appId, 'wx-test-app');
|
||||
assert.equal(tenantId, '7');
|
||||
return bootstrap;
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/bootstrap',
|
||||
headers: {
|
||||
'x-wechat-appid': 'wx-test-app',
|
||||
'tenant-id': '7',
|
||||
'x-trace-id': 'm02a-test'
|
||||
}
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.tenantId, '7');
|
||||
assert.equal(response.headers['x-trace-id'], 'm02a-test');
|
||||
await app.close();
|
||||
|
||||
const missingContextApp = await buildApp({
|
||||
platformConfigRepository: {
|
||||
async resolveBootstrap() {
|
||||
throw new Error('must not be called');
|
||||
}
|
||||
}
|
||||
});
|
||||
const invalidResponse = await missingContextApp.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/bootstrap'
|
||||
});
|
||||
assert.equal(invalidResponse.statusCode, 400);
|
||||
assert.equal(invalidResponse.json().code, 'INVALID_APP_CONTEXT');
|
||||
await missingContextApp.close();
|
||||
|
||||
console.log('PASS: M02-A app and tenant bootstrap enforces explicit bindings.');
|
||||
Reference in New Issue
Block a user