takos-runtime-service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +29 -0
  2. package/src/__tests__/middleware/rate-limit.test.ts +33 -0
  3. package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
  4. package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
  5. package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
  6. package/src/__tests__/routes/cli-proxy.test.ts +72 -0
  7. package/src/__tests__/routes/git-http.test.ts +218 -0
  8. package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
  9. package/src/__tests__/routes/sessions/store.test.ts +72 -0
  10. package/src/__tests__/routes/workspace-scope.test.ts +45 -0
  11. package/src/__tests__/runtime/action-registry.test.ts +208 -0
  12. package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
  13. package/src/__tests__/runtime/actions/executor.test.ts +131 -0
  14. package/src/__tests__/runtime/composite-expression.test.ts +294 -0
  15. package/src/__tests__/runtime/file-parsers.test.ts +129 -0
  16. package/src/__tests__/runtime/logging.test.ts +65 -0
  17. package/src/__tests__/runtime/paths.test.ts +236 -0
  18. package/src/__tests__/runtime/secrets.test.ts +247 -0
  19. package/src/__tests__/runtime/validation.test.ts +516 -0
  20. package/src/__tests__/setup.ts +126 -0
  21. package/src/__tests__/shared/errors.test.ts +117 -0
  22. package/src/__tests__/storage/r2.test.ts +106 -0
  23. package/src/__tests__/utils/audit-log.test.ts +163 -0
  24. package/src/__tests__/utils/error-message.test.ts +38 -0
  25. package/src/__tests__/utils/sandbox-env.test.ts +74 -0
  26. package/src/app.ts +245 -0
  27. package/src/index.ts +1 -0
  28. package/src/middleware/rate-limit.ts +91 -0
  29. package/src/middleware/space-scope.ts +95 -0
  30. package/src/routes/actions/action-types.ts +20 -0
  31. package/src/routes/actions/execution.ts +229 -0
  32. package/src/routes/actions/index.ts +17 -0
  33. package/src/routes/actions/job-lifecycle.ts +242 -0
  34. package/src/routes/actions/job-queries.ts +52 -0
  35. package/src/routes/cli/proxy.ts +105 -0
  36. package/src/routes/git/http.ts +565 -0
  37. package/src/routes/git/init.ts +88 -0
  38. package/src/routes/repos/branches.ts +160 -0
  39. package/src/routes/repos/content.ts +209 -0
  40. package/src/routes/repos/read.ts +130 -0
  41. package/src/routes/repos/repo-validation.ts +136 -0
  42. package/src/routes/repos/write.ts +274 -0
  43. package/src/routes/runtime/exec.ts +147 -0
  44. package/src/routes/runtime/tools.ts +113 -0
  45. package/src/routes/sessions/execution.ts +263 -0
  46. package/src/routes/sessions/files.ts +326 -0
  47. package/src/routes/sessions/session-routes.ts +241 -0
  48. package/src/routes/sessions/session-utils.ts +88 -0
  49. package/src/routes/sessions/snapshot.ts +208 -0
  50. package/src/routes/sessions/storage.ts +329 -0
  51. package/src/runtime/actions/action-registry.ts +450 -0
  52. package/src/runtime/actions/action-result-converter.ts +31 -0
  53. package/src/runtime/actions/builtin/artifacts.ts +292 -0
  54. package/src/runtime/actions/builtin/cache-operations.ts +358 -0
  55. package/src/runtime/actions/builtin/checkout.ts +58 -0
  56. package/src/runtime/actions/builtin/index.ts +5 -0
  57. package/src/runtime/actions/builtin/setup-node.ts +86 -0
  58. package/src/runtime/actions/builtin/tar-parser.ts +175 -0
  59. package/src/runtime/actions/composite-executor.ts +192 -0
  60. package/src/runtime/actions/composite-expression.ts +190 -0
  61. package/src/runtime/actions/executor.ts +578 -0
  62. package/src/runtime/actions/file-parsers.ts +51 -0
  63. package/src/runtime/actions/job-manager.ts +213 -0
  64. package/src/runtime/actions/process-spawner.ts +275 -0
  65. package/src/runtime/actions/secrets.ts +162 -0
  66. package/src/runtime/command.ts +120 -0
  67. package/src/runtime/exec-runner.ts +309 -0
  68. package/src/runtime/git-http-backend.ts +145 -0
  69. package/src/runtime/git.ts +98 -0
  70. package/src/runtime/heartbeat.ts +57 -0
  71. package/src/runtime/logging.ts +26 -0
  72. package/src/runtime/paths.ts +264 -0
  73. package/src/runtime/secure-fs.ts +82 -0
  74. package/src/runtime/tools/network.ts +161 -0
  75. package/src/runtime/tools/worker.ts +335 -0
  76. package/src/runtime/validation.ts +292 -0
  77. package/src/shared/config.ts +149 -0
  78. package/src/shared/errors.ts +65 -0
  79. package/src/shared/temp-id.ts +10 -0
  80. package/src/storage/r2.ts +287 -0
  81. package/src/types/hono.d.ts +23 -0
  82. package/src/utils/audit-log.ts +92 -0
  83. package/src/utils/process-kill.ts +18 -0
  84. package/src/utils/sandbox-env.ts +136 -0
  85. package/src/utils/temp-dir.ts +74 -0
@@ -0,0 +1,218 @@
1
+ import { Hono } from 'hono';
2
+ import * as fs from 'fs/promises';
3
+ import path from 'path';
4
+ import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'child_process';
5
+ import { EventEmitter } from 'events';
6
+ import { PassThrough } from 'stream';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ const hoisted = vi.hoisted(() => ({
10
+ reposBaseDir: '/tmp/takos-runtime-git-http-tests',
11
+ spawn: vi.fn(),
12
+ gracefulKill: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../../shared/config.js', () => ({
16
+ REPOS_BASE_DIR: hoisted.reposBaseDir,
17
+ }));
18
+
19
+ vi.mock('child_process', () => ({
20
+ spawn: hoisted.spawn,
21
+ }));
22
+
23
+ vi.mock('../../utils/process-kill.js', () => ({
24
+ gracefulKill: hoisted.gracefulKill,
25
+ }));
26
+
27
+ import gitHttpRoutes from '../../routes/git/http.js';
28
+
29
+ interface MockChildProcess extends ChildProcessWithoutNullStreams {
30
+ stdin: PassThrough;
31
+ stdout: PassThrough;
32
+ stderr: PassThrough;
33
+ killed: boolean;
34
+ }
35
+
36
+ function createTestApp() {
37
+ const app = new Hono();
38
+ app.route('/', gitHttpRoutes);
39
+ return app;
40
+ }
41
+
42
+ async function createRepoDir(spaceId: string, repoName: string): Promise<void> {
43
+ await fs.mkdir(path.join(hoisted.reposBaseDir, spaceId, `${repoName}.git`), { recursive: true });
44
+ }
45
+
46
+ function createMockChildProcess(): MockChildProcess {
47
+ const child = new EventEmitter() as MockChildProcess;
48
+ child.stdin = new PassThrough();
49
+ child.stdout = new PassThrough();
50
+ child.stderr = new PassThrough();
51
+ child.killed = false;
52
+ child.kill = vi.fn(() => {
53
+ child.killed = true;
54
+ return true;
55
+ }) as ChildProcessWithoutNullStreams['kill'];
56
+ return child;
57
+ }
58
+
59
+ describe('git-http route hardening', () => {
60
+ beforeEach(async () => {
61
+ vi.clearAllMocks();
62
+ vi.useRealTimers();
63
+ await fs.rm(hoisted.reposBaseDir, { recursive: true, force: true });
64
+ await fs.mkdir(hoisted.reposBaseDir, { recursive: true });
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.useRealTimers();
69
+ });
70
+
71
+ it('rejects workspace-scoped JWT that targets another workspace', async () => {
72
+ const app = new Hono();
73
+ // Simulate service token middleware setting serviceToken on context
74
+ app.use('*', async (c, next) => {
75
+ c.set('serviceToken', { scope_space_id: 'ws-allowed' } as any);
76
+ await next();
77
+ });
78
+ app.route('/', gitHttpRoutes);
79
+
80
+ const response = await app.request('/git/ws-denied/repo.git/info/refs?service=git-upload-pack');
81
+
82
+ expect(response.status).toBe(403);
83
+ const body = await response.json();
84
+ expect(body).toEqual({
85
+ error: {
86
+ code: 'FORBIDDEN',
87
+ message: 'Token workspace scope does not match requested workspace',
88
+ },
89
+ });
90
+ });
91
+
92
+ it('supports LFS batch endpoint and reports missing download objects', async () => {
93
+ const spaceId = 'ws123';
94
+ const repoName = 'repo123';
95
+ const oid = 'a'.repeat(64);
96
+ await createRepoDir(spaceId, repoName);
97
+
98
+ const app = createTestApp();
99
+
100
+ const batchUpload = await app.request(
101
+ `/git/${spaceId}/${repoName}.git/info/lfs/objects/batch`,
102
+ {
103
+ method: 'POST',
104
+ headers: {
105
+ 'Content-Type': 'application/vnd.git-lfs+json',
106
+ 'Host': 'localhost',
107
+ },
108
+ body: JSON.stringify({
109
+ operation: 'upload',
110
+ objects: [{ oid, size: 12 }],
111
+ }),
112
+ }
113
+ );
114
+
115
+ expect(batchUpload.status).toBe(200);
116
+ const uploadBody = await batchUpload.json();
117
+ expect(uploadBody).toMatchObject({
118
+ transfer: 'basic',
119
+ objects: [
120
+ {
121
+ oid,
122
+ actions: {
123
+ upload: {
124
+ href: expect.stringContaining(`/git/${spaceId}/${repoName}.git/info/lfs/objects/${oid}`),
125
+ },
126
+ },
127
+ },
128
+ ],
129
+ });
130
+
131
+ const batchDownload = await app.request(
132
+ `/git/${spaceId}/${repoName}.git/info/lfs/objects/batch`,
133
+ {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/vnd.git-lfs+json',
137
+ 'Host': 'localhost',
138
+ },
139
+ body: JSON.stringify({
140
+ operation: 'download',
141
+ objects: [{ oid, size: 12 }],
142
+ }),
143
+ }
144
+ );
145
+
146
+ expect(batchDownload.status).toBe(200);
147
+ const downloadBody = await batchDownload.json();
148
+ expect(downloadBody).toMatchObject({
149
+ transfer: 'basic',
150
+ objects: [
151
+ {
152
+ oid,
153
+ error: {
154
+ code: 404,
155
+ message: 'Object does not exist',
156
+ },
157
+ },
158
+ ],
159
+ });
160
+ });
161
+
162
+ it('returns 400 for invalid oid in both LFS upload/download object handlers', async () => {
163
+ const spaceId = 'ws-invalid-oid';
164
+ const repoName = 'repo-invalid-oid';
165
+ await createRepoDir(spaceId, repoName);
166
+ const app = createTestApp();
167
+
168
+ const invalidOid = 'not-a-valid-oid';
169
+ const putResponse = await app.request(
170
+ `/git/${spaceId}/${repoName}.git/info/lfs/objects/${invalidOid}`,
171
+ { method: 'PUT' }
172
+ );
173
+ const getResponse = await app.request(
174
+ `/git/${spaceId}/${repoName}.git/info/lfs/objects/${invalidOid}`
175
+ );
176
+
177
+ expect(putResponse.status).toBe(400);
178
+ const putBody = await putResponse.json();
179
+ expect(putBody).toEqual({ error: { code: 'BAD_REQUEST', message: 'Invalid LFS object id' } });
180
+ expect(getResponse.status).toBe(400);
181
+ const getBody = await getResponse.json();
182
+ expect(getBody).toEqual({ error: { code: 'BAD_REQUEST', message: 'Invalid LFS object id' } });
183
+ });
184
+
185
+ it('keeps PUT content-length validation response', async () => {
186
+ const app = createTestApp();
187
+
188
+ const response = await app.request(
189
+ `/git/ws-content-length/repo-content-length.git/info/lfs/objects/${'a'.repeat(64)}`,
190
+ {
191
+ method: 'PUT',
192
+ headers: {
193
+ 'Content-Length': 'abc',
194
+ },
195
+ }
196
+ );
197
+
198
+ expect(response.status).toBe(400);
199
+ const body = await response.json();
200
+ expect(body).toEqual({ error: { code: 'BAD_REQUEST', message: 'Invalid Content-Length' } });
201
+ });
202
+
203
+ it('returns 404 for missing LFS object download', async () => {
204
+ const spaceId = 'ws-missing-object';
205
+ const repoName = 'repo-missing-object';
206
+ const oid = 'b'.repeat(64);
207
+ await createRepoDir(spaceId, repoName);
208
+
209
+ const app = createTestApp();
210
+ const response = await app.request(
211
+ `/git/${spaceId}/${repoName}.git/info/lfs/objects/${oid}`
212
+ );
213
+
214
+ expect(response.status).toBe(404);
215
+ const body = await response.json();
216
+ expect(body).toEqual({ error: { code: 'NOT_FOUND', message: 'LFS object not found' } });
217
+ });
218
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../../shared/config.js', () => ({
4
+ REPOS_BASE_DIR: '/tmp/test-repos',
5
+ }));
6
+
7
+ import {
8
+ buildLfsBatchObjectResponse,
9
+ getLfsObjectPath,
10
+ normalizeLfsOid,
11
+ parseContentLength,
12
+ parseLfsBatchRequest,
13
+ } from '../../routes/git/http.js';
14
+
15
+ describe('git-lfs policy helpers', () => {
16
+ it('normalizes valid oid and rejects invalid values', () => {
17
+ const upper = 'A'.repeat(64);
18
+ expect(normalizeLfsOid(upper)).toBe('a'.repeat(64));
19
+ expect(normalizeLfsOid('invalid')).toBeNull();
20
+ expect(normalizeLfsOid(undefined)).toBeNull();
21
+ });
22
+
23
+ it('parses valid LFS batch request and normalizes object oids', () => {
24
+ const parsed = parseLfsBatchRequest({
25
+ operation: 'upload',
26
+ objects: [{ oid: 'A'.repeat(64), size: 42 }],
27
+ });
28
+
29
+ expect(parsed).toEqual({
30
+ operation: 'upload',
31
+ objects: [{ oid: 'a'.repeat(64), size: 42 }],
32
+ });
33
+ });
34
+
35
+ it('rejects malformed LFS batch request payloads', () => {
36
+ expect(parseLfsBatchRequest({ operation: 'upload', objects: [{ oid: 'abc', size: 1 }] })).toBeNull();
37
+ expect(parseLfsBatchRequest({ operation: 'download', objects: [{ oid: 'a'.repeat(64), size: -1 }] })).toBeNull();
38
+ expect(parseLfsBatchRequest({ operation: 'download' })).toBeNull();
39
+ expect(parseLfsBatchRequest(null)).toBeNull();
40
+ });
41
+
42
+ it('parses content length consistently', () => {
43
+ expect(parseContentLength(undefined)).toBeNull();
44
+ expect(parseContentLength('')).toBeNull();
45
+ expect(parseContentLength('123')).toBe(123);
46
+ expect(Number.isNaN(parseContentLength('12x'))).toBe(true);
47
+ });
48
+
49
+ it('builds stable object paths from oid sharding', () => {
50
+ const oid = 'ab'.padEnd(64, 'c');
51
+ expect(getLfsObjectPath('/repo.git', oid)).toBe(
52
+ '/repo.git/lfs/objects/ab/cc/'.concat(oid)
53
+ );
54
+ });
55
+
56
+ it('builds upload/download batch responses from existence policy', () => {
57
+ const oid = 'a'.repeat(64);
58
+ const href = `https://example.test/git/ws/repo.git/info/lfs/objects/${oid}`;
59
+
60
+ expect(
61
+ buildLfsBatchObjectResponse({
62
+ operation: 'upload',
63
+ oid,
64
+ size: 12,
65
+ exists: true,
66
+ href,
67
+ })
68
+ ).toEqual({ oid, size: 12 });
69
+
70
+ expect(
71
+ buildLfsBatchObjectResponse({
72
+ operation: 'upload',
73
+ oid,
74
+ size: 12,
75
+ exists: false,
76
+ href,
77
+ })
78
+ ).toEqual({
79
+ oid,
80
+ size: 12,
81
+ actions: { upload: { href, expires_in: 3600 } },
82
+ });
83
+
84
+ expect(
85
+ buildLfsBatchObjectResponse({
86
+ operation: 'download',
87
+ oid,
88
+ size: 12,
89
+ exists: false,
90
+ href,
91
+ })
92
+ ).toEqual({
93
+ oid,
94
+ size: 12,
95
+ error: { code: 404, message: 'Object does not exist' },
96
+ });
97
+
98
+ expect(
99
+ buildLfsBatchObjectResponse({
100
+ operation: 'download',
101
+ oid,
102
+ size: 12,
103
+ exists: true,
104
+ href,
105
+ })
106
+ ).toEqual({
107
+ oid,
108
+ size: 12,
109
+ actions: { download: { href, expires_in: 3600 } },
110
+ });
111
+ });
112
+ });
@@ -0,0 +1,72 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ vi.mock('../../../shared/config.js', () => ({
6
+ HEARTBEAT_INTERVAL_MS: 120000,
7
+ HEARTBEAT_ASSUMED_INTERVAL_MS: 2 * 60 * 1000,
8
+ PROXY_BASE_URL: undefined,
9
+ SESSION_IDLE_TIMEOUT_MS: 10 * 60 * 1000,
10
+ SESSION_MAX_DURATION_MS: 60 * 60 * 1000,
11
+ SESSION_CLEANUP_INTERVAL_MS: 30 * 1000,
12
+ MAX_SESSIONS_PER_WORKSPACE: 2,
13
+ MAX_TOTAL_SESSIONS: 100000,
14
+ }));
15
+
16
+ import { sessionStore } from '../../../routes/sessions/storage.js';
17
+
18
+ describe('session owner binding', () => {
19
+ it('rejects session reuse when owner sub does not match', async () => {
20
+ const sessionId = 'a12345678901234b';
21
+ const spaceId = 'ws046owner1';
22
+
23
+ try {
24
+ await sessionStore.getSessionDir(sessionId, spaceId, 'owner-a');
25
+ expect(() => sessionStore.getSessionWithValidation(sessionId, spaceId, 'owner-a')).not.toThrow();
26
+ expect(() => sessionStore.getSessionWithValidation(sessionId, spaceId, 'owner-b')).toThrow(
27
+ 'Session does not belong to the authenticated owner'
28
+ );
29
+ } finally {
30
+ await sessionStore.destroySession(sessionId, spaceId, 'owner-a');
31
+ }
32
+ });
33
+
34
+ it('rejects retroactive owner binding when session was created without explicit owner', async () => {
35
+ const sessionId = 'a12345678901234c';
36
+ const spaceId = 'ws046owner2';
37
+
38
+ try {
39
+ await sessionStore.getSessionDir(sessionId, spaceId);
40
+ expect(() => sessionStore.getSessionWithValidation(sessionId, spaceId)).not.toThrow();
41
+ expect(() => sessionStore.getSessionWithValidation(sessionId, spaceId, 'owner-a')).toThrow(
42
+ 'Session does not belong to the authenticated owner'
43
+ );
44
+ expect(() => sessionStore.getSessionWithValidation(sessionId, spaceId, 'owner-b')).toThrow(
45
+ 'Session does not belong to the authenticated owner'
46
+ );
47
+ } finally {
48
+ await sessionStore.destroySession(sessionId, spaceId);
49
+ }
50
+ });
51
+ });
52
+
53
+ describe('.takos-session metadata', () => {
54
+ it('stores only session_id and space_id', async () => {
55
+ const sessionId = 'a12345678901234d';
56
+ const spaceId = 'ws046owner3';
57
+
58
+ try {
59
+ const workDir = await sessionStore.getSessionDir(sessionId, spaceId, 'owner-a');
60
+ const sessionInfoPath = path.join(workDir, '.takos-session');
61
+ const sessionInfo = JSON.parse(await fs.readFile(sessionInfoPath, 'utf-8')) as Record<string, unknown>;
62
+
63
+ expect(sessionInfo).toEqual({
64
+ session_id: sessionId,
65
+ space_id: spaceId,
66
+ });
67
+ expect(sessionInfo).not.toHaveProperty('api_url');
68
+ } finally {
69
+ await sessionStore.destroySession(sessionId, spaceId, 'owner-a');
70
+ }
71
+ });
72
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { getSpaceIdFromBody } from '../../middleware/space-scope.js';
4
+
5
+ function createContext(body: unknown): { get: (key: string) => unknown } {
6
+ return {
7
+ get(key: string) {
8
+ if (key === 'parsedBody') return body;
9
+ return undefined;
10
+ },
11
+ };
12
+ }
13
+
14
+ describe('getSpaceIdFromBody', () => {
15
+ it('returns spaceId from camelCase body field', () => {
16
+ const c = createContext({ spaceId: 'ws-camel' });
17
+ expect(getSpaceIdFromBody(c as any, 'spaceId')).toBe('ws-camel');
18
+ });
19
+
20
+ it('returns space_id from snake_case body field', () => {
21
+ const c = createContext({ space_id: 'ws-snake' });
22
+ expect(getSpaceIdFromBody(c as any, 'space_id')).toBe('ws-snake');
23
+ });
24
+
25
+ it('returns null for missing, empty, and non-string values', () => {
26
+ const invalidBodies: unknown[] = [
27
+ undefined,
28
+ null,
29
+ false,
30
+ 0,
31
+ '',
32
+ {},
33
+ { spaceId: '' },
34
+ { spaceId: 123 },
35
+ { space_id: '' },
36
+ { space_id: 123 },
37
+ ];
38
+
39
+ for (const body of invalidBodies) {
40
+ const c = createContext(body);
41
+ expect(getSpaceIdFromBody(c as any, 'spaceId')).toBeNull();
42
+ expect(getSpaceIdFromBody(c as any, 'space_id')).toBeNull();
43
+ }
44
+ });
45
+ });
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../../shared/config.js', () => ({
4
+ REPOS_BASE_DIR: '/repos',
5
+ WORKDIR_BASE_DIR: '/tmp',
6
+ GIT_ENDPOINT_URL: 'https://git.takos.dev',
7
+ }));
8
+
9
+ vi.mock('../../runtime/git.js', () => ({
10
+ cloneAndCheckout: vi.fn(),
11
+ }));
12
+
13
+ import {
14
+ parseActionRef,
15
+ validateActionComponent,
16
+ resolveInputs,
17
+ buildInputEnv,
18
+ } from '../../runtime/actions/action-registry.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // parseActionRef
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('parseActionRef', () => {
25
+ it('parses owner/repo@ref', () => {
26
+ expect(parseActionRef('actions/checkout@v4')).toEqual({
27
+ owner: 'actions',
28
+ repo: 'checkout',
29
+ actionPath: '',
30
+ ref: 'v4',
31
+ });
32
+ });
33
+
34
+ it('parses owner/repo/subpath@ref', () => {
35
+ expect(parseActionRef('actions/toolkit/packages/core@v1')).toEqual({
36
+ owner: 'actions',
37
+ repo: 'toolkit',
38
+ actionPath: 'packages/core',
39
+ ref: 'v1',
40
+ });
41
+ });
42
+
43
+ it('defaults to main when no @ref', () => {
44
+ expect(parseActionRef('owner/repo')).toEqual({
45
+ owner: 'owner',
46
+ repo: 'repo',
47
+ actionPath: '',
48
+ ref: 'main',
49
+ });
50
+ });
51
+
52
+ it('handles empty ref after @', () => {
53
+ expect(parseActionRef('owner/repo@')).toEqual({
54
+ owner: 'owner',
55
+ repo: 'repo',
56
+ actionPath: '',
57
+ ref: 'main',
58
+ });
59
+ });
60
+
61
+ it('handles single component (no slash)', () => {
62
+ expect(parseActionRef('single@v1')).toEqual({
63
+ owner: 'single',
64
+ repo: '',
65
+ actionPath: '',
66
+ ref: 'v1',
67
+ });
68
+ });
69
+
70
+ it('handles deep nested action path', () => {
71
+ expect(parseActionRef('org/repo/a/b/c@v2')).toEqual({
72
+ owner: 'org',
73
+ repo: 'repo',
74
+ actionPath: 'a/b/c',
75
+ ref: 'v2',
76
+ });
77
+ });
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // validateActionComponent
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('validateActionComponent', () => {
85
+ it('accepts valid component', () => {
86
+ expect(() => validateActionComponent('actions', 'owner')).not.toThrow();
87
+ expect(() => validateActionComponent('my-repo_v2', 'repo')).not.toThrow();
88
+ expect(() => validateActionComponent('v1.0.0', 'ref')).not.toThrow();
89
+ });
90
+
91
+ it('rejects component with slash', () => {
92
+ expect(() => validateActionComponent('path/to', 'owner')).toThrow('Invalid action owner');
93
+ });
94
+
95
+ it('rejects component with spaces', () => {
96
+ expect(() => validateActionComponent('has space', 'repo')).toThrow('Invalid action repo');
97
+ });
98
+
99
+ it('rejects component with special chars', () => {
100
+ expect(() => validateActionComponent('bad@char', 'ref')).toThrow('Invalid action ref');
101
+ });
102
+
103
+ it('rejects empty component', () => {
104
+ expect(() => validateActionComponent('', 'owner')).toThrow('Invalid action owner');
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // resolveInputs
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('resolveInputs', () => {
113
+ it('resolves provided inputs', () => {
114
+ const definitions = {
115
+ name: { description: 'Name', required: true },
116
+ };
117
+ const { resolvedInputs, missing } = resolveInputs(definitions, { name: 'John' });
118
+ expect(resolvedInputs).toEqual({ name: 'John' });
119
+ expect(missing).toEqual([]);
120
+ });
121
+
122
+ it('uses default values when not provided', () => {
123
+ const definitions = {
124
+ name: { description: 'Name', default: 'Default' },
125
+ };
126
+ const { resolvedInputs, missing } = resolveInputs(definitions, {});
127
+ expect(resolvedInputs).toEqual({ name: 'Default' });
128
+ expect(missing).toEqual([]);
129
+ });
130
+
131
+ it('reports missing required inputs', () => {
132
+ const definitions = {
133
+ name: { description: 'Name', required: true },
134
+ };
135
+ const { resolvedInputs, missing } = resolveInputs(definitions, {});
136
+ expect(missing).toEqual(['name']);
137
+ });
138
+
139
+ it('matches inputs case-insensitively', () => {
140
+ const definitions = {
141
+ Name: { description: 'Name', required: true },
142
+ };
143
+ const { resolvedInputs } = resolveInputs(definitions, { name: 'John' });
144
+ expect(resolvedInputs).toEqual({ Name: 'John' });
145
+ });
146
+
147
+ it('passes through undefined definitions', () => {
148
+ const { resolvedInputs, missing } = resolveInputs(undefined, { extra: 'value' });
149
+ expect(resolvedInputs).toEqual({ extra: 'value' });
150
+ expect(missing).toEqual([]);
151
+ });
152
+
153
+ it('normalizes boolean default values', () => {
154
+ const definitions = {
155
+ flag: { description: 'Flag', default: true },
156
+ };
157
+ const { resolvedInputs } = resolveInputs(definitions, {});
158
+ expect(resolvedInputs).toEqual({ flag: 'true' });
159
+ });
160
+
161
+ it('normalizes null default to empty string', () => {
162
+ const definitions = {
163
+ val: { description: 'Val', default: null },
164
+ };
165
+ const { resolvedInputs } = resolveInputs(definitions, {});
166
+ expect(resolvedInputs).toEqual({ val: '' });
167
+ });
168
+
169
+ it('passes through extra inputs not in definitions', () => {
170
+ const definitions = {
171
+ defined: { description: 'Defined' },
172
+ };
173
+ const { resolvedInputs } = resolveInputs(definitions, {
174
+ defined: 'yes',
175
+ extra: 'bonus',
176
+ });
177
+ expect(resolvedInputs).toEqual({ defined: 'yes', extra: 'bonus' });
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // buildInputEnv
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe('buildInputEnv', () => {
186
+ it('creates INPUT_* env vars', () => {
187
+ expect(buildInputEnv({ name: 'John', version: '1.0' })).toEqual({
188
+ INPUT_NAME: 'John',
189
+ INPUT_VERSION: '1.0',
190
+ });
191
+ });
192
+
193
+ it('uppercases and sanitizes key names', () => {
194
+ expect(buildInputEnv({ 'my-input': 'value' })).toEqual({
195
+ INPUT_MY_INPUT: 'value',
196
+ });
197
+ });
198
+
199
+ it('handles empty inputs', () => {
200
+ expect(buildInputEnv({})).toEqual({});
201
+ });
202
+
203
+ it('replaces dots in key names', () => {
204
+ expect(buildInputEnv({ 'dotted.key': 'val' })).toEqual({
205
+ INPUT_DOTTED_KEY: 'val',
206
+ });
207
+ });
208
+ });