|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `os environments create` does not take `--clone-from`, and sends no |
| 5 | + * `clone_from_environment_id`. |
| 6 | + * |
| 7 | + * ## The defect this file would have caught |
| 8 | + * |
| 9 | + * The command declared `--clone-from` ("Clone schema from an existing |
| 10 | + * environment id") and put its value on the create request as |
| 11 | + * `clone_from_environment_id`. The control plane never read that key: its |
| 12 | + * create schema does not declare it, and an undeclared key is stripped by the |
| 13 | + * schema's `z.object` gate, which is how the key was "accepted and ignored". So |
| 14 | + * `os environments create --clone-from X` answered success and created an |
| 15 | + * EMPTY environment, with nothing saying the clone had not happened. Cloning is |
| 16 | + * not implemented; a flag that advertises it is a capability the runtime does |
| 17 | + * not deliver. |
| 18 | + * |
| 19 | + * ## What is pinned, and why each half needs its own case |
| 20 | + * |
| 21 | + * - The flag is REFUSED, by oclif's own "Nonexistent flag" answer and with the |
| 22 | + * control plane never called. The refusal is the parser's default on purpose: |
| 23 | + * a bespoke message would be a sentence the CLI owns and has to keep true. |
| 24 | + * - A create WITHOUT the flag puts no `clone_from_environment_id` on the wire. |
| 25 | + * `JSON.stringify` drops an `undefined` value, so this half goes green even |
| 26 | + * while the request still names the key; it is the control that proves the |
| 27 | + * interception below sees the real request, and the key-set equality is what |
| 28 | + * fails if anything ever writes that key with a value again. |
| 29 | + * - No `examples` line spells a flag the command refuses. `examples` is printed |
| 30 | + * verbatim by `--help`, so a stale line is a copy-paste that fails. |
| 31 | + * |
| 32 | + * ## Why every case carries an explicit 60s budget |
| 33 | + * |
| 34 | + * Each case drives a real `Command.run` against the real oclif root, which |
| 35 | + * resolves the plugin and manifest surface before the command body runs. The |
| 36 | + * budget is wall-clock only and no assertion moves with it; the sibling |
| 37 | + * `commands/data/delete-arms-agree.test.ts` states the same reason. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 41 | +import path from 'node:path'; |
| 42 | +import { fileURLToPath } from 'node:url'; |
| 43 | +import { ObjectStackClient } from '@objectstack/client'; |
| 44 | +import EnvironmentsCreate from './create.js'; |
| 45 | + |
| 46 | +const stub = vi.hoisted(() => ({ |
| 47 | + client: undefined as any, |
| 48 | + token: 'test-token' as string | undefined, |
| 49 | +})); |
| 50 | + |
| 51 | +vi.mock('../../utils/api-client.js', async (importOriginal) => { |
| 52 | + const actual = await importOriginal<typeof import('../../utils/api-client.js')>(); |
| 53 | + return { |
| 54 | + ...actual, |
| 55 | + createApiClient: async () => ({ client: stub.client, token: stub.token, baseUrl: 'https://door.test' }), |
| 56 | + }; |
| 57 | +}); |
| 58 | + |
| 59 | +/** `packages/cli` — the oclif root the command is loaded against. */ |
| 60 | +const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); |
| 61 | + |
| 62 | +interface Recorded { |
| 63 | + url: string; |
| 64 | + method: string | undefined; |
| 65 | + body: Record<string, unknown> | undefined; |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * A client whose `fetch` records every request and answers the create route's |
| 70 | + * real envelope. Recording at the WIRE, not at `client.environments.create`, |
| 71 | + * so the assertion reads what the control plane would have received. |
| 72 | + */ |
| 73 | +function recordingClient(): { client: ObjectStackClient; requests: Recorded[] } { |
| 74 | + const requests: Recorded[] = []; |
| 75 | + const client = new ObjectStackClient({ |
| 76 | + baseUrl: 'https://door.test', |
| 77 | + token: 'test-token', |
| 78 | + fetch: async (url: URL | RequestInfo, init?: RequestInit) => { |
| 79 | + requests.push({ |
| 80 | + url: String(url), |
| 81 | + method: init?.method, |
| 82 | + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, |
| 83 | + }); |
| 84 | + return { |
| 85 | + ok: true, |
| 86 | + status: 201, |
| 87 | + statusText: 'Created', |
| 88 | + json: async () => ({ |
| 89 | + environment: { id: 'env_new', display_name: 'Dev' }, |
| 90 | + warnings: [], |
| 91 | + durationMs: 1, |
| 92 | + }), |
| 93 | + headers: new Headers(), |
| 94 | + } as any; |
| 95 | + }, |
| 96 | + }); |
| 97 | + return { client, requests }; |
| 98 | +} |
| 99 | + |
| 100 | +interface CliRun { |
| 101 | + /** What the command threw, or `undefined` when it returned normally. */ |
| 102 | + error: (Error & { flags?: string[] }) | undefined; |
| 103 | + exitCode: number; |
| 104 | +} |
| 105 | + |
| 106 | +async function runCreate(argv: string[]): Promise<CliRun> { |
| 107 | + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); |
| 108 | + const writeSpy = vi |
| 109 | + .spyOn(process.stdout, 'write') |
| 110 | + .mockImplementation(((_chunk: unknown, encodingOrCb?: unknown, maybeCb?: unknown) => { |
| 111 | + const done = typeof encodingOrCb === 'function' ? encodingOrCb : maybeCb; |
| 112 | + if (typeof done === 'function') (done as (err?: Error | null) => void)(null); |
| 113 | + return true; |
| 114 | + }) as never); |
| 115 | + const savedExitCode = process.exitCode; |
| 116 | + process.exitCode = undefined; |
| 117 | + let exitCode = 0; |
| 118 | + let error: CliRun['error']; |
| 119 | + try { |
| 120 | + await EnvironmentsCreate.run(argv, { root: CLI_ROOT }); |
| 121 | + } catch (caught: unknown) { |
| 122 | + error = caught as CliRun['error']; |
| 123 | + const oclif = (caught as { oclif?: { exit?: number } })?.oclif; |
| 124 | + exitCode = typeof oclif?.exit === 'number' ? oclif.exit : 1; |
| 125 | + } finally { |
| 126 | + if (exitCode === 0 && typeof process.exitCode === 'number') exitCode = process.exitCode; |
| 127 | + logSpy.mockRestore(); |
| 128 | + writeSpy.mockRestore(); |
| 129 | + process.exitCode = savedExitCode; |
| 130 | + } |
| 131 | + return { error, exitCode }; |
| 132 | +} |
| 133 | + |
| 134 | +/** Every flag a create needs and nothing else; `--no-activate` keeps it off `~/.objectstack`. */ |
| 135 | +const BASE_ARGV = ['--org', 'org_1', '--name', 'Dev', '--no-activate']; |
| 136 | + |
| 137 | +afterEach(() => { |
| 138 | + stub.client = undefined; |
| 139 | + stub.token = 'test-token'; |
| 140 | +}); |
| 141 | + |
| 142 | +describe('`os environments create` — no `--clone-from`, no `clone_from_environment_id`', () => { |
| 143 | + it('CONTROL — a create without the flag POSTs once and its body names no clone key', async () => { |
| 144 | + const { client, requests } = recordingClient(); |
| 145 | + stub.client = client; |
| 146 | + const run = await runCreate(BASE_ARGV); |
| 147 | + |
| 148 | + expect(run.error).toBeUndefined(); |
| 149 | + expect(run.exitCode).toBe(0); |
| 150 | + expect(requests).toHaveLength(1); |
| 151 | + expect(requests[0].url).toBe('https://door.test/api/v1/cloud/environments'); |
| 152 | + expect(requests[0].method).toBe('POST'); |
| 153 | + // The key SET, not only the absence of one key: this is what reddens if a |
| 154 | + // value for it is ever written again. |
| 155 | + expect(Object.keys(requests[0].body ?? {}).sort()).toEqual(['display_name', 'organization_id', 'plan']); |
| 156 | + expect(requests[0].body).not.toHaveProperty('clone_from_environment_id'); |
| 157 | + }, 60_000); |
| 158 | + |
| 159 | + it.each([ |
| 160 | + ['separate value', ['--clone-from', 'env_source']], |
| 161 | + ['=value', ['--clone-from=env_source']], |
| 162 | + ])('`--clone-from` (%s) is refused by the parser, and the control plane is never called', async (_label, extra) => { |
| 163 | + const { client, requests } = recordingClient(); |
| 164 | + stub.client = client; |
| 165 | + const run = await runCreate([...BASE_ARGV, ...extra]); |
| 166 | + |
| 167 | + // The parser's own answer: a non-zero exit and its `Nonexistent flag` text. |
| 168 | + expect(run.exitCode).not.toBe(0); |
| 169 | + expect(run.error?.message).toMatch(/^Nonexistent flag: --clone-from\b/); |
| 170 | + // Nothing reached the wire: the user is told, rather than handed an EMPTY |
| 171 | + // environment and a success line. |
| 172 | + expect(requests).toEqual([]); |
| 173 | + }, 60_000); |
| 174 | + |
| 175 | + it('the flag is not declared, and no example line spells a flag the command refuses', () => { |
| 176 | + const declared = new Set( |
| 177 | + Object.entries(EnvironmentsCreate.flags).flatMap(([name, flag]) => [ |
| 178 | + `--${name}`, |
| 179 | + ...((flag as { allowNo?: boolean }).allowNo ? [`--no-${name}`] : []), |
| 180 | + ]), |
| 181 | + ); |
| 182 | + |
| 183 | + expect(declared.has('--clone-from')).toBe(false); |
| 184 | + |
| 185 | + const spelled = EnvironmentsCreate.examples |
| 186 | + .flatMap((example) => String(example).match(/--[a-z][a-z0-9-]*/g) ?? []); |
| 187 | + // The control: the examples really do spell flags, so the loop below is not |
| 188 | + // vacuously true over an empty list. |
| 189 | + expect(spelled).toContain('--org'); |
| 190 | + expect(spelled).toContain('--artifact'); |
| 191 | + for (const flag of spelled) { |
| 192 | + expect(declared.has(flag), `example spells ${flag}, which the command does not declare`).toBe(true); |
| 193 | + } |
| 194 | + }); |
| 195 | +}); |
0 commit comments