|
| 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