capsium 0.1.2 → 0.3.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -1,76 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/storage.rb
4
- require "json"
5
- require "shale"
6
- require_relative "dataset"
7
- require_relative "storage_config"
3
+ require "forwardable"
8
4
 
9
5
  module Capsium
10
6
  class Package
11
7
  class Storage
12
8
  extend Forwardable
13
- attr_reader :config
14
9
 
15
- def_delegators :@config, :to_json
10
+ attr_reader :path, :config, :datasets
16
11
 
17
- attr_reader :datasets
12
+ def_delegators :@config, :to_json, :to_hash
18
13
 
19
14
  def initialize(path)
20
15
  @path = path
21
16
  @dir = File.dirname(path)
22
- @datasets_path = File.join(@dir, DATA_DIR)
23
17
  @config = if File.exist?(path)
24
18
  StorageConfig.from_json(File.read(path))
25
19
  else
26
- StorageConfig.new(datasets: generate_datasets)
20
+ StorageConfig.new
27
21
  end
28
- @datasets = load_datasets || generate_datasets
22
+ @datasets = load_datasets
23
+ end
24
+
25
+ def data_sets
26
+ @config.data_sets
29
27
  end
30
28
 
31
29
  def load_datasets
32
- if File.exist?(@path)
33
- storage_data = StorageConfig.from_json(File.read(@path))
34
- storage_data.datasets.map do |dataset_config|
35
- dataset_config.to_dataset(@datasets_path)
36
- end
30
+ data_sets.map do |name, dataset_config|
31
+ dataset_config.to_dataset(name, @dir)
37
32
  end
38
33
  end
39
34
 
40
- def save_to_file(output_path = @path)
41
- storage_config = StorageConfig.new(datasets: @datasets.map do |dataset|
42
- DatasetConfig.from_dataset(dataset)
43
- end)
44
- File.write(output_path, storage_config.to_json)
35
+ def dataset(name)
36
+ @datasets.find { |dataset| dataset.name == name }
45
37
  end
46
38
 
47
- def generate_datasets
48
- paths = File.join(@datasets_path, "*.{yaml,yml,json,csv,tsv,sqlite,db}")
49
- Dir.glob(paths).map do |file_path|
50
- Dataset.new(config: DatasetConfig.new(
51
- name: File.basename(file_path,
52
- ".*"), source: file_path, format: detect_format(file_path)
53
- ))
54
- end
39
+ def dataset_names
40
+ data_sets.keys.sort
55
41
  end
56
42
 
57
- private
43
+ def empty?
44
+ data_sets.empty?
45
+ end
58
46
 
59
- def detect_format(file_path)
60
- case File.extname(file_path).downcase
61
- when ".yaml", ".yml"
62
- "yaml"
63
- when ".json"
64
- "json"
65
- when ".csv"
66
- "csv"
67
- when ".tsv"
68
- "tsv"
69
- when ".sqlite", ".db"
70
- "sqlite"
71
- else
72
- raise "Unsupported data file type: #{File.extname(file_path)}"
73
- end
47
+ def save_to_file(output_path = @path)
48
+ File.write(output_path, to_json)
74
49
  end
75
50
  end
76
51
  end
@@ -1,9 +1,134 @@
1
- require "shale"
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
2
5
 
3
6
  module Capsium
4
7
  class Package
