|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0048 addendum §A.2 Phase A1 — `os package publish` carries the artifact's |
| 5 | + * namespace to the control plane. |
| 6 | + * |
| 7 | + * The publish-time exclusivity gate (Phase A2, enterprise-side) reads the |
| 8 | + * namespace off the publish payload. Before this phase the namespace never left |
| 9 | + * the artifact, so the gate had nothing to check. These cases pin the three |
| 10 | + * behaviours the addendum's algorithm distinguishes: a namespace present (it |
| 11 | + * travels), a namespace absent (`if (namespace is absent) -> allow` — the key |
| 12 | + * is simply not sent), and a namespace that is not a namespace (refused before |
| 13 | + * any network call). |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 17 | +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; |
| 18 | +import { tmpdir } from 'node:os'; |
| 19 | +import { join } from 'node:path'; |
| 20 | +import { CreatePackageRequestSchema } from '@objectstack/spec/cloud'; |
| 21 | +import PackagePublish, { NAMESPACE_RE } from '../src/commands/package/publish.js'; |
| 22 | + |
| 23 | +type Call = { url: string; body: any }; |
| 24 | + |
| 25 | +function artifactJson(manifest: Record<string, unknown>): string { |
| 26 | + return JSON.stringify({ |
| 27 | + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0', ...manifest }, |
| 28 | + objects: [], |
| 29 | + }); |
| 30 | +} |
| 31 | + |
| 32 | +/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */ |
| 33 | +function stubCloud(): Call[] { |
| 34 | + const calls: Call[] = []; |
| 35 | + vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => { |
| 36 | + calls.push({ url, body: JSON.parse(init.body) }); |
| 37 | + const data = url.endsWith('/versions') |
| 38 | + ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' } |
| 39 | + : { id: 'pkg_1', created: true, visibility: 'org' }; |
| 40 | + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any; |
| 41 | + })); |
| 42 | + return calls; |
| 43 | +} |
| 44 | + |
| 45 | +describe('os package publish — namespace on the publish payload', () => { |
| 46 | + let dir = ''; |
| 47 | + const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; |
| 48 | + const prevCwd = process.cwd(); |
| 49 | + |
| 50 | + afterEach(async () => { |
| 51 | + process.chdir(prevCwd); |
| 52 | + vi.unstubAllGlobals(); |
| 53 | + vi.restoreAllMocks(); |
| 54 | + process.env.OS_CLOUD_URL = prevEnv.url; |
| 55 | + process.env.OS_CLOUD_API_KEY = prevEnv.key; |
| 56 | + if (dir) await rm(dir, { recursive: true, force: true }); |
| 57 | + }); |
| 58 | + |
| 59 | + async function artifactAt(manifest: Record<string, unknown>): Promise<string> { |
| 60 | + dir = await mkdtemp(join(tmpdir(), 'package-publish-ns-')); |
| 61 | + const path = join(dir, 'objectstack.json'); |
| 62 | + await writeFile(path, artifactJson(manifest)); |
| 63 | + process.env.OS_CLOUD_URL = 'http://cloud.test'; |
| 64 | + process.env.OS_CLOUD_API_KEY = 'tok_123'; |
| 65 | + return path; |
| 66 | + } |
| 67 | + |
| 68 | + it('sends `namespace` read off the compiled artifact manifest', async () => { |
| 69 | + const path = await artifactAt({ namespace: 'crm' }); |
| 70 | + const calls = stubCloud(); |
| 71 | + |
| 72 | + await PackagePublish.run([path]); |
| 73 | + |
| 74 | + expect(calls).toHaveLength(2); |
| 75 | + expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages'); |
| 76 | + expect(calls[0].body).toMatchObject({ manifest_id: 'com.acme.crm', namespace: 'crm' }); |
| 77 | + // The value the CLI puts on the wire is one the acceptance face accepts. |
| 78 | + expect(CreatePackageRequestSchema.shape.namespace.safeParse(calls[0].body.namespace).success).toBe(true); |
| 79 | + }); |
| 80 | + |
| 81 | + // Reverse verification, direction 1: an artifact with NO namespace must not |
| 82 | + // grow one. §A.2 allows an absent namespace, and the key must be absent |
| 83 | + // rather than null/'' so the gate never has to interpret an empty value. |
| 84 | + it('omits the key entirely when the artifact declares no namespace', async () => { |
| 85 | + const path = await artifactAt({}); |
| 86 | + const calls = stubCloud(); |
| 87 | + |
| 88 | + await PackagePublish.run([path]); |
| 89 | + |
| 90 | + expect(calls).toHaveLength(2); |
| 91 | + expect('namespace' in calls[0].body).toBe(false); |
| 92 | + expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true); |
| 93 | + }); |
| 94 | + |
| 95 | + // Reverse verification, direction 2: a malformed namespace is refused, and |
| 96 | + // refused BEFORE the network call — silently dropping it would publish an |
| 97 | + // artifact whose namespace the A2 gate never sees. |
| 98 | + it('refuses a malformed namespace with exit code 1 and never calls the cloud', async () => { |
| 99 | + const path = await artifactAt({ namespace: 'CRM-App' }); |
| 100 | + const calls = stubCloud(); |
| 101 | + const errors: string[] = []; |
| 102 | + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { |
| 103 | + errors.push(args.map(String).join(' ')); |
| 104 | + }); |
| 105 | + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { |
| 106 | + errors.push(args.map(String).join(' ')); |
| 107 | + }); |
| 108 | + |
| 109 | + let exitCode: number | undefined; |
| 110 | + try { |
| 111 | + await PackagePublish.run([path]); |
| 112 | + } catch (err: any) { |
| 113 | + exitCode = err?.oclif?.exit ?? err?.exitCode; |
| 114 | + } |
| 115 | + |
| 116 | + expect(exitCode).toBe(1); |
| 117 | + expect(calls).toEqual([]); |
| 118 | + expect(errors.join('\n')).toContain("Invalid manifest.namespace 'CRM-App'"); |
| 119 | + // The message names the rule and the remedy, not just the failure. |
| 120 | + expect(errors.join('\n')).toContain('objectstack.config.ts'); |
| 121 | + // …and the payload schema agrees this value is not a namespace. |
| 122 | + const rejected = CreatePackageRequestSchema.shape.namespace.safeParse('CRM-App'); |
| 123 | + expect(rejected.success).toBe(false); |
| 124 | + expect(rejected.success === false && rejected.error.issues[0].code).toBe('invalid_format'); |
| 125 | + }); |
| 126 | + |
| 127 | + // The namespace has exactly ONE source. `objectstack.manifest.json` may |
| 128 | + // override manifestId/displayName/category; it must not be able to claim a |
| 129 | + // namespace the artifact does not ship, or the reservation would name a |
| 130 | + // different string than the installed object prefix. |
| 131 | + it('ignores a namespace in objectstack.manifest.json — the artifact wins', async () => { |
| 132 | + const path = await artifactAt({ namespace: 'crm' }); |
| 133 | + await writeFile( |
| 134 | + join(dir, 'objectstack.manifest.json'), |
| 135 | + JSON.stringify({ name: 'acme-crm', namespace: 'squatted', displayName: 'Acme CRM' }), |
| 136 | + ); |
| 137 | + process.chdir(dir); |
| 138 | + const calls = stubCloud(); |
| 139 | + |
| 140 | + await PackagePublish.run([path]); |
| 141 | + |
| 142 | + expect(calls[0].body.namespace).toBe('crm'); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('the CLI namespace rule is the spec namespace rule', () => { |
| 147 | + it('agrees with CreatePackageRequestSchema on every value', () => { |
| 148 | + const cases: ReadonlyArray<readonly [string, boolean]> = [ |
| 149 | + ['crm', true], |
| 150 | + ['todo', true], |
| 151 | + ['a1', true], |
| 152 | + ['my_app_2', true], |
| 153 | + ['abcdefghijklmnopqrst', true], |
| 154 | + ['a', false], |
| 155 | + ['abcdefghijklmnopqrstu', false], |
| 156 | + ['1crm', false], |
| 157 | + ['CRM', false], |
| 158 | + ['crm-app', false], |
| 159 | + ['crm.account', false], |
| 160 | + ['crm account', false], |
| 161 | + ['', false], |
| 162 | + ]; |
| 163 | + const disagreements = cases.filter(([value, expected]) => { |
| 164 | + const cli = NAMESPACE_RE.test(value); |
| 165 | + const spec = CreatePackageRequestSchema.shape.namespace.safeParse(value).success; |
| 166 | + return cli !== expected || spec !== expected; |
| 167 | + }); |
| 168 | + expect(disagreements).toEqual([]); |
| 169 | + }); |
| 170 | +}); |
0 commit comments