Skip to content

Commit 6ca1a47

Browse files
test(plugin-security): pin that the registered security service serves reopenAudienceBindingSuggestion (#27546)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVpEmthvkRbuHGjC6oSQZvd)_ Fixes #27362 Clause-②: no Metadata-protocol: none Skills-docs: none — test-only diff; no published skill, doc or authorable surface moves. ## 维护者速读 - 改了什么:新增一个测试文件,钉住 plugin-security 注册的 `security` 服务确实提供可选方法 `reopenAudienceBindingSuggestion`,并且它调用的是真正的模块函数而不是桩。 - 为什么改:这个方法是可选的,若接线被重构删掉,所有测试仍然全绿,而两个“重新打开建议绑定”的接口会悄悄变成 501。 - 风险与回滚:只加测试,不改任何源码、不发布任何内容(`skip-changeset`);回滚即删除该测试文件。 - 先看:`packages/plugins/plugin-security/src/registered-security-service-reopen.pin.test.ts`(唯一改动文件,看文件头说明的理由与断言)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/registered-security-service-reopen.pin.test.ts`,预期 1 个测试通过。 - 需要你做的:无,席位落地。 ## What changes One new test file beside the members pin: `packages/plugins/plugin-security/src/registered-security-service-reopen.pin.test.ts`. It boots the real `SecurityPlugin` the same way `registered-security-service-members.pin.test.ts` does and takes the object it registers as `security`. Then it asserts two things: 1. `reopenAudienceBindingSuggestion` is a function on the registered object. 2. Calling it reaches this package's `reopenAudienceBindingSuggestion` module function, with the suggestion deps (which carry `ql`), the caller context and the id. The test returns that function's answer unchanged. The module function is replaced with a spy through a partial `vi.mock`, so the test proves the call is delegated and not answered by a stub. Per triage 6486679343, the test sits beside the members pin with its own reason and is **not** in `MUST_SERVE_OPTIONAL`. That list holds optional members whose absence consumers read as a fallback that admits. Reopen's absence refuses (`501`, every dismissal stands), so it does not belong there. The file header gives the reason. No source change. The member's wiring in `security-plugin-security-service.ts` is untouched. ## Tests - New pin plus members pin, on merged head `a34ad920b1`: `Test Files 2 passed (2)`, `Tests 5 passed (5)`. - `pnpm --filter @objectstack/plugin-security test`: `Test Files 237 passed (237)`, `Tests 5112 passed | 45 skipped (5157)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. This includes `check:test-typecheck` over `tsconfig.test.json`, which compiles the new file: `0 file(s) / 0 error(s)`. <details><summary>Negative-pin ablation (through scripts/ablation-replace.mjs; restored, blob equal to HEAD)</summary> The test imports `./security-plugin.js` relatively, so it runs against `src/` and no `dist/` rebuild applies. **A: delete the two wiring lines** (`reopenAudienceBindingSuggestion: (callerContext: any, id: string) =>` and the delegation line under it) ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 49d0052 -> f7bf8b8f1b32 × serves the member, delegating to the module function with the caller context and id AssertionError: reopenAudienceBindingSuggestion is not served: both reopen routes answer 501: expected 'undefined' to be 'function' Test Files 1 failed (1) ablation-replace: blob after restore 49d0052 ablation-replace: blob at HEAD 49d0052 ablation-replace: git diff HEAD empty ``` **B: keep the member and replace the delegation with a stub** (`Promise.resolve({ suggestion: { id, status: 'pending' } })`) ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 49d0052 -> 0af78b7d0772 × serves the member, delegating to the module function with the caller context and id AssertionError: expected { suggestion: { id: 'sugg_1', …(1) } } to be { suggestion: { id: 'sugg_1', …(1) } } // Object.is equality Test Files 1 failed (1) ablation-replace: blob after restore 49d0052 ``` After restore the pin is green again: `Tests 1 passed (1)`. </details> ## Acceptance notes - `skip-changeset`: the diff publishes nothing. `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the tsup entry is `src/index.ts`. Measured after `pnpm build`: the new test's names occur in 0 `dist/` files, and the positive control `reopenAudienceBindingSuggestion` occurs in 4. - The boot helper (about 25 lines) is copied from the members pin, not imported. The pin file exports nothing, and importing one test file from another would also run its suites. ## Verification Local subset on head `a34ad920b1` (merge of `origin/main` `f57eddcf0c`). Exit codes were captured before any pipe. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 52 commands. 50 exited 0. - Two answered exit 3 (`PREREQUISITE NOT MET`, they need the whole workspace built): `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n`. Both are **NOT MEASURED** locally and are left to CI's `Lint & Repo Gates`. A single test file under `src/` is outside what either one reads. - `--ran` reconcile: `52 derived famil(ies) accounted for — 50 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`, exit 0. - The derivation printed a stale-tree warning: `origin/main` moved after the merge, and the changed files are `scripts/pm/*` and `scripts/gen-sdui-reference.mjs` families. They are not this diff's. - `npx eslint --no-inline-config` on the new file: exit 0. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. - `pnpm --filter @objectstack/plugin-security test`: exit 0, 237/237 files. - Every family CI runs is read from this PR's check-runs. None of them was re-run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee9a8d6 commit 6ca1a47

1 file changed

Lines changed: 80 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `security` service this plugin registers serves the OPTIONAL
5+
* `ISecurityService.reopenAudienceBindingSuggestion`, and the served member
6+
* delegates to this package's `reopenAudienceBindingSuggestion` module
7+
* function — not to a stub.
8+
*
9+
* ## Why this pin exists, and why it is not in `MUST_SERVE_OPTIONAL`
10+
*
11+
* The member is optional on the contract, and both mounts of
12+
* `POST …/suggested-bindings/:id/reopen` feature-detect it: absent, they answer
13+
* `501` naming the door. That absence fails CLOSED (every dismissal stands and
14+
* nothing is granted), so it is not the admitting fallback the members pin's
15+
* `MUST_SERVE_OPTIONAL` list exists for (`registered-security-service-members.pin.test.ts`).
16+
* But it is a silent loss of a door this plugin ships: the plugin tests call the
17+
* module function directly, the members pin checks non-vacuity for REQUIRED
18+
* members only, and the runtime and rest tests use mocks. Without this pin,
19+
* dropping the wiring in `security-plugin-security-service.ts` would leave every
20+
* suite green while both routes turned into `501`.
21+
*/
22+
23+
import { describe, it, expect, vi } from 'vitest';
24+
25+
const reopenSpy = vi.hoisted(() => vi.fn());
26+
27+
vi.mock('./suggested-audience-bindings.js', async (importOriginal) => {
28+
const actual = await importOriginal<typeof import('./suggested-audience-bindings.js')>();
29+
return { ...actual, reopenAudienceBindingSuggestion: reopenSpy };
30+
});
31+
32+
import { SecurityPlugin } from './security-plugin.js';
33+
34+
/** Boot the real plugin far enough to register `security`, and return what it registered. */
35+
async function registeredSecurityService(): Promise<Record<string, unknown>> {
36+
const services: Record<string, unknown> = {
37+
manifest: { register: vi.fn() },
38+
objectql: { registerMiddleware: vi.fn(), getSchema: () => undefined },
39+
metadata: { get: async () => undefined, list: async () => [] },
40+
};
41+
const registerService = vi.fn();
42+
const ctx: Record<string, unknown> = {
43+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
44+
registerService,
45+
getService: (name: string) => {
46+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
47+
return services[name];
48+
},
49+
};
50+
const plugin = new SecurityPlugin();
51+
await plugin.init(ctx as any);
52+
await plugin.start(ctx as any);
53+
const security = registerService.mock.calls.find((c: unknown[]) => c[0] === 'security')?.[1];
54+
if (security === null || typeof security !== 'object') {
55+
throw new Error('the plugin did not register a `security` service object');
56+
}
57+
return security as Record<string, unknown>;
58+
}
59+
60+
describe('the registered `security` service serves reopenAudienceBindingSuggestion', () => {
61+
it('serves the member, delegating to the module function with the caller context and id', async () => {
62+
const security = await registeredSecurityService();
63+
expect(
64+
typeof security.reopenAudienceBindingSuggestion,
65+
'reopenAudienceBindingSuggestion is not served: both reopen routes answer 501',
66+
).toBe('function');
67+
68+
const answer = { suggestion: { id: 'sugg_1', status: 'pending' } };
69+
reopenSpy.mockResolvedValueOnce(answer);
70+
const callerContext = { userId: 'u_admin' };
71+
const reopen = security.reopenAudienceBindingSuggestion as (c: unknown, id: string) => Promise<unknown>;
72+
73+
await expect(reopen(callerContext, 'sugg_1')).resolves.toBe(answer);
74+
expect(reopenSpy).toHaveBeenCalledTimes(1);
75+
const [deps, ctxArg, idArg] = reopenSpy.mock.calls[0]!;
76+
expect(deps, 'the module function received no suggestion deps').toEqual(expect.objectContaining({ ql: expect.anything() }));
77+
expect(ctxArg).toBe(callerContext);
78+
expect(idArg).toBe('sugg_1');
79+
});
80+
});

0 commit comments

Comments
 (0)