|
2 | 2 |
|
3 | 3 | import { describe, it, expect, vi } from 'vitest'; |
4 | 4 | import { createTenancyService, resolveDefaultOrgId } from './tenancy-service.js'; |
| 5 | +import { backfillMemberships } from './reconcile-membership.js'; |
5 | 6 |
|
6 | 7 | function makeEngine(orgs: Array<{ id: string; slug?: string }>) { |
7 | 8 | return { |
@@ -294,3 +295,252 @@ describe('posture entitlement declared by the org-scoping runtime', () => { |
294 | 295 | expect(entitle).not.toHaveBeenCalled(); |
295 | 296 | }); |
296 | 297 | }); |
| 298 | + |
| 299 | +// --------------------------------------------------------------------------- |
| 300 | +// [#17010] The organization census — a `single`-posture deployment that HOLDS |
| 301 | +// more than one organization stops booting silently. |
| 302 | +// |
| 303 | +// ADR-0131 §1.2(3) calls that precondition 「a refused boot」 and it is not. |
| 304 | +// ⛔ Boot still PROCEEDS here (ruled 2026-09-10): this suite pins the REPORT, |
| 305 | +// its level, the subjects it must name — and, just as load-bearing, the |
| 306 | +// NEGATIVE CONTROL, because a check that fires on a healthy install is worse |
| 307 | +// than no check at all. |
| 308 | +// --------------------------------------------------------------------------- |
| 309 | +describe('single-posture organization census (#17010)', () => { |
| 310 | + /** An engine that answers the census — `count` is what makes the reading exact. */ |
| 311 | + function makeCensusEngine(orgs: Array<{ id: string; slug?: string }>) { |
| 312 | + return { |
| 313 | + find: vi.fn(async (object: string, query: any) => { |
| 314 | + if (object !== 'sys_organization') return []; |
| 315 | + const where = query?.where ?? {}; |
| 316 | + let rows = orgs; |
| 317 | + if (where.slug !== undefined) rows = rows.filter((o) => o.slug === where.slug); |
| 318 | + return rows.slice(0, query?.limit ?? rows.length); |
| 319 | + }), |
| 320 | + count: vi.fn(async (object: string) => (object === 'sys_organization' ? orgs.length : 0)), |
| 321 | + insert: vi.fn(async () => ({ id: 'ignored' })), |
| 322 | + }; |
| 323 | + } |
| 324 | + |
| 325 | + const makeSink = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); |
| 326 | + |
| 327 | + // Spelled here rather than imported: the census is module-private (it has one |
| 328 | + // in-file caller), so this literal is the pin — rename the token and this |
| 329 | + // suite says so, which is the whole point of a grep token an operator keys on. |
| 330 | + const SINGLE_POSTURE_MANY_ORGANIZATIONS = 'single_posture_holds_many_organizations'; |
| 331 | + |
| 332 | + const orgs = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `org_${i + 1}` })); |
| 333 | + |
| 334 | + it('reports at ERROR, naming the posture, the COUNT and both remedies', async () => { |
| 335 | + const engine = makeCensusEngine(orgs(3)); |
| 336 | + const logger = makeSink(); |
| 337 | + const t = createTenancyService({ |
| 338 | + requested: 'single', |
| 339 | + probeIsolation: () => false, |
| 340 | + getEngine: () => engine, |
| 341 | + logger, |
| 342 | + }); |
| 343 | + |
| 344 | + expect(await t.defaultOrgId()).toBeNull(); |
| 345 | + |
| 346 | + expect(logger.error).toHaveBeenCalledTimes(1); |
| 347 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 348 | + const [message, meta] = logger.error.mock.calls[0]!; |
| 349 | + // The grep token an operator keys on. |
| 350 | + expect(message).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS); |
| 351 | + // ① the posture it DECLARED, ② the count it HOLDS — the two facts the card asks for. |
| 352 | + expect(message).toContain("'single'"); |
| 353 | + expect(message).toContain('3'); |
| 354 | + expect(meta).toEqual({ posture: 'single', organizationCount: 3 }); |
| 355 | + // ③ both remedies, named: declare a walled posture, or hold one organization. |
| 356 | + expect(message).toContain('OS_TENANCY_POSTURE=group'); |
| 357 | + expect(message).toContain('OS_TENANCY_POSTURE=isolated'); |
| 358 | + expect(message).toContain('HOLD ONE ORGANIZATION'); |
| 359 | + // ④ the consequence, including that the deployment keeps looking healthy |
| 360 | + // (AGENTS.md → "Degradation log levels": what an `error` owes its reader). |
| 361 | + expect(message).toContain('KEEP LOOKING HEALTHY'); |
| 362 | + }); |
| 363 | + |
| 364 | + it('NEGATIVE CONTROL: exactly one organization under `single` stays SILENT', async () => { |
| 365 | + const engine = makeCensusEngine([{ id: 'org_1', slug: 'default' }]); |
| 366 | + const logger = makeSink(); |
| 367 | + const t = createTenancyService({ |
| 368 | + requested: 'single', |
| 369 | + probeIsolation: () => false, |
| 370 | + getEngine: () => engine, |
| 371 | + logger, |
| 372 | + }); |
| 373 | + |
| 374 | + expect(await t.defaultOrgId()).toBe('org_1'); |
| 375 | + expect(logger.error).not.toHaveBeenCalled(); |
| 376 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 377 | + }); |
| 378 | + |
| 379 | + it('NEGATIVE CONTROL: a store with NO organization yet stays SILENT', async () => { |
| 380 | + const engine = makeCensusEngine([]); |
| 381 | + const logger = makeSink(); |
| 382 | + const t = createTenancyService({ |
| 383 | + requested: 'single', |
| 384 | + probeIsolation: () => false, |
| 385 | + getEngine: () => engine, |
| 386 | + logger, |
| 387 | + }); |
| 388 | + |
| 389 | + expect(await t.defaultOrgId()).toBeNull(); |
| 390 | + expect(logger.error).not.toHaveBeenCalled(); |
| 391 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 392 | + }); |
| 393 | + |
| 394 | + it('a WALLED posture pays nothing and says nothing — the organizations are declared', async () => { |
| 395 | + for (const requested of ['group', 'isolated'] as const) { |
| 396 | + const engine = makeCensusEngine(orgs(3)); |
| 397 | + const logger = makeSink(); |
| 398 | + const t = createTenancyService({ |
| 399 | + requested, |
| 400 | + probeIsolation: () => true, |
| 401 | + getEngine: () => engine, |
| 402 | + logger, |
| 403 | + }); |
| 404 | + |
| 405 | + expect(await t.defaultOrgId(), requested).toBeNull(); |
| 406 | + expect(engine.count, requested).not.toHaveBeenCalled(); |
| 407 | + expect(logger.error, requested).not.toHaveBeenCalled(); |
| 408 | + expect(logger.warn, requested).not.toHaveBeenCalled(); |
| 409 | + } |
| 410 | + }); |
| 411 | + |
| 412 | + it('COST: the census costs ONE count() per process, however often it is asked', async () => { |
| 413 | + const engine = makeCensusEngine(orgs(4)); |
| 414 | + const logger = makeSink(); |
| 415 | + const t = createTenancyService({ |
| 416 | + requested: 'single', |
| 417 | + probeIsolation: () => false, |
| 418 | + getEngine: () => engine, |
| 419 | + logger, |
| 420 | + }); |
| 421 | + |
| 422 | + await t.defaultOrgId(); |
| 423 | + await t.defaultOrgId(); |
| 424 | + await t.defaultOrgId(); |
| 425 | + |
| 426 | + expect(engine.count).toHaveBeenCalledTimes(1); |
| 427 | + expect(engine.count).toHaveBeenCalledWith('sys_organization', {}, { context: { isSystem: true } }); |
| 428 | + // Said ONCE, at the first degradation — not once per failed resolution. |
| 429 | + expect(logger.error).toHaveBeenCalledTimes(1); |
| 430 | + }); |
| 431 | + |
| 432 | + it('falls back to warn on a sink that declares no error, and never emits both', async () => { |
| 433 | + const engine = makeCensusEngine(orgs(2)); |
| 434 | + const logger = { info: vi.fn(), warn: vi.fn() }; |
| 435 | + const t = createTenancyService({ |
| 436 | + requested: 'single', |
| 437 | + probeIsolation: () => false, |
| 438 | + getEngine: () => engine, |
| 439 | + logger, |
| 440 | + }); |
| 441 | + |
| 442 | + await t.defaultOrgId(); |
| 443 | + expect(logger.warn).toHaveBeenCalledTimes(1); |
| 444 | + expect(logger.warn.mock.calls[0]![0]).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS); |
| 445 | + }); |
| 446 | + |
| 447 | + it('an engine that cannot answer stays silent AND does not latch the census', async () => { |
| 448 | + // No `count`: every reduced mock embedding. An absence of measurement is |
| 449 | + // not evidence of a defect — and it must not disable the census either. |
| 450 | + const countless: any = makeCensusEngine(orgs(3)); |
| 451 | + delete countless.count; |
| 452 | + const logger = makeSink(); |
| 453 | + let engine: any = countless; |
| 454 | + const t = createTenancyService({ |
| 455 | + requested: 'single', |
| 456 | + probeIsolation: () => false, |
| 457 | + getEngine: () => engine, |
| 458 | + logger, |
| 459 | + }); |
| 460 | + |
| 461 | + await t.defaultOrgId(); |
| 462 | + expect(logger.error).not.toHaveBeenCalled(); |
| 463 | + expect(logger.warn).not.toHaveBeenCalled(); |
| 464 | + |
| 465 | + // The engine becomes answerable later (the store came up after this seam |
| 466 | + // was first reached): the census must still be takeable. |
| 467 | + engine = makeCensusEngine(orgs(3)); |
| 468 | + await t.defaultOrgId(); |
| 469 | + expect(logger.error).toHaveBeenCalledTimes(1); |
| 470 | + }); |
| 471 | + |
| 472 | + it('a throwing count, and a throwing logger, never break the resolution', async () => { |
| 473 | + const engine: any = makeCensusEngine([{ id: 'org_1', slug: 'default' }]); |
| 474 | + engine.count = vi.fn(async () => { |
| 475 | + throw new Error('store unreachable'); |
| 476 | + }); |
| 477 | + expect(await createTenancyService({ |
| 478 | + requested: 'single', |
| 479 | + probeIsolation: () => false, |
| 480 | + getEngine: () => engine, |
| 481 | + logger: makeSink(), |
| 482 | + }).defaultOrgId()).toBe('org_1'); |
| 483 | + |
| 484 | + const loud = makeCensusEngine(orgs(3)); |
| 485 | + const thrower = { |
| 486 | + warn: vi.fn(), |
| 487 | + error: vi.fn(() => { |
| 488 | + throw new Error('sink exploded'); |
| 489 | + }), |
| 490 | + }; |
| 491 | + expect(await createTenancyService({ |
| 492 | + requested: 'single', |
| 493 | + probeIsolation: () => false, |
| 494 | + getEngine: () => loud, |
| 495 | + logger: thrower, |
| 496 | + }).defaultOrgId()).toBeNull(); |
| 497 | + expect(thrower.error).toHaveBeenCalledTimes(1); |
| 498 | + }); |
| 499 | + |
| 500 | + it('a sink with no warn channel at all drops the report instead of throwing', async () => { |
| 501 | + // The declared sink types both members as optional, so a host CAN inject |
| 502 | + // `{ info }` alone. The narrowing proves `warn` before it claims the sink, |
| 503 | + // so such a host gets nothing — quietly, from inside a diagnostic. |
| 504 | + const engine = makeCensusEngine(orgs(3)); |
| 505 | + const infoOnly = { info: vi.fn() }; |
| 506 | + const t = createTenancyService({ |
| 507 | + requested: 'single', |
| 508 | + probeIsolation: () => false, |
| 509 | + getEngine: () => engine, |
| 510 | + logger: infoOnly, |
| 511 | + }); |
| 512 | + |
| 513 | + expect(await t.defaultOrgId()).toBeNull(); |
| 514 | + expect(infoOnly.info).not.toHaveBeenCalled(); |
| 515 | + }); |
| 516 | + |
| 517 | + // ------------------------------------------------------------------------- |
| 518 | + // The claim that makes this a BOOT-time reading rather than a lazy one: |
| 519 | + // `AuthPlugin` runs `backfillMemberships` from its `kernel:ready` hook with |
| 520 | + // `resolveTargetOrg: () => tenancy.defaultOrgId()`, under a membership policy |
| 521 | + // that defaults to `auto`. Pinned against the real pass, so the day that |
| 522 | + // wiring stops reaching this seam, this test says so. |
| 523 | + // ------------------------------------------------------------------------- |
| 524 | + it('is taken AT BOOT: the kernel:ready membership backfill reaches this seam', async () => { |
| 525 | + const engine = makeCensusEngine(orgs(3)); |
| 526 | + const logger = makeSink(); |
| 527 | + const tenancy = createTenancyService({ |
| 528 | + requested: 'single', |
| 529 | + probeIsolation: () => false, |
| 530 | + getEngine: () => engine, |
| 531 | + logger, |
| 532 | + }); |
| 533 | + |
| 534 | + const res = await backfillMemberships(engine, { |
| 535 | + policy: 'auto', |
| 536 | + resolveTargetOrg: () => tenancy.defaultOrgId(), |
| 537 | + logger, |
| 538 | + }); |
| 539 | + |
| 540 | + // The backfill itself correctly declines to guess (ADR-0093 D6) … |
| 541 | + expect(res.reason).toBe('no-target-org'); |
| 542 | + // … and THAT is the boot moment the census is taken in. |
| 543 | + expect(logger.error).toHaveBeenCalledTimes(1); |
| 544 | + expect(logger.error.mock.calls[0]![0]).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS); |
| 545 | + }); |
| 546 | +}); |
0 commit comments