5
- class StorageConfig < Shale::Mapper
6
- attribute :datasets, DatasetConfig, collection: true
8
+ # A single dataset entry (ARCHITECTURE.md section 5). Paths are
9
+ # package-relative POSIX paths. Kinds: schema-backed file (via
10
+ # "source") or SQLite ("databaseFile" + "table").
11
+ class DatasetConfig < Lutaml::Model::Serializable
12
+ FORMATS = {
13
+ ".yaml" => "yaml", ".yml" => "yaml", ".json" => "json",
14
+ ".csv" => "csv", ".tsv" => "tsv",
15
+ ".sqlite" => "sqlite", ".db" => "sqlite"
16
+ }.freeze
17
+ SCHEMA_TYPES = %w[json-schema].freeze
18
+
19
+ attribute :source, :string
20
+ attribute :schema_file, :string
21
+ attribute :schema_type, :string, values: SCHEMA_TYPES
22
+ attribute :database_file, :string
23
+ attribute :table, :string
24
+
25
+ json do
26
+ map :source, to: :source
27
+ map "schemaFile", to: :schema_file
28
+ map "schemaType", to: :schema_type
29
+ map "databaseFile", to: :database_file
30
+ map :table, to: :table
31
+ end
32
+
33
+ def format
34
+ FORMATS.fetch(File.extname(backing_file).downcase) do
35
+ raise Error, "Unsupported data file type: #{File.extname(backing_file)}"
36
+ end
37
+ end
38
+
39
+ def sqlite?
40
+ !database_file.nil?
41
+ end
42
+
43
+ def backing_file
44
+ database_file || source.to_s
45
+ end
46
+
47
+ def to_dataset(name, package_path)
48
+ Dataset.new(name: name, config: self, package_path: package_path)
49
+ end
50
+ end
51
+
52
+ # A single storage layer entry (ARCHITECTURE.md section 5a). "path" is
53
+ # a package-relative directory mirroring the content/ tree; layers are
54
+ # stacked bottom -> top in declaration order. "visibility: private"
55
+ # layers are hidden from dependent packages.
56
+ class LayerConfig < Lutaml::Model::Serializable
57
+ attribute :path, :string
58
+ attribute :writable, :boolean, default: false
59
+ attribute :visibility, :string, values: Resource::VISIBILITIES, default: "exported"
60
+
61
+ json do
62
+ map :path, to: :path
63
+ map :writable, to: :writable
64
+ map :visibility, to: :visibility
65
+ end
66
+
67
+ def private?
68
+ visibility == "private"
69
+ end
70
+ end
71
+
72
+ # The "storage" object holding the dataSets map and the layers stack.
73
+ class StorageData < Lutaml::Model::Serializable
74
+ attribute :data_sets, :hash, default: {}
75
+ attribute :layers, LayerConfig, collection: true, default: []
76
+
77
+ json do
78
+ map "dataSets", with: { from: :data_sets_from_json, to: :data_sets_to_json }
79
+ map :layers, to: :layers
80
+ end
81
+
82
+ def data_sets_from_json(model, value)
83
+ model.data_sets = (value || {}).to_h do |name, attributes|
84
+ [name, DatasetConfig.from_json(JSON.generate(attributes))]
85
+ end
86
+ end
87
+
88
+ def data_sets_to_json(model, doc)
89
+ doc["dataSets"] = model.data_sets.sort.to_h do |name, dataset|
90
+ [name, JSON.parse(dataset.to_json)]
91
+ end
92
+ end
93
+ end
94
+
95
+ # Canonical storage.json model. The legacy gem form
96
+ # ({"datasets": [{name, source, format, schema}]}) is accepted on read
97
+ # and normalized; writers emit only the canonical form.
98
+ class StorageConfig < Lutaml::Model::Serializable
99
+ attribute :storage, StorageData
100
+
101
+ json do
102
+ map :storage, to: :storage
103
+ end
104
+
105
+ def self.from_json(json)
106
+ doc = JSON.parse(json)
107
+ doc["storage"] ||= { "dataSets" => legacy_data_sets(doc.delete("datasets")) }
108
+ super(JSON.generate(doc))
109
+ end
110
+
111
+ def self.legacy_data_sets(datasets)
112
+ (datasets || []).to_h do |item|
113
+ entry = { "source" => item["source"] }
114
+ if item["schema"]
115
+ entry["schemaFile"] = item["schema"]
116
+ entry["schemaType"] = "json-schema"
117
+ end
118
+ [item["name"], entry]
119
+ end
120
+ end
121
+ private_class_method :legacy_data_sets
122
+
123
+ def data_sets
124
+ storage ? storage.data_sets : {}
125
+ end
126
+
127
+ # The configured storage layers, bottom -> top (empty when the
128
+ # package uses the single implicit content/ layer).
129
+ def layers
130
+ storage ? storage.layers : []
131
+ end
7
132
  end
8
133
  end
9
134
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zip"
5
+
6
+ module Capsium
7
+ class Package
8
+ # A package store directory (ARCHITECTURE.md section 4a): a directory
9
+ # of "<name>-<version>.cap" files plus an optional index.json mapping
10
+ # dependency GUID -> file name. Dependencies resolve to the newest
11
+ # version satisfying their semver range; an index.json entry pins the
12
+ # GUID to a specific file (still range-checked).
13
+ class Store
14
+ INDEX_FILE = "index.json"
15
+ CAP_GLOB = "*.cap"
16
+
17
+ # One store .cap with its identity per its metadata.json.
18
+ CatalogEntry = Data.define(:path, :guid, :version)
19
+
20
+ attr_reader :dir
21
+
22
+ # The store configured via the CAPSIUM_STORE environment variable,
23
+ # or nil when unset.
24
+ def self.default
25
+ dir = ENV.fetch("CAPSIUM_STORE", nil)
26
+ dir.nil? || dir.empty? ? nil : new(dir)
27
+ end
28
+
29
+ def initialize(dir)
30
+ unless File.directory?(dir)
31
+ raise DependencyError, "Package store directory not found: #{dir}"
32
+ end
33
+
34
+ @dir = dir
35
+ end
36
+
37
+ # The newest .cap providing the dependency GUID whose version
38
+ # satisfies the range string.
39
+ def find(guid, range_string)
40
+ range = VersionRange.parse(range_string)
41
+ indexed = indexed_path(guid)
42
+ return indexed_satisfying(indexed, guid, range_string, range) if indexed
43
+
44
+ candidates = catalog.select { |entry| entry.guid == guid }
45
+ if candidates.empty?
46
+ raise DependencyNotFoundError,
47
+ "no package for dependency #{guid} in store #{@dir}"
48
+ end
49
+
50
+ satisfying = candidates.select { |entry| range.satisfied_by?(entry.version) }
51
+ return satisfying.max_by(&:version).path unless satisfying.empty?
52
+
53
+ versions = candidates.map { |entry| entry.version.to_s }.sort.join(", ")
54
+ raise UnsatisfiableDependencyError,
55
+ "no version of #{guid} satisfies '#{range_string}' " \
56
+ "(store has: #{versions})"
57
+ end
58
+
59
+ # Every .cap in the store with its metadata identity (memoized).
60
+ def catalog
61
+ @catalog ||= Dir.glob(File.join(@dir, CAP_GLOB)).map do |path|
62
+ catalog_entry(path)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def indexed_path(guid)
69
+ file = load_index[guid]
70
+ file && File.join(@dir, file)
71
+ end
72
+
73
+ def indexed_satisfying(path, guid, range_string, range)
74
+ unless File.file?(path)
75
+ raise DependencyNotFoundError,
76
+ "#{INDEX_FILE} maps #{guid} to a missing file: #{path}"
77
+ end
78
+
79
+ entry = catalog_entry(path)
80
+ return entry.path if range.satisfied_by?(entry.version)
81
+
82
+ raise UnsatisfiableDependencyError,
83
+ "indexed #{guid} #{entry.version} does not satisfy '#{range_string}'"
84
+ end
85
+
86
+ def catalog_entry(cap_path)
87
+ metadata = read_metadata(cap_path)
88
+ CatalogEntry.new(path: cap_path, guid: metadata["guid"],
89
+ version: Version.parse(metadata["version"].to_s))
90
+ end
91
+
92
+ def read_metadata(cap_path)
93
+ Zip::File.open(cap_path) do |zip|
94
+ entry = zip.find_entry(Package::METADATA_FILE)
95
+ raise DependencyError, "no #{Package::METADATA_FILE} in #{cap_path}" unless entry
96
+
97
+ JSON.parse(entry.get_input_stream.read)
98
+ end
99
+ end
100
+
101
+ def load_index
102
+ @load_index ||= begin
103
+ path = File.join(@dir, INDEX_FILE)
104
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # A "config" test (05x-testing): the configuration file must exist
10
+ # in the package and parse as the declared format (json or yaml).
11
+ # The known Capsium package configs (metadata.json, manifest.json,
12
+ # routes.json, storage.json, security.json) are additionally
13
+ # validated against their canonical models.
14
+ class ConfigTest < TestCase
15
+ FORMATS = %w[json yaml].freeze
16
+
17
+ attr_reader :format, :config_file
18
+
19
+ def initialize(name:, format:, config_file:)
20
+ super(name: name)
21
+ @format = format
22
+ @config_file = config_file
23
+ end
24
+
25
+ def run(context)
26
+ problems = config_problems(context)
27
+ Result.new(name: name, ok: problems.empty?, messages: problems)
28
+ end
29
+
30
+ private
31
+
32
+ def config_problems(context)
33
+ return ["unsupported config format: #{format}"] unless FORMATS.include?(format)
34
+
35
+ full_path = File.join(context.package_path, config_file.delete_prefix("/"))
36
+ return ["config file missing in package: #{config_file}"] unless File.file?(full_path)
37
+
38
+ parse(full_path)
39
+ model_problems(full_path)
40
+ rescue JSON::ParserError, Psych::SyntaxError => e
41
+ ["cannot parse #{config_file}: #{e.message}"]
42
+ end
43
+
44
+ def parse(full_path)
45
+ format == "yaml" ? YAML.load_file(full_path) : JSON.parse(File.read(full_path))
46
+ end
47
+
48
+ def model_problems(full_path)
49
+ case File.basename(config_file)
50
+ when Package::METADATA_FILE then Metadata.new(full_path).config.format_errors
51
+ when Package::MANIFEST_FILE then probe { ManifestConfig.from_json(File.read(full_path)) }
52
+ when Package::ROUTES_FILE then probe { RoutesConfig.from_json(File.read(full_path)) }
53
+ when Package::STORAGE_FILE then probe { StorageConfig.from_json(File.read(full_path)) }
54
+ when Package::SECURITY_FILE then probe { SecurityConfig.from_json(File.read(full_path)) }
55
+ else []
56
+ end
57
+ end
58
+
59
+ def probe
60
+ yield
61
+ []
62
+ rescue StandardError => e
63
+ [e.message]
64
+ end
65
+ end
66
+
67
+ TestCase.register("config", ConfigTest)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # What a test run needs: the extracted package directory and, when
7
+ # route tests run, the base URL of the reactor serving the package.
8
+ Context = Data.define(:package_path, :base_url)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json-schema"
5
+ require "yaml"
6
+
7
+ module Capsium
8
+ class Package
9
+ module Testing
10
+ # A "data_validation" test (05x-testing): the rows of the data
11
+ # file (format: json or yaml) must validate against the JSON
12
+ # schema file. Array data validates row by row; a single document
13
+ # validates as one row.
14
+ class DataValidationTest < TestCase
15
+ FORMATS = %w[json yaml].freeze
16
+
17
+ attr_reader :format, :data_file, :schema_file
18
+
19
+ def initialize(name:, format:, data_file:, schema_file:)
20
+ super(name: name)
21
+ @format = format
22
+ @data_file = data_file
23
+ @schema_file = schema_file
24
+ end
25
+
26
+ def run(context)
27
+ problems = validation_problems(context)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ def validation_problems(context)
34
+ return ["unsupported data format: #{format}"] unless FORMATS.include?(format)
35
+
36
+ schema = load_structured(context, schema_file)
37
+ rows = load_structured(context, data_file)
38
+ rows = [rows] unless rows.is_a?(Array)
39
+ rows.each_with_index.flat_map { |row, index| row_problems(schema, row, index) }
40
+ rescue Errno::ENOENT, JSON::ParserError, Psych::SyntaxError => e
41
+ ["cannot load data or schema: #{e.message}"]
42
+ end
43
+
44
+ def load_structured(context, relative_path)
45
+ full_path = File.join(context.package_path, relative_path.delete_prefix("/"))
46
+ if File.extname(full_path).match?(/\A\.ya?ml\z/i)
47
+ YAML.load_file(full_path)
48
+ else
49
+ JSON.parse(File.read(full_path))
50
+ end
51
+ end
52
+
53
+ def row_problems(schema, row, index)
54
+ JSON::Validator.fully_validate(schema, row).map do |message|
55
+ "row #{index}: #{message}"
56
+ end
57
+ end
58
+ end
59
+
60
+ TestCase.register("data_validation", DataValidationTest)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # A "file" test (05x-testing): the file must exist in the package;
7
+ # optionally its content must contain a string.
8
+ class FileTest < TestCase
9
+ attr_reader :path, :contains
10
+
11
+ def initialize(name:, path:, contains: nil)
12
+ super(name: name)
13
+ @path = path
14
+ @contains = contains
15
+ end
16
+
17
+ def run(context)
18
+ problems = existence_problems(context) + contains_problems(context)
19
+ Result.new(name: name, ok: problems.empty?, messages: problems)
20
+ end
21
+
22
+ private
23
+
24
+ def full_path(context)
25
+ File.join(context.package_path, path.delete_prefix("/"))
26
+ end
27
+
28
+ def existence_problems(context)
29
+ return [] if File.file?(full_path(context))
30
+
31
+ ["file missing in package: #{path}"]
32
+ end
33
+
34
+ def contains_problems(context)
35
+ return [] unless contains && File.file?(full_path(context))
36
+ return [] if File.read(full_path(context)).include?(contains)
37
+
38
+ ["file #{path} does not contain #{contains.inspect}"]
39
+ end
40
+ end
41
+
42
+ TestCase.register("file", FileTest)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # The outcome of a test suite run: one Result per test case.
7
+ class Report
8
+ attr_reader :results
9
+
10
+ def initialize(results = [])
11
+ @results = results
12
+ end
13
+
14
+ def <<(result)
15
+ @results << result
16
+ self
17
+ end
18
+
19
+ def ok?
20
+ @results.all?(&:ok?)
21
+ end
22
+
23
+ def failures
24
+ @results.reject(&:ok?)
25
+ end
26
+
27
+ def summary
28
+ "#{@results.size} tests, #{failures.size} failures"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # A "route" test (05x-testing): requests the URL path from the
10
+ # reactor serving the package and checks the expected status, and
11
+ # optionally the content type and a response body substring.
12
+ class RouteTest < TestCase
13
+ attr_reader :url, :expected_status, :response_contains, :expected_content_type
14
+
15
+ def initialize(name:, url:, expected_status:, response_contains: nil,
16
+ expected_content_type: nil)
17
+ super(name: name)
18
+ @url = url
19
+ @expected_status = expected_status
20
+ @response_contains = response_contains
21
+ @expected_content_type = expected_content_type
22
+ end
23
+
24
+ def run(context)
25
+ response = Net::HTTP.get_response(URI.join(context.base_url, request_path))
26
+ problems = status_problems(response) + content_type_problems(response) +
27
+ body_problems(response)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ # The DSL allows absolute URLs; only their path (and query) is
34
+ # used — the runner always targets its own reactor.
35
+ def request_path
36
+ uri = URI.parse(url)
37
+ uri.host ? uri.request_uri : url
38
+ end
39
+
40
+ def status_problems(response)
41
+ return [] if response.code.to_i == expected_status
42
+
43
+ ["expected status #{expected_status}, got #{response.code}"]
44
+ end
45
+
46
+ def content_type_problems(response)
47
+ return [] unless expected_content_type
48
+ return [] if response.content_type == expected_content_type
49
+
50
+ ["expected content type #{expected_content_type}, got #{response.content_type}"]
51
+ end
52
+
53
+ def body_problems(response)
54
+ return [] unless response_contains
55
+ return [] if response.body.to_s.include?(response_contains)
56
+
57
+ ["response does not contain #{response_contains.inspect}"]
58
+ end
59
+ end
60
+
61
+ TestCase.register("route", RouteTest)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # Base class for one test of the Capsium testing YAML DSL
7
+ # (05x-testing). Subclasses implement a test kind and register
8
+ # themselves under their DSL type name (open/closed registry).
9
+ class TestCase
10
+ # The outcome of running one test.
11
+ Result = Data.define(:name, :ok, :messages) do
12
+ def ok? = ok
13
+ end
14
+
15
+ # Raised for invalid test definitions (unknown type,
16
+ # missing/unknown attributes, non-hash entries).
17
+ class DefinitionError < Capsium::Error; end
18
+
19
+ attr_reader :name
20
+
21
+ def initialize(name:)
22
+ @name = name
23
+ end
24
+
25
+ # Runs the test against the context and returns a Result.
26
+ def run(context)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def self.register(type, klass)
31
+ types[type] = klass
32
+ end
33
+
34
+ def self.types
35
+ @types ||= {}
36
+ end
37
+
38
+ # Builds a test case from a YAML definition hash (string keys).
39
+ def self.build(definition)
40
+ unless definition.is_a?(Hash)
41
+ raise DefinitionError, "test definition is not a mapping: #{definition.inspect}"
42
+ end
43
+
44
+ type = definition["type"] ||
45
+ raise(DefinitionError, "test #{definition['name'].inspect} has no type")
46
+ klass = types[type] || raise(DefinitionError, "unknown test type: #{type}")
47
+ klass.from_h(definition)
48
+ rescue ArgumentError => e
49
+ raise DefinitionError, "invalid #{type} test #{definition['name'].inspect}: #{e.message}"
50
+ end
51
+
52
+ def self.from_h(definition)
53
+ attributes = definition.transform_keys(&:to_sym)
54
+ attributes.delete(:type)
55
+ new(**attributes)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end