Skip to content

Commit f1c1ca5

Browse files
fix(runtime)!: package commit revert and rollback act only on a commit of the package their path names (#29090)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X6hv78CUTUGu8UPTqHyYRaS)_ Fixes #28987 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — no page under `skills/**` or `content/docs` says which package a commit revert or rollback acts on; `content/docs/api/metadata-api.mdx` only lists the door names. ## 维护者速读 - 改了什么:`POST /packages/:id/commits/:commitId/revert` 和 `POST /packages/:id/rollback` 现在只处理路径里那个包自己的提交。提交属于别的包 → `404 COMMIT_NOT_FOUND`("No commit 'X' in package 'B'."),什么都不写;路径里的包不存在(含已卸载) → `404 Package 'X' not found`,与兄弟门一致。 - 为什么改:原来两个门都不读 `:id`。按包回滚的管理员一旦把提交号串错包,就会悄悄回滚另一个包的元数据(创建的被软删、编辑的被还原),回 200,还往那个包的历史里写一条 revert 提交,即使那个包已卸载。 - 风险与回滚:同包的提交照旧回滚,响应体不变;鉴权(manage_metadata)、围墙目录、查询参数三道拒绝的顺序不变。只读包(代码包/安装包)不加只读守卫:实测它们唯一的提交是环境覆盖(ADR-0131 D6)发布,撤销覆盖仍可用,已钉为对照。回滚即还原本 PR。 - 先看:`packages/metadata-protocol/src/protocol.ts` 的 `readRevertableCommit`(新增的包比对);`packages/runtime/src/domains/packages.ts` 两个门把 `packageId` 交给协议、未知包 404;`packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/package-commit-revert-path-package.dogfood.test.ts`(需先建闭包),预期 7/7 通过;在 main 上其中 3 例为红。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/metadata-protocol`.** `revertCommit` and `rollbackToPackageCommit` take an optional `packageId`. `readRevertableCommit`, the one commit read both use, refuses a commit whose `package_id` is not that package with the existing `COMMIT_NOT_FOUND` (404), worded `No commit 'ID' in package 'PKG'.`, before the legacy-organization check and before anything is written. A caller that sends no `packageId` reads any commit, as before. This optional member is a widening of two published method signatures, beside the door narrowing: hence `yes (narrowing)`. - **`@objectstack/runtime`.** Both doors read `:id` and hand it to the protocol as `packageId`. An unknown `:id` (`packageIsUnknown`, the predicate the draft doors and `GET /:id/commits` use) answers `404 Package 'ID' not found` without calling the protocol. The check sits inside each door's capability probe, so a partial protocol host still answers 501, and the rollback's `400 Body { commitId } is required` still comes first. `manage_metadata`, the walled-catalog refusal and the closed query set keep their order ahead of it. - **No read-only guard (seat's ruling, option A).** The order asked for `requireWritablePackage` on both doors. Measured on main: a read-only package carries commits. An environment overlay draft on `showcase_task.default` bound to `com.example.showcase` (a booted code package), published with `publish-drafts`, records an `apply` commit in the showcase's history, and reverting it through the showcase's own path answers 200 and removes the overlay. Those are the only commits a read-only package can carry (`POST /packages/:id/publish` already refuses read-only packages). The sibling `POST /packages/:id/revert` answers a read-only package's stored rows before its own guard, so the guard there never refuses them. A guard on these doors would refuse the undo of an ADR-0131 D6 customization, a narrowing beyond the card. The seat chose not to add it; the overlay revert is pinned as a control. **BREAKING** (patch, ruling A #25105: a non-protocol narrowing ships `patch`): - FROM: `POST /packages/B/commits/COMMIT_OF_A/revert` answered 200, reverted A's items and recorded a revert commit in A's history. TO: `404 COMMIT_NOT_FOUND`, nothing written. - FROM: `POST /packages/B/rollback` `{ commitId: COMMIT_OF_A }` answered 200 and reverted A's newer commits. TO: `404 COMMIT_NOT_FOUND`, nothing written. - FROM: either door with an unknown or uninstalled `:id` acted on the commit. TO: `404 Package 'ID' not found`. ## Tests - **Dogfood, new `package-commit-revert-path-package.dogfood.test.ts` (7 cases, showcase boot, REST):** cross-package revert 404 and A's history and items unchanged; cross-package rollback 404, both histories and A's items unchanged; unknown package 404 on both doors; member without `manage_metadata` 403 on both; controls: own-path revert (200, `revertedCount: 1`, revert commit recorded, item removed) and own-path rollback (200, the newer commit reverted); a read-only package's overlay commit: 404 through another package's path with the showcase history unchanged, 200 through its own path removing the overlay. - **Runtime unit, new `packages-commit-door-path-package.test.ts` (6 cases):** each door hands the protocol `{ commitId, packageId }`, relays the protocol's `COMMIT_NOT_FOUND` as 404, and answers an unknown package 404 without calling the protocol. - **Protocol unit, `packages/objectql/src/protocol-commit-history.test.ts` (+2 cases):** `revertCommit` and `rollbackToPackageCommit` with another package's id reject `COMMIT_NOT_FOUND`/404 with the message, with no delete, no restore and no new commit row; the same call with the commit's own package reverts. <details><summary>Red on main, green on head, ablation</summary> - Red on main `ab085a04` (dogfood file, `dist/` built from main): `3 failed | 4 passed (7)`. Cross-package revert: 200 `{"revertedCount":1,"reverted":[{"type":"object","name":"cmtpa_second","action":"removed"}],"revertCommitId":"cmt_…"}`. Cross-package rollback: 200 `{"revertedCommits":["cmt_…"],"failed":[]}`. Unknown package: 200 (`revertedCount: 1`). The controls passed on main. - Read-only probe on main (same boot): overlay draft save 200 (`Saved customization overlay (env-wide, package=com.example.showcase, state=draft)`), `publish-drafts` 200 `outcome: published` with a `commitId`, `GET /packages/com.example.showcase/commits` lists that `apply` commit, its revert 200 `reverted: [{ type: view, name: showcase_task.default, action: removed }]`. - Green on head `a4647aa1`: dogfood file `7 passed (7)`; `@objectstack/runtime` `pnpm test`: `514 passed` files, `8273 passed | 19 skipped` tests; objectql `protocol-commit-history.test.ts` `32 passed (32)`. - Ablation (`scripts/ablation-replace.mjs` on `readRevertableCommit`: the condition becomes `packageId === '__ablated_pkg_check__' && …`, so it never matches; `@objectstack/metadata-protocol` rebuilt; `ablation-dist-preflight` found the marker in 2 built files): dogfood `3 failed | 4 passed (7)`, exactly the cross-package revert, the cross-package rollback and the overlay case at its cross-package step (expected 404, received 200); the unknown-package case (runtime check) and every own-path control stayed green. objectql `2 failed | 30 passed (32)`, the two new mismatch cases only. Restored: blob == HEAD, `git diff HEAD` empty, rebuilt, preflight `--absent` clean. A first attempt used `if (false && …)` as the mutation; the bundler folded it away, the dist preflight answered "marker ABSENT", and that run's reading was discarded. </details> ## Acceptance notes - **An uninstalled package.** The card's repro reverted A's commit after A was uninstalled. With the ruling's unknown-package 404, an uninstalled package's commit is now refused through its own path too (404 `Package 'ID' not found`); `GET /packages/:id/commits` still serves a trail that outlived its package. - **In-process callers** of `revertCommit` / `rollbackToPackageCommit` (no non-REST caller exists in the repo besides the rollback's own inner `revertCommit` steps, which read commits listed under the target's package) keep reading any commit when they send no `packageId`. - **Clause-② arm.** The seat's correction said a narrowing-only change is `no (narrowing)`. This diff also adds an optional member to two published `ObjectStackProtocolImplementation` method requests, which is the widening the order names, so the line is `yes (narrowing)`. - Overlap: of the 49 open PRs, four touch `packages/metadata-protocol/src/protocol.ts` (#29066, #29003, #28850, #28731) and none touch `packages/runtime/src/domains/packages.ts`; no hunk of theirs is in `readRevertableCommit`, `revertCommit` or `rollbackToPackageCommit` (#28850's rollback hunks are in `rollbackMetaItem`). ## Verification Every local reading above is on head `a4647aa1`. `pnpm typecheck` for `@objectstack/metadata-protocol`, `@objectstack/runtime` (with `check:test-typecheck`), `@objectstack/objectql` (with `check:test-typecheck`) and `@objectstack/dogfood`: exit 0. Derived gate families (`node scripts/pm/dispatch-gates.mjs --commands`): 67 derived; at the time of writing 63 had run, 61 with exit 0; `check-plugin-teardown-shape --self-test` answered exit 3 on the shallow clone and exit 0 (48 cases) after `git fetch --depth=1` of its fixture commit; `check:dual-build-cjs-loads` exit 3, NOT MEASURED (its prerequisite, every package's `dist/`, is not met in this checkout); `check:type-check-debt`, `check:undeclared-dep-imports`, `check:watch-hint-literal` and `check:where-matcher` were still running when the maintainer's pause arrived and are NOT MEASURED here. `check-adr-0087-registration --base origin/main`: exit 0, `not-required (no-migration-prescription)`. Full dogfood battery (owed by the `(narrowing)` arm): started on this head under the verify lock, still running at the pause; NOT MEASURED here, the reading goes to the seat. The repo-wide lint, `test:repo` and the full farm are CI's. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nWnKw7n4za79sxnfc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f29c84 commit f1c1ca5

6 files changed

Lines changed: 381 additions & 5 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(runtime)!: package commit revert and rollback act only on a commit of the package their path names
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata key, schema, accepted value, export or error code is removed or renamed. Two REST doors now refuse a request whose path package does not own the commit it names, with the existing COMMIT_NOT_FOUND code and the package-not-found 404 the sibling package doors already give, so no stored metadata changes and there is nothing for objectstack migrate meta to rewrite. The protocol's two request types gain an OPTIONAL member, which no existing caller has to send. -->
12+
13+
**BREAKING** for one call shape only: a request that named one package in the path and a commit of another. `POST /api/v1/packages/:id/commits/:commitId/revert` and `POST /api/v1/packages/:id/rollback` (body `{ commitId }`) used to ignore `:id`: a commit of package A, sent through package B's path, answered 200, reverted A's items and recorded a revert commit in A's history, even after A was uninstalled. Such a request now answers `404 COMMIT_NOT_FOUND` ("No commit 'ID' in package 'B'.") and writes nothing. An unknown `:id`, which includes a package that has been uninstalled, answers `404 Package 'ID' not found`, as the draft doors do, so an uninstalled package's history no longer takes new revert commits through its own path either. A commit sent through its own package's path reverts and rolls back exactly as before, with the same response body.
14+
15+
- **`@objectstack/runtime`**: both doors hand the path's package to the protocol, after the `manage_metadata`, walled-catalog and query refusals, which keep their order.
16+
- **`@objectstack/metadata-protocol`**: `revertCommit` and `rollbackToPackageCommit` take an optional `packageId`. When it is sent, a commit of any other package is refused `COMMIT_NOT_FOUND` (404) before anything is written. An in-process caller that sends none reads any commit, as before.
17+
18+
A read-only package keeps its commit revert. Its only commits are the publishes of its environment overlays (ADR-0131 D6), and `POST /packages/:id/revert` answers a read-only package's stored rows too.
19+
20+
What to do: nothing, unless a script sends commit ids through another package's path. Send each commit through the package that `GET /packages/:id/commits` lists it under.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27793,8 +27793,15 @@ export class ObjectStackProtocolImplementation implements
2779327793
* used to read it through {@link findEnvironmentRow}, which leaves the
2779427794
* legacy commit out, and answered `COMMIT_NOT_FOUND` for a commit that is
2779527795
* there.
27796+
*
27797+
* [ADR-0067] With `packageId`, a commit of ANOTHER package is not found
27798+
* either: the package a caller names (the REST doors' path id) decides
27799+
* which commits it can act on, so a commit id copied from the wrong
27800+
* package is answered `COMMIT_NOT_FOUND` naming that package, before
27801+
* anything is written. Without it (an in-process caller that names no
27802+
* package) any commit is read, as before.
2779627803
*/
27797-
private async readRevertableCommit(commitId: string): Promise<any> {
27804+
private async readRevertableCommit(commitId: string, packageId?: string): Promise<any> {
2779827805
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
2779927806
// platform store the door already authorized; the protocol scopes the
2780027807
// `where` by organization itself. Principal-less, the engine refuses it.
@@ -27805,6 +27812,12 @@ export class ObjectStackProtocolImplementation implements
2780527812
err.status = 404;
2780627813
throw err;
2780727814
}
27815+
if (packageId !== undefined && String(row.package_id) !== packageId) {
27816+
const err: any = new Error(`No commit '${commitId}' in package '${packageId}'.`);
27817+
err.code = 'COMMIT_NOT_FOUND';
27818+
err.status = 404;
27819+
throw err;
27820+
}
2780827821
const legacyCommits = await this.legacyOrganizationRowsOf('sys_metadata_commit');
2780927822
const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal(
2781027823
`Commit '${commitId}'`,
@@ -27847,6 +27860,12 @@ export class ObjectStackProtocolImplementation implements
2784727860
*/
2784827861
async revertCommit(request: {
2784927862
commitId: string;
27863+
/**
27864+
* The package the commit must belong to (ADR-0067): a commit of any
27865+
* other package is refused `COMMIT_NOT_FOUND` (404) and nothing is
27866+
* written. Optional for in-process callers that name no package.
27867+
*/
27868+
packageId?: string;
2785027869
actor?: string;
2785127870
}): Promise<{
2785227871
success: boolean;
@@ -27861,7 +27880,7 @@ export class ObjectStackProtocolImplementation implements
2786127880
`Commit '${request.commitId}'`, request,
2786227881
);
2786327882
await this.ensureOverlayIndex();
27864-
const row = await this.readRevertableCommit(request.commitId);
27883+
const row = await this.readRevertableCommit(request.commitId, request.packageId);
2786527884
const items = this.parseCommitItems(row.items);
2786627885
// #4556 — threaded into repo.put/delete → `recorded_by`; NULL when the
2786727886
// revert carries no human actor.
@@ -28350,6 +28369,12 @@ export class ObjectStackProtocolImplementation implements
2835028369
*/
2835128370
async rollbackToPackageCommit(request: {
2835228371
commitId: string;
28372+
/**
28373+
* The package being rolled back (ADR-0067): a target commit of any
28374+
* other package is refused `COMMIT_NOT_FOUND` (404) and nothing is
28375+
* written. Optional for in-process callers that name no package.
28376+
*/
28377+
packageId?: string;
2835328378
actor?: string;
2835428379
}): Promise<{
2835528380
success: boolean;
@@ -28360,7 +28385,7 @@ export class ObjectStackProtocolImplementation implements
2836028385
ObjectStackProtocolImplementation.refuseOrganizationScopedWrite(
2836128386
`Commit '${request.commitId}'`, request,
2836228387
);
28363-
const target = await this.readRevertableCommit(request.commitId);
28388+
const target = await this.readRevertableCommit(request.commitId, request.packageId);
2836428389
const all = await this.listCommits({ packageId: target.package_id });
2836528390
// listCommits is newest-first; revert every `apply` commit strictly newer
2836628391
// than the target (by created_at). Revert commits are skipped (their

‎packages/objectql/src/protocol-commit-history.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ describe('ADR-0067 — revertCommit', () => {
144144
await expect(p.revertCommit({ commitId: 'nope' })).rejects.toMatchObject({ code: 'COMMIT_NOT_FOUND', status: 404 });
145145
});
146146

147+
it('refuses a commit of ANOTHER package with commit_not_found naming the package, and writes nothing', async () => {
148+
const { engine, commits } = makeFakeEngine([
149+
applyCommit({ id: 'cmt_a', items: [{ type: 'object', name: 'course', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:00.000Z' }),
150+
]);
151+
const repo = { get: vi.fn(async () => ({ hash: 'h1' })), delete: vi.fn(async () => {}), restoreVersion: vi.fn() };
152+
const p = makeProtocol(engine, repo);
153+
154+
await expect(p.revertCommit({ commitId: 'cmt_a', packageId: 'app.other' })).rejects.toMatchObject({
155+
code: 'COMMIT_NOT_FOUND',
156+
status: 404,
157+
message: "No commit 'cmt_a' in package 'app.other'.",
158+
});
159+
expect(repo.delete).not.toHaveBeenCalled();
160+
expect(repo.restoreVersion).not.toHaveBeenCalled();
161+
expect(commits).toHaveLength(1);
162+
163+
// Control: the commit's own package reverts it.
164+
const res = await p.revertCommit({ commitId: 'cmt_a', packageId: 'app.edu' });
165+
expect(res).toMatchObject({ success: true, revertedCount: 1 });
166+
expect(commits.map((c) => c.operation)).toEqual(['apply', 'revert']);
167+
});
168+
147169
it('reverts dependent artifacts in REVERSE apply order (view before its object)', async () => {
148170
const { engine } = makeFakeEngine([
149171
applyCommit({
@@ -188,6 +210,27 @@ describe('ADR-0067 — rollbackToPackageCommit', () => {
188210
const p = makeProtocol(engine, {});
189211
await expect(p.rollbackToPackageCommit({ commitId: 'ghost' })).rejects.toMatchObject({ code: 'COMMIT_NOT_FOUND' });
190212
});
213+
214+
it('refuses a target commit of ANOTHER package with commit_not_found, and reverts nothing', async () => {
215+
const { engine, commits } = makeFakeEngine([
216+
applyCommit({ id: 'c1', items: [], created_at: '2026-06-24T00:00:01.000Z' }),
217+
applyCommit({ id: 'c2', items: [{ type: 'object', name: 'x', existedBefore: false, prevVersion: null }], created_at: '2026-06-24T00:00:02.000Z' }),
218+
]);
219+
const repo = { get: vi.fn(async () => ({ hash: 'h' })), delete: vi.fn(async () => {}), restoreVersion: vi.fn() };
220+
const p = makeProtocol(engine, repo);
221+
222+
await expect(p.rollbackToPackageCommit({ commitId: 'c1', packageId: 'app.other' })).rejects.toMatchObject({
223+
code: 'COMMIT_NOT_FOUND',
224+
status: 404,
225+
message: "No commit 'c1' in package 'app.other'.",
226+
});
227+
expect(repo.delete).not.toHaveBeenCalled();
228+
expect(commits).toHaveLength(2);
229+
230+
// Control: the package's own rollback reverts c2.
231+
const res = await p.rollbackToPackageCommit({ commitId: 'c1', packageId: 'app.edu' });
232+
expect(res).toMatchObject({ success: true, revertedCommits: ['c2'] });
233+
});
191234
});
192235

193236
describe('ADR-0067 — publishPackageDrafts records a commit', () => {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0067 — the per-commit revert and the rollback act only on a commit of
4+
// the package their path names, over the real showcase composition and
5+
// through the REST doors.
6+
//
7+
// ## What was broken
8+
//
9+
// `POST /api/v1/packages/B/commits/COMMIT_OF_A/revert` read the commit id from
10+
// the path and never read the package id beside it: it answered 200, reverted
11+
// package A's items, and recorded a "Revert: …" commit in A's history.
12+
// `POST /api/v1/packages/B/rollback` with `{ commitId: COMMIT_OF_A }` did the
13+
// same through A's whole timeline.
14+
//
15+
// ## What these cases pin
16+
//
17+
// A commit of another package answers `404 COMMIT_NOT_FOUND` naming the
18+
// commit as not in the path's package, and writes nothing: the items are
19+
// untouched and the commit's own package keeps the history it had. An
20+
// unknown package id answers the sibling doors' `404 Package 'ID' not found`.
21+
// Controls: the same revert and rollback through the commit's own package
22+
// still answer 200 and record their commits; a member without
23+
// `manage_metadata` is still refused 403 first.
24+
//
25+
// ## Read-only packages carry commits too, and keep their revert
26+
//
27+
// Measured on main: an environment overlay draft on a showcase item, bound to
28+
// `com.example.showcase` (a booted code package, read-only) and published with
29+
// `publish-drafts`, records an `apply` commit in the showcase's history, and
30+
// reverting it through the showcase's own path removes the overlay. That is
31+
// the undo of an ADR-0131 D6 customization, which `POST /packages/:id/revert`
32+
// also answers for a read-only package (stored rows first), so the commit
33+
// doors take no read-only guard; the last case pins it.
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import { type VerifyStack } from '@objectstack/verify';
37+
import { bootShowcase } from './showcase-boot.js';
38+
39+
const SHOWCASE = 'com.example.showcase';
40+
const OVERLAY = 'showcase_task.default';
41+
const A = 'app.cmtprobe-a';
42+
const B = 'app.cmtprobe-b';
43+
44+
const object = (name: string, label: string) => ({
45+
name,
46+
label,
47+
sharingModel: 'public_read_write',
48+
fields: { name: { type: 'text', label: 'Name' } },
49+
});
50+
51+
interface Commit { id: string; operation: string; message?: string }
52+
53+
describe('dogfood: package commit revert and rollback act only on a commit of the path\'s package (ADR-0067)', () => {
54+
let stack: VerifyStack;
55+
let admin: string;
56+
let member: string;
57+
58+
const call = async (method: string, path: string, body?: unknown, token = admin) => {
59+
const res = await stack.apiAs(token, method, path, body);
60+
const json: any = await res.json().catch(() => ({}));
61+
return { status: res.status, json };
62+
};
63+
const commitsOf = async (pkg: string): Promise<Commit[]> => {
64+
const res = await call('GET', `/packages/${pkg}/commits`);
65+
expect(res.status, JSON.stringify(res.json)).toBe(200);
66+
return (res.json?.data?.commits ?? []) as Commit[];
67+
};
68+
const publishObject = async (pkg: string, name: string, label: string) => {
69+
const saved = await call('PUT', `/meta/object/${name}?mode=draft&package=${pkg}`, object(name, label));
70+
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
71+
const published = await call('POST', `/packages/${pkg}/publish-drafts`, {});
72+
expect(published.status, JSON.stringify(published.json)).toBe(200);
73+
};
74+
const isServed = async (name: string) => (await call('GET', `/meta/object/${name}`)).status === 200;
75+
76+
beforeAll(async () => {
77+
stack = await bootShowcase();
78+
admin = await stack.signIn();
79+
member = await stack.signUp('member.commit-revert@verify.test');
80+
for (const [id, ns] of [[A, 'cmtpa'], [B, 'cmtpb']] as const) {
81+
const created = await call('POST', '/packages', {
82+
manifest: { id, name: `Commit probe ${ns}`, version: '0.1.0', type: 'app', namespace: ns },
83+
});
84+
expect(created.status, JSON.stringify(created.json)).toBe(201);
85+
}
86+
await publishObject(A, 'cmtpa_first', 'A first');
87+
await publishObject(A, 'cmtpa_second', 'A second');
88+
await publishObject(B, 'cmtpb_first', 'B first');
89+
}, 180_000);
90+
91+
afterAll(async () => {
92+
await stack?.stop();
93+
});
94+
95+
it('the card: a commit of A reverted through B\'s path is 404 and writes nothing', async () => {
96+
const before = await commitsOf(A);
97+
const target = before.find((c) => c.operation === 'apply')!;
98+
const res = await call('POST', `/packages/${B}/commits/${target.id}/revert`, {});
99+
expect(res.status, JSON.stringify(res.json)).toBe(404);
100+
expect(JSON.stringify(res.json)).toContain('COMMIT_NOT_FOUND');
101+
expect(await commitsOf(A)).toEqual(before);
102+
expect(await isServed('cmtpa_first')).toBe(true);
103+
expect(await isServed('cmtpa_second')).toBe(true);
104+
});
105+
106+
it('a rollback of B to a commit of A is 404 and writes nothing', async () => {
107+
const before = await commitsOf(A);
108+
const oldest = before[before.length - 1];
109+
const res = await call('POST', `/packages/${B}/rollback`, { commitId: oldest.id });
110+
expect(res.status, JSON.stringify(res.json)).toBe(404);
111+
expect(JSON.stringify(res.json)).toContain('COMMIT_NOT_FOUND');
112+
expect(await commitsOf(A)).toEqual(before);
113+
expect(await commitsOf(B)).toHaveLength(1);
114+
expect(await isServed('cmtpa_second')).toBe(true);
115+
});
116+
117+
it('an unknown package id answers the sibling doors\' package-not-found 404', async () => {
118+
const target = (await commitsOf(A))[0];
119+
const revert = await call('POST', `/packages/app.cmtprobe-nosuch/commits/${target.id}/revert`, {});
120+
expect(revert.status, JSON.stringify(revert.json)).toBe(404);
121+
expect(JSON.stringify(revert.json)).toContain("Package 'app.cmtprobe-nosuch' not found");
122+
const rollback = await call('POST', '/packages/app.cmtprobe-nosuch/rollback', { commitId: target.id });
123+
expect(rollback.status, JSON.stringify(rollback.json)).toBe(404);
124+
expect(JSON.stringify(rollback.json)).toContain("Package 'app.cmtprobe-nosuch' not found");
125+
});
126+
127+
it('control: a member without manage_metadata is refused 403 first', async () => {
128+
const target = (await commitsOf(A))[0];
129+
expect((await call('POST', `/packages/${B}/commits/${target.id}/revert`, {}, member)).status).toBe(403);
130+
expect((await call('POST', `/packages/${B}/rollback`, { commitId: target.id }, member)).status).toBe(403);
131+
});
132+
133+
it('control: A\'s newest commit reverts through A\'s own path, as before', async () => {
134+
const before = await commitsOf(A);
135+
const newest = before[0];
136+
const res = await call('POST', `/packages/${A}/commits/${newest.id}/revert`, {});
137+
expect(res.status, JSON.stringify(res.json)).toBe(200);
138+
expect(res.json?.data).toMatchObject({ success: true, revertedCount: 1, failedCount: 0 });
139+
const after = await commitsOf(A);
140+
expect(after).toHaveLength(before.length + 1);
141+
expect(after[0]).toMatchObject({ operation: 'revert', id: res.json.data.revertCommitId });
142+
expect(await isServed('cmtpa_second')).toBe(false);
143+
});
144+
145+
it('control: B rolls back to its own first commit through B\'s path, as before', async () => {
146+
await publishObject(B, 'cmtpb_second', 'B second');
147+
const before = await commitsOf(B);
148+
const oldest = before[before.length - 1];
149+
const res = await call('POST', `/packages/${B}/rollback`, { commitId: oldest.id });
150+
expect(res.status, JSON.stringify(res.json)).toBe(200);
151+
expect(res.json?.data).toMatchObject({ success: true, revertedCommits: [before[0].id], failed: [] });
152+
expect(await commitsOf(B)).toHaveLength(before.length + 1);
153+
expect(await isServed('cmtpb_second')).toBe(false);
154+
expect(await isServed('cmtpb_first')).toBe(true);
155+
});
156+
157+
it('control: a read-only package\'s overlay commit reverts through its own path, and through no other', async () => {
158+
const saved = await call('PUT', `/meta/view/${OVERLAY}?mode=draft&package=${SHOWCASE}`, {
159+
name: OVERLAY, object: 'showcase_task', viewKind: 'list', label: 'Overlay probe',
160+
config: { type: 'grid', columns: [{ field: 'title' }] },
161+
});
162+
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
163+
const published = await call('POST', `/packages/${SHOWCASE}/publish-drafts`, {});
164+
expect(published.status, JSON.stringify(published.json)).toBe(200);
165+
const before = await commitsOf(SHOWCASE);
166+
expect(before[0]).toMatchObject({ operation: 'apply', id: published.json?.data?.commitId });
167+
168+
const cross = await call('POST', `/packages/${B}/commits/${before[0].id}/revert`, {});
169+
expect(cross.status, JSON.stringify(cross.json)).toBe(404);
170+
expect(await commitsOf(SHOWCASE)).toEqual(before);
171+
172+
const res = await call('POST', `/packages/${SHOWCASE}/commits/${before[0].id}/revert`, {});
173+
expect(res.status, JSON.stringify(res.json)).toBe(200);
174+
expect(res.json?.data).toMatchObject({
175+
success: true, revertedCount: 1, reverted: [{ type: 'view', name: OVERLAY, action: 'removed' }],
176+
});
177+
expect(await commitsOf(SHOWCASE)).toHaveLength(before.length + 1);
178+
});
179+
});

0 commit comments

Comments
 (0)