@celilo/cli 0.1.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.
- package/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- package/src/variables/types.ts +59 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
3
|
+
import { buildCapabilityData, promptForSecretValue } from './registration';
|
|
4
|
+
|
|
5
|
+
describe('Capability Registration', () => {
|
|
6
|
+
describe('buildCapabilityData', () => {
|
|
7
|
+
test('should return capability data unchanged', () => {
|
|
8
|
+
const capability = {
|
|
9
|
+
name: 'test_capability',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
data: {
|
|
12
|
+
server: {
|
|
13
|
+
port: 443,
|
|
14
|
+
protocol: 'https',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const manifest: ModuleManifest = {
|
|
20
|
+
celilo_contract: '1.0',
|
|
21
|
+
id: 'test-module',
|
|
22
|
+
name: 'Test Module',
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
description: 'Test',
|
|
25
|
+
requires: { capabilities: [] },
|
|
26
|
+
provides: { capabilities: [] },
|
|
27
|
+
variables: { owns: [], imports: [] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const result = buildCapabilityData(capability, manifest);
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
server: {
|
|
34
|
+
port: 443,
|
|
35
|
+
protocol: 'https',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should preserve $self: variables (not resolved)', () => {
|
|
41
|
+
const capability = {
|
|
42
|
+
name: 'test_capability',
|
|
43
|
+
version: '1.0.0',
|
|
44
|
+
data: {
|
|
45
|
+
server: {
|
|
46
|
+
ip: '$self:container_ip',
|
|
47
|
+
port: 443,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const manifest: ModuleManifest = {
|
|
53
|
+
celilo_contract: '1.0',
|
|
54
|
+
id: 'test-module',
|
|
55
|
+
name: 'Test Module',
|
|
56
|
+
version: '1.0.0',
|
|
57
|
+
description: 'Test',
|
|
58
|
+
requires: { capabilities: [] },
|
|
59
|
+
provides: { capabilities: [] },
|
|
60
|
+
variables: { owns: [], imports: [] },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const result = buildCapabilityData(capability, manifest);
|
|
64
|
+
|
|
65
|
+
// Variables are preserved, not resolved
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
server: {
|
|
68
|
+
ip: '$self:container_ip',
|
|
69
|
+
port: 443,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should preserve nested $self: variables', () => {
|
|
75
|
+
const capability = {
|
|
76
|
+
name: 'dns_external',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
data: {
|
|
79
|
+
server: {
|
|
80
|
+
ip: {
|
|
81
|
+
primary: '$self:vps_ip',
|
|
82
|
+
},
|
|
83
|
+
port: 53,
|
|
84
|
+
},
|
|
85
|
+
zone: {
|
|
86
|
+
primary_domain: '$self:primary_domain',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const manifest: ModuleManifest = {
|
|
92
|
+
celilo_contract: '1.0',
|
|
93
|
+
id: 'dns-external',
|
|
94
|
+
name: 'DNS External',
|
|
95
|
+
version: '1.0.0',
|
|
96
|
+
description: 'Test',
|
|
97
|
+
requires: { capabilities: [] },
|
|
98
|
+
provides: { capabilities: [] },
|
|
99
|
+
variables: { owns: [], imports: [] },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const result = buildCapabilityData(capability, manifest);
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
server: {
|
|
106
|
+
ip: {
|
|
107
|
+
primary: '$self:vps_ip',
|
|
108
|
+
},
|
|
109
|
+
port: 53,
|
|
110
|
+
},
|
|
111
|
+
zone: {
|
|
112
|
+
primary_domain: '$self:primary_domain',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('should preserve multiple variables in same object', () => {
|
|
118
|
+
const capability = {
|
|
119
|
+
name: 'test_capability',
|
|
120
|
+
version: '1.0.0',
|
|
121
|
+
data: {
|
|
122
|
+
url: '$self:base_url',
|
|
123
|
+
api_version: '$self:api_version',
|
|
124
|
+
timeout: 30,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const manifest: ModuleManifest = {
|
|
129
|
+
celilo_contract: '1.0',
|
|
130
|
+
id: 'test-module',
|
|
131
|
+
name: 'Test Module',
|
|
132
|
+
version: '1.0.0',
|
|
133
|
+
description: 'Test',
|
|
134
|
+
requires: { capabilities: [] },
|
|
135
|
+
provides: { capabilities: [] },
|
|
136
|
+
variables: { owns: [], imports: [] },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = buildCapabilityData(capability, manifest);
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
url: '$self:base_url',
|
|
143
|
+
api_version: '$self:api_version',
|
|
144
|
+
timeout: 30,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('should preserve variables in arrays', () => {
|
|
149
|
+
const capability = {
|
|
150
|
+
name: 'test_capability',
|
|
151
|
+
version: '1.0.0',
|
|
152
|
+
data: {
|
|
153
|
+
servers: ['$self:primary_server', '$self:backup_server'],
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const manifest: ModuleManifest = {
|
|
158
|
+
celilo_contract: '1.0',
|
|
159
|
+
id: 'test-module',
|
|
160
|
+
name: 'Test Module',
|
|
161
|
+
version: '1.0.0',
|
|
162
|
+
description: 'Test',
|
|
163
|
+
requires: { capabilities: [] },
|
|
164
|
+
provides: { capabilities: [] },
|
|
165
|
+
variables: { owns: [], imports: [] },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const result = buildCapabilityData(capability, manifest);
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({
|
|
171
|
+
servers: ['$self:primary_server', '$self:backup_server'],
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('should preserve all string types including variables', () => {
|
|
176
|
+
const capability = {
|
|
177
|
+
name: 'test_capability',
|
|
178
|
+
version: '1.0.0',
|
|
179
|
+
data: {
|
|
180
|
+
variable: '$self:container_ip',
|
|
181
|
+
literal: 'not-a-variable',
|
|
182
|
+
with_dollar: '$100',
|
|
183
|
+
empty: '',
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const manifest: ModuleManifest = {
|
|
188
|
+
celilo_contract: '1.0',
|
|
189
|
+
id: 'test-module',
|
|
190
|
+
name: 'Test Module',
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
description: 'Test',
|
|
193
|
+
requires: { capabilities: [] },
|
|
194
|
+
provides: { capabilities: [] },
|
|
195
|
+
variables: { owns: [], imports: [] },
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const result = buildCapabilityData(capability, manifest);
|
|
199
|
+
|
|
200
|
+
expect(result).toEqual({
|
|
201
|
+
variable: '$self:container_ip',
|
|
202
|
+
literal: 'not-a-variable',
|
|
203
|
+
with_dollar: '$100',
|
|
204
|
+
empty: '',
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('should handle empty data object', () => {
|
|
209
|
+
const capability = {
|
|
210
|
+
name: 'test_capability',
|
|
211
|
+
version: '1.0.0',
|
|
212
|
+
data: {},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const manifest: ModuleManifest = {
|
|
216
|
+
celilo_contract: '1.0',
|
|
217
|
+
id: 'test-module',
|
|
218
|
+
name: 'Test Module',
|
|
219
|
+
version: '1.0.0',
|
|
220
|
+
description: 'Test',
|
|
221
|
+
requires: { capabilities: [] },
|
|
222
|
+
provides: { capabilities: [] },
|
|
223
|
+
variables: { owns: [], imports: [] },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const result = buildCapabilityData(capability, manifest);
|
|
227
|
+
|
|
228
|
+
expect(result).toEqual({});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('should preserve deeply nested structures', () => {
|
|
232
|
+
const capability = {
|
|
233
|
+
name: 'test_capability',
|
|
234
|
+
version: '1.0.0',
|
|
235
|
+
data: {
|
|
236
|
+
level1: {
|
|
237
|
+
level2: {
|
|
238
|
+
level3: {
|
|
239
|
+
value: '$self:deep_value',
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const manifest: ModuleManifest = {
|
|
247
|
+
celilo_contract: '1.0',
|
|
248
|
+
id: 'test-module',
|
|
249
|
+
name: 'Test Module',
|
|
250
|
+
version: '1.0.0',
|
|
251
|
+
description: 'Test',
|
|
252
|
+
requires: { capabilities: [] },
|
|
253
|
+
provides: { capabilities: [] },
|
|
254
|
+
variables: { owns: [], imports: [] },
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = buildCapabilityData(capability, manifest);
|
|
258
|
+
|
|
259
|
+
expect(result).toEqual({
|
|
260
|
+
level1: {
|
|
261
|
+
level2: {
|
|
262
|
+
level3: {
|
|
263
|
+
value: '$self:deep_value',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('should preserve number and boolean types', () => {
|
|
271
|
+
const capability = {
|
|
272
|
+
name: 'test_capability',
|
|
273
|
+
version: '1.0.0',
|
|
274
|
+
data: {
|
|
275
|
+
port: 443,
|
|
276
|
+
enabled: true,
|
|
277
|
+
timeout: 0,
|
|
278
|
+
disabled: false,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const manifest: ModuleManifest = {
|
|
283
|
+
celilo_contract: '1.0',
|
|
284
|
+
id: 'test-module',
|
|
285
|
+
name: 'Test Module',
|
|
286
|
+
version: '1.0.0',
|
|
287
|
+
description: 'Test',
|
|
288
|
+
requires: { capabilities: [] },
|
|
289
|
+
provides: { capabilities: [] },
|
|
290
|
+
variables: { owns: [], imports: [] },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const result = buildCapabilityData(capability, manifest);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual({
|
|
296
|
+
port: 443,
|
|
297
|
+
enabled: true,
|
|
298
|
+
timeout: 0,
|
|
299
|
+
disabled: false,
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('should handle null and undefined values', () => {
|
|
304
|
+
const capability = {
|
|
305
|
+
name: 'test_capability',
|
|
306
|
+
version: '1.0.0',
|
|
307
|
+
data: {
|
|
308
|
+
nullable: null,
|
|
309
|
+
optional: undefined,
|
|
310
|
+
present: 'value',
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const manifest: ModuleManifest = {
|
|
315
|
+
celilo_contract: '1.0',
|
|
316
|
+
id: 'test-module',
|
|
317
|
+
name: 'Test Module',
|
|
318
|
+
version: '1.0.0',
|
|
319
|
+
description: 'Test',
|
|
320
|
+
requires: { capabilities: [] },
|
|
321
|
+
provides: { capabilities: [] },
|
|
322
|
+
variables: { owns: [], imports: [] },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const result = buildCapabilityData(capability, manifest);
|
|
326
|
+
|
|
327
|
+
expect(result).toEqual({
|
|
328
|
+
nullable: null,
|
|
329
|
+
optional: undefined,
|
|
330
|
+
present: 'value',
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('should return cloned data (not mutate original)', () => {
|
|
335
|
+
const capability = {
|
|
336
|
+
name: 'test_capability',
|
|
337
|
+
version: '1.0.0',
|
|
338
|
+
data: {
|
|
339
|
+
server: {
|
|
340
|
+
ip: '$self:container_ip',
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const manifest: ModuleManifest = {
|
|
346
|
+
celilo_contract: '1.0',
|
|
347
|
+
id: 'test-module',
|
|
348
|
+
name: 'Test Module',
|
|
349
|
+
version: '1.0.0',
|
|
350
|
+
description: 'Test',
|
|
351
|
+
requires: { capabilities: [] },
|
|
352
|
+
provides: { capabilities: [] },
|
|
353
|
+
variables: { owns: [], imports: [] },
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const result = buildCapabilityData(capability, manifest);
|
|
357
|
+
|
|
358
|
+
// Modify result to test deep copy
|
|
359
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentionally mutating result to verify deep copy behavior
|
|
360
|
+
(result as any).server.ip = 'modified';
|
|
361
|
+
|
|
362
|
+
// Original should be unchanged
|
|
363
|
+
expect(capability.data.server.ip).toBe('$self:container_ip');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('promptForSecretValue', () => {
|
|
368
|
+
test('should generate base64-encoded secret when auto-generate is true', async () => {
|
|
369
|
+
const result = await promptForSecretValue('tsig', 'TSIG secret for DNS', true);
|
|
370
|
+
|
|
371
|
+
// Should be base64-encoded 32-byte value
|
|
372
|
+
expect(result).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
373
|
+
expect(result.length).toBeGreaterThan(40); // base64 of 32 bytes is ~44 chars
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('should generate different secrets on each call', async () => {
|
|
377
|
+
const secret1 = await promptForSecretValue('secret1', 'Test secret', true);
|
|
378
|
+
const secret2 = await promptForSecretValue('secret2', 'Test secret', true);
|
|
379
|
+
|
|
380
|
+
expect(secret1).not.toBe(secret2);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('should generate secrets of consistent length', async () => {
|
|
384
|
+
const results = await Promise.all([
|
|
385
|
+
promptForSecretValue('s1', 'Test', true),
|
|
386
|
+
promptForSecretValue('s2', 'Test', true),
|
|
387
|
+
promptForSecretValue('s3', 'Test', true),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
const lengths = results.map((r) => r.length);
|
|
391
|
+
expect(lengths[0]).toBe(lengths[1]);
|
|
392
|
+
expect(lengths[1]).toBe(lengths[2]);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability Registration
|
|
3
|
+
* Handles registration of capabilities during module import
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Database } from 'bun:sqlite';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
9
|
+
import { encryptSecret } from '../secrets/encryption';
|
|
10
|
+
import type { EncryptedSecret } from '../secrets/encryption';
|
|
11
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
12
|
+
|
|
13
|
+
export interface RegistrationResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
details?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CapabilitySecretDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
type: 'string' | 'number' | 'boolean';
|
|
22
|
+
description?: string;
|
|
23
|
+
readable_by?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register all capabilities provided by a module
|
|
28
|
+
*
|
|
29
|
+
* Execution function (Rule 10.1) - performs database operations
|
|
30
|
+
*
|
|
31
|
+
* @param moduleId - Module identifier
|
|
32
|
+
* @param manifest - Module manifest
|
|
33
|
+
* @param db - Database connection
|
|
34
|
+
* @param flags - CLI flags (e.g., auto-generate-secrets)
|
|
35
|
+
* @returns Registration result
|
|
36
|
+
*/
|
|
37
|
+
export async function registerModuleCapabilities(
|
|
38
|
+
moduleId: string,
|
|
39
|
+
manifest: ModuleManifest,
|
|
40
|
+
db: Database,
|
|
41
|
+
_flags: Record<string, unknown> = {},
|
|
42
|
+
): Promise<RegistrationResult> {
|
|
43
|
+
if (!manifest.provides?.capabilities || manifest.provides.capabilities.length === 0) {
|
|
44
|
+
return { success: true }; // No capabilities to register
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const _masterKey = await getOrCreateMasterKey();
|
|
49
|
+
|
|
50
|
+
for (const capability of manifest.provides.capabilities) {
|
|
51
|
+
// Build capability data by resolving $self: variables
|
|
52
|
+
const capabilityData = buildCapabilityData(capability, manifest);
|
|
53
|
+
|
|
54
|
+
// Insert capability into database (with optional zones)
|
|
55
|
+
const zones = capability.zones ? JSON.stringify(capability.zones) : null;
|
|
56
|
+
const capabilityRecord = db
|
|
57
|
+
.prepare(
|
|
58
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, unixepoch())
|
|
60
|
+
RETURNING id`,
|
|
61
|
+
)
|
|
62
|
+
.get(
|
|
63
|
+
moduleId,
|
|
64
|
+
capability.name,
|
|
65
|
+
capability.version,
|
|
66
|
+
JSON.stringify(capabilityData),
|
|
67
|
+
zones,
|
|
68
|
+
) as {
|
|
69
|
+
id: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Register capability secret metadata (values will be prompted during generation)
|
|
73
|
+
if (capability.secrets && capability.secrets.length > 0) {
|
|
74
|
+
for (const secret of capability.secrets) {
|
|
75
|
+
// Store secret metadata only (no value yet)
|
|
76
|
+
db.prepare(
|
|
77
|
+
`INSERT INTO capability_secrets (capability_id, name, description, created_at, updated_at)
|
|
78
|
+
VALUES (?, ?, ?, unixepoch(), unixepoch())`,
|
|
79
|
+
).run(capabilityRecord.id, secret.name, secret.description || null);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { success: true };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: 'Failed to register capabilities',
|
|
89
|
+
details: error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build capability data by resolving $self: variables
|
|
96
|
+
*
|
|
97
|
+
* Policy function (Rule 10.1) - pure data transformation
|
|
98
|
+
*
|
|
99
|
+
* Note: Only resolves $self: variables. Other variable types ($system:, $capability:)
|
|
100
|
+
* are resolved during generation, not registration.
|
|
101
|
+
*
|
|
102
|
+
* @param capability - Capability definition from manifest
|
|
103
|
+
* @param manifest - Module manifest (for $self: variable resolution)
|
|
104
|
+
* @returns Capability data with resolved variables
|
|
105
|
+
*/
|
|
106
|
+
export function buildCapabilityData(
|
|
107
|
+
capability: { data?: Record<string, unknown> },
|
|
108
|
+
_manifest: ModuleManifest,
|
|
109
|
+
): Record<string, unknown> {
|
|
110
|
+
if (!capability.data) {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Capability data is static (no $self: variables in well-known capabilities)
|
|
115
|
+
// This function is here for future extensibility
|
|
116
|
+
return structuredClone(capability.data);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prompt user for secret value or auto-generate
|
|
121
|
+
*
|
|
122
|
+
* Execution function (Rule 10.1) - performs I/O (console input)
|
|
123
|
+
*
|
|
124
|
+
* @param secretName - Name of the secret
|
|
125
|
+
* @param description - Description of the secret
|
|
126
|
+
* @param autoGenerate - Whether to auto-generate without prompting
|
|
127
|
+
* @returns Secret value
|
|
128
|
+
*/
|
|
129
|
+
export async function promptForSecretValue(
|
|
130
|
+
secretName: string,
|
|
131
|
+
description: string,
|
|
132
|
+
autoGenerate: boolean,
|
|
133
|
+
): Promise<string> {
|
|
134
|
+
if (autoGenerate) {
|
|
135
|
+
// Auto-generate secret (TSIG keys are base64-encoded 32-byte values)
|
|
136
|
+
const randomValue = randomBytes(32);
|
|
137
|
+
return randomValue.toString('base64');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check if stdin is available (TTY check)
|
|
141
|
+
if (!process.stdin.isTTY) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Cannot prompt for secret '${secretName}': stdin is not available.\nUse --auto-generate-secrets flag to auto-generate capability secrets.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Prompt user for secret value
|
|
148
|
+
console.log('\nCapability secret required:');
|
|
149
|
+
console.log(` • ${secretName}: ${description}`);
|
|
150
|
+
console.log(`\nEnter value for ${secretName} (or press Enter to auto-generate):`);
|
|
151
|
+
|
|
152
|
+
// Read from stdin
|
|
153
|
+
const readline = await import('node:readline');
|
|
154
|
+
const rl = readline.createInterface({
|
|
155
|
+
input: process.stdin,
|
|
156
|
+
output: process.stdout,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
rl.question('> ', (answer) => {
|
|
161
|
+
rl.close();
|
|
162
|
+
|
|
163
|
+
if (answer.trim() === '') {
|
|
164
|
+
// User pressed Enter - auto-generate
|
|
165
|
+
const randomValue = randomBytes(32);
|
|
166
|
+
const generated = randomValue.toString('base64');
|
|
167
|
+
console.log(' ✓ Auto-generated secret (hmac-sha256)');
|
|
168
|
+
resolve(generated);
|
|
169
|
+
} else {
|
|
170
|
+
resolve(answer.trim());
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Encrypt and store capability secret
|
|
178
|
+
*
|
|
179
|
+
* Execution function (Rule 10.1) - performs database operations
|
|
180
|
+
*
|
|
181
|
+
* @param capabilityId - Capability ID from database
|
|
182
|
+
* @param name - Secret name
|
|
183
|
+
* @param value - Plaintext secret value
|
|
184
|
+
* @param masterKey - Master encryption key
|
|
185
|
+
* @param db - Database connection
|
|
186
|
+
*/
|
|
187
|
+
export async function storeCapabilitySecret(
|
|
188
|
+
capabilityId: number,
|
|
189
|
+
name: string,
|
|
190
|
+
value: string,
|
|
191
|
+
masterKey: Buffer,
|
|
192
|
+
db: Database,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
const encrypted: EncryptedSecret = encryptSecret(value, masterKey);
|
|
195
|
+
|
|
196
|
+
db.prepare(
|
|
197
|
+
`INSERT INTO capability_secrets (capability_id, name, encrypted_value, iv, auth_tag, created_at, updated_at)
|
|
198
|
+
VALUES (?, ?, ?, ?, ?, unixepoch(), unixepoch())`,
|
|
199
|
+
).run(capabilityId, name, encrypted.encryptedValue, encrypted.iv, encrypted.authTag);
|
|
200
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validatePath, validateRouteRequest, validateSlug } from './route-validation';
|
|
3
|
+
|
|
4
|
+
describe('validateSlug', () => {
|
|
5
|
+
test('accepts valid kebab-case slugs', () => {
|
|
6
|
+
expect(validateSlug('lunacycle').valid).toBe(true);
|
|
7
|
+
expect(validateSlug('my-app').valid).toBe(true);
|
|
8
|
+
expect(validateSlug('app1').valid).toBe(true);
|
|
9
|
+
expect(validateSlug('dns-external').valid).toBe(true);
|
|
10
|
+
expect(validateSlug('a').valid).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('rejects invalid slugs', () => {
|
|
14
|
+
expect(validateSlug('MyApp').valid).toBe(false);
|
|
15
|
+
expect(validateSlug('my_app').valid).toBe(false);
|
|
16
|
+
expect(validateSlug('my--app').valid).toBe(false);
|
|
17
|
+
expect(validateSlug('-app').valid).toBe(false);
|
|
18
|
+
expect(validateSlug('app-').valid).toBe(false);
|
|
19
|
+
expect(validateSlug('').valid).toBe(false);
|
|
20
|
+
expect(validateSlug('App').valid).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('validatePath', () => {
|
|
25
|
+
test('accepts valid paths', () => {
|
|
26
|
+
expect(validatePath('/lunacycle').valid).toBe(true);
|
|
27
|
+
expect(validatePath('/lunacycle/api').valid).toBe(true);
|
|
28
|
+
expect(validatePath('/a').valid).toBe(true);
|
|
29
|
+
expect(validatePath('/foo/bar/baz').valid).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects paths without leading slash', () => {
|
|
33
|
+
const result = validatePath('lunacycle');
|
|
34
|
+
expect(result.valid).toBe(false);
|
|
35
|
+
expect(result.errors[0]).toContain('start with /');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects paths with trailing slash', () => {
|
|
39
|
+
const result = validatePath('/lunacycle/');
|
|
40
|
+
expect(result.valid).toBe(false);
|
|
41
|
+
expect(result.errors[0]).toContain('trailing slash');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('rejects paths with double slashes', () => {
|
|
45
|
+
const result = validatePath('/luna//cycle');
|
|
46
|
+
expect(result.valid).toBe(false);
|
|
47
|
+
expect(result.errors[0]).toContain('double slashes');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('rejects empty path', () => {
|
|
51
|
+
expect(validatePath('').valid).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('validateRouteRequest', () => {
|
|
56
|
+
test('accepts valid static route', () => {
|
|
57
|
+
const result = validateRouteRequest({
|
|
58
|
+
slug: 'lunacycle',
|
|
59
|
+
type: 'static',
|
|
60
|
+
path: '/lunacycle',
|
|
61
|
+
});
|
|
62
|
+
expect(result.valid).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('accepts valid reverse_proxy route', () => {
|
|
66
|
+
const result = validateRouteRequest({
|
|
67
|
+
slug: 'lunacycle',
|
|
68
|
+
type: 'reverse_proxy',
|
|
69
|
+
path: '/lunacycle/api',
|
|
70
|
+
targetHost: '10.0.20.5',
|
|
71
|
+
targetPort: 3000,
|
|
72
|
+
});
|
|
73
|
+
expect(result.valid).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('rejects reverse_proxy without targetHost', () => {
|
|
77
|
+
const result = validateRouteRequest({
|
|
78
|
+
slug: 'lunacycle',
|
|
79
|
+
type: 'reverse_proxy',
|
|
80
|
+
path: '/lunacycle/api',
|
|
81
|
+
targetPort: 3000,
|
|
82
|
+
});
|
|
83
|
+
expect(result.valid).toBe(false);
|
|
84
|
+
expect(result.errors).toContain('reverse_proxy route requires targetHost');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('rejects reverse_proxy without targetPort', () => {
|
|
88
|
+
const result = validateRouteRequest({
|
|
89
|
+
slug: 'lunacycle',
|
|
90
|
+
type: 'reverse_proxy',
|
|
91
|
+
path: '/lunacycle/api',
|
|
92
|
+
targetHost: '10.0.20.5',
|
|
93
|
+
});
|
|
94
|
+
expect(result.valid).toBe(false);
|
|
95
|
+
expect(result.errors).toContain('reverse_proxy route requires targetPort');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejects invalid port numbers', () => {
|
|
99
|
+
const result = validateRouteRequest({
|
|
100
|
+
slug: 'lunacycle',
|
|
101
|
+
type: 'reverse_proxy',
|
|
102
|
+
path: '/lunacycle/api',
|
|
103
|
+
targetHost: '10.0.20.5',
|
|
104
|
+
targetPort: 70000,
|
|
105
|
+
});
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
expect(result.errors[0]).toContain('between 1 and 65535');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('collects multiple errors', () => {
|
|
111
|
+
const result = validateRouteRequest({
|
|
112
|
+
slug: 'BAD SLUG',
|
|
113
|
+
type: 'reverse_proxy',
|
|
114
|
+
path: 'no-slash',
|
|
115
|
+
targetHost: undefined,
|
|
116
|
+
targetPort: undefined,
|
|
117
|
+
});
|
|
118
|
+
expect(result.valid).toBe(false);
|
|
119
|
+
expect(result.errors.length).toBeGreaterThan(2);
|
|
120
|
+
});
|
|
121
|
+
});
|