diff --git a/packages/targets/deploy-railway/src/index.test.ts b/packages/targets/deploy-railway/src/index.test.ts index c2877a6c..c775b0c0 100644 --- a/packages/targets/deploy-railway/src/index.test.ts +++ b/packages/targets/deploy-railway/src/index.test.ts @@ -1,4 +1,90 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { fakeShipContext, makeVault, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock } = vi.hoisted(() => ({ + execMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, +})); + import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'deploy', requireKind: true }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Railway deployment target', () => { + it('keeps dry-run shipping side-effect free while validating config', async () => { + await expect(adapter.ship(fakeShipContext({ + channel: 'beta', + dryRun: true, + }) as any, { + projectId: 'project-123', + serviceId: 'service-123', + })).resolves.toEqual({ id: 'dry-run' }); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('rejects invalid Railway config before CLI work', async () => { + await expect(adapter.ship(fakeShipContext({ + dryRun: true, + }) as any, { + projectId: ' ', + serviceId: 'service-123', + })).rejects.toThrow('deploy-railway requires projectId'); + + await expect(adapter.ship(fakeShipContext({ + dryRun: true, + }) as any, { + projectId: 'project-123', + serviceId: 'service/123', + })).rejects.toThrow('serviceId must be a single URL path segment'); + + await expect(adapter.ship(fakeShipContext({ + dryRun: true, + }) as any, { + projectId: 'project-123', + serviceId: 'service-123', + environment: 'bad env', + })).rejects.toThrow('environment must contain only letters'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('runs railway up with normalized service and environment values', async () => { + execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + + const result = await adapter.ship(fakeShipContext({ + channel: 'stable', + dryRun: false, + version: '1.2.3', + secret: makeVault({ RAILWAY_TOKEN: 'mock-token' }), + }) as any, { + projectId: ' project-123 ', + serviceId: ' service-123 ', + environment: ' prod_1 ', + }); + + expect(execMock).toHaveBeenCalledWith('railway', [ + 'up', + '--ci', + '--service', + 'service-123', + '--environment', + 'prod_1', + ], expect.objectContaining({ + env: { RAILWAY_TOKEN: 'mock-token' }, + throwOnNonZero: true, + })); + expect(result).toEqual({ + id: 'service-123@1.2.3', + meta: { projectId: 'project-123', environment: 'prod_1' }, + }); + }); +}); diff --git a/packages/targets/deploy-railway/src/index.ts b/packages/targets/deploy-railway/src/index.ts index 4037c968..aedb73d5 100644 --- a/packages/targets/deploy-railway/src/index.ts +++ b/packages/targets/deploy-railway/src/index.ts @@ -7,6 +7,30 @@ interface Config { detach?: boolean; } +function requireText(value: string | undefined, field: string): string { + const text = value?.trim(); + if (!text) throw new Error(`deploy-railway requires ${field}`); + return text; +} + +function requireSegment(value: string | undefined, field: string): string { + const text = requireText(value, field); + if (/[\\/?#\x00-\x1F\x7F]/.test(text)) { + throw new Error(`deploy-railway ${field} must be a single URL path segment`); + } + return text; +} + +function environmentName(config: Config, channel: string): string { + const env = config.environment === undefined + ? (channel === 'stable' ? 'production' : 'staging') + : requireText(config.environment, 'environment'); + if (!/^[A-Za-z0-9._-]+$/.test(env)) { + throw new Error('deploy-railway environment must contain only letters, numbers, dots, underscores, or hyphens'); + } + return env; +} + export default defineTarget({ id: 'deploy-railway', kind: 'web', @@ -16,6 +40,12 @@ export default defineTarget({ return { artifact: ctx.projectDir }; }, async ship(ctx, config) { + config = { + ...config, + projectId: requireSegment(config.projectId, 'projectId'), + serviceId: requireSegment(config.serviceId, 'serviceId'), + environment: environmentName(config, ctx.channel), + }; const env = config.environment ?? (ctx.channel === 'stable' ? 'production' : 'staging'); ctx.log(`railway up · service=${config.serviceId} · env=${env}`); if (ctx.dryRun) return { id: 'dry-run' };