calmlens 0.20.0 → 0.21.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.
@@ -82,16 +82,17 @@ module CalmLens
82
82
  end
83
83
 
84
84
  # The report a decided asset gets, as the API sends it: every category (unflagged ones
85
- # evaluated with score 0), the policy's actions and `flaggedCategories`.
85
+ # evaluated with score 0), the policy's actions, the automated status and `flaggedCategories`.
86
86
  # @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
87
87
  def report
88
88
  return if undecided?
89
89
 
90
90
  evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
91
- { 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] } },
91
+ { 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
92
+ 'hashMatches' => [] },
92
93
  'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
93
- 'explanationComplete' => true, 'source' => 'policy', 'automatedAction' => STATUS_ACTIONS[@status],
94
- 'regions' => [], 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
94
+ 'explanationComplete' => true, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
95
+ 'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
95
96
  end
96
97
 
97
98
  def inspect = "#<#{self.class.name} #{@status}>"
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'asset_filters'
4
+
5
+ module CalmLens
6
+ module Testing
7
+ # Asset list filters as the API reads them (zodbase/rsql with @rsql/parser): comparisons joined
8
+ # by `;` or `and` (binding tighter), `,` or `or`, and grouped with parentheses. A filter names
9
+ # only {MockAssetFilters::FIELDS}, each with only its own operators; anything else, or
10
+ # bad syntax, answers 400 `filter/invalid`. A number field's value must be a number,
11
+ # and `null` is a missing value only on a nullable field. `*` in an `==` or `!=` text value
12
+ # matches any run of characters regardless of case; without it `==` is exact. As in SQL, a
13
+ # comparison never matches a missing value.
14
+ # @api private
15
+ module RSQL
16
+ module_function
17
+
18
+ # A filter the API refuses, answered as `filter/invalid`.
19
+ class Invalid < StandardError; end
20
+
21
+ def filter(items, expression)
22
+ return items if expression.nil? || expression.to_s.empty?
23
+
24
+ test = Parser.new(Lexer.new(expression.to_s).tokens).parse
25
+ items.select { |item| test.call(item) }
26
+ rescue Invalid
27
+ raise Failure.new(ErrorCodes::INVALID_FILTER, 'Invalid filter', status: 400)
28
+ end
29
+
30
+ # A test for one comparison, refusing a field or operator the API doesn't accept.
31
+ def comparison(field, operator, raw_values)
32
+ rule = MockAssetFilters::FIELDS[field]
33
+ raise Invalid unless rule && rule[:operators].include?(operator)
34
+
35
+ values = raw_values.map { |raw| typed(rule, raw) }
36
+ pattern = values.first.is_a?(String) && values.first.include?('*') ? wildcard(values.first) : nil
37
+ lambda do |item|
38
+ actual = field.split('.').reduce(item) { |object, key| object.is_a?(Hash) ? object[key] : nil }
39
+ # A list holds any of the values (=in=) or none of them (=out=); a missing list holds none.
40
+ next values.any? { |value| Array(actual).include?(value) } == (operator == '=in=') if rule[:kind] == 'list'
41
+
42
+ # Only `==null` matches a missing value.
43
+ next operator == '==' && values.first.nil? if actual.nil?
44
+
45
+ compare([actual], operator, values, pattern)
46
+ end
47
+ end
48
+
49
+ def compare(entries, operator, values, pattern)
50
+ first = values.first
51
+ case operator
52
+ when '==' then pattern ? entries.any? { |entry| pattern.match?(entry.to_s) } : !first.nil? && entries.include?(first)
53
+ when '!=' then pattern ? entries.none? { |entry| pattern.match?(entry.to_s) } : first.nil? || !entries.include?(first)
54
+ when '=in=' then values.compact.any? { |value| entries.include?(value) }
55
+ when '=out=' then values.none?(&:nil?) && values.none? { |value| entries.include?(value) }
56
+ else !first.nil? && entries.any? { |entry| ordered?(entry, operator, first) }
57
+ end
58
+ end
59
+
60
+ # A value as the API reads it for the field: `null` is missing on a nullable field, a number
61
+ # field's value must be a finite number (as JavaScript's `Number` reads it), and text stays text.
62
+ def typed(rule, raw)
63
+ return nil if raw == 'null' && rule[:nullable]
64
+ return raw unless rule[:kind] == 'number'
65
+
66
+ number(raw) || raise(Invalid)
67
+ end
68
+
69
+ RADIXES = { 'x' => 16, 'o' => 8, 'b' => 2 }.freeze
70
+
71
+ def number(raw)
72
+ text = raw.strip
73
+ return 0 if text.empty?
74
+ return Integer(text[2..], RADIXES.fetch(text[1].downcase)) if text.match?(/\A0(?:[xX]\h+|[oO][0-7]+|[bB][01]+)\z/)
75
+ return unless text.match?(/\A[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\z/)
76
+
77
+ value = Float(text)
78
+ value.finite? ? value : nil
79
+ end
80
+
81
+ # `*` matches any run of characters, ignoring case; every other character matches itself.
82
+ def wildcard(value)
83
+ Regexp.new("\\A#{value.split('*', -1).map { |part| Regexp.escape(part) }.join('.*')}\\z",
84
+ Regexp::IGNORECASE | Regexp::MULTILINE)
85
+ end
86
+
87
+ # Numbers compare as numbers, text by code point.
88
+ def ordered?(entry, operator, value)
89
+ return false unless entry.is_a?(Numeric) == value.is_a?(Numeric)
90
+
91
+ entry.public_send(operator, value)
92
+ end
93
+
94
+ # Tokens like @rsql/parser's lexer: quoted values (with backslash escapes), parentheses, `;`
95
+ # and `,` (or `and ` and `or `), comparison operators (`==`, `!=`, `<`, `<=`, `>`, `>=` and
96
+ # `=word=`) and unquoted runs up to a reserved character.
97
+ class Lexer
98
+ RESERVED = ['"', "'", '(', ')', ';', ',', '=', '!', '~', '<', '>', ' ', "\n", "\t", "\r"].freeze
99
+ WHITESPACE = [' ', "\n", "\t", "\r"].freeze
100
+
101
+ attr_reader :tokens
102
+
103
+ def initialize(input)
104
+ @input = input
105
+ @index = 0
106
+ @tokens = []
107
+ @tokens << next_token while token_ahead?
108
+ end
109
+
110
+ private
111
+
112
+ # Skips whitespace; true while input remains.
113
+ def token_ahead?
114
+ @index += 1 while @index < @input.length && WHITESPACE.include?(@input[@index])
115
+ @index < @input.length
116
+ end
117
+
118
+ def next_token
119
+ char = @input[@index]
120
+ case char
121
+ when '"', "'" then quoted(char)
122
+ when '(', ')' then single(:paren, char)
123
+ when ';', ',' then single(:logic, char)
124
+ when '=', '!', '~', '<', '>' then operator
125
+ else verbose || unquoted
126
+ end
127
+ end
128
+
129
+ def single(type, char)
130
+ @index += 1
131
+ [type, char]
132
+ end
133
+
134
+ def quoted(quote)
135
+ finish = @index
136
+ loop do
137
+ finish = @input.index(quote, finish + 1)
138
+ raise Invalid if finish.nil?
139
+
140
+ slashes = 0
141
+ slashes += 1 while @input[finish - 1 - slashes] == '\\' && finish - 1 - slashes > @index
142
+ break if slashes.even?
143
+ end
144
+ token = [:quoted, @input[(@index + 1)...finish].gsub(/\\([\s\S])/, '\1')]
145
+ @index = finish + 1
146
+ token
147
+ end
148
+
149
+ def operator
150
+ match = @input[@index..].match(/\A(?:==|!=|<=|>=|<|>|=[a-z]*=)/)
151
+ raise Invalid unless match
152
+
153
+ @index += match[0].length
154
+ [:operator, match[0]]
155
+ end
156
+
157
+ def verbose
158
+ match = @input[@index..].match(/\A(and|or) /)
159
+ return unless match
160
+
161
+ @index += match[1].length
162
+ [:logic, match[1] == 'and' ? ';' : ',']
163
+ end
164
+
165
+ def unquoted
166
+ finish = @index + 1
167
+ finish += 1 while finish < @input.length && !RESERVED.include?(@input[finish])
168
+ token = [:unquoted, @input[@index...finish]]
169
+ @index = finish
170
+ token
171
+ end
172
+ end
173
+
174
+ # Builds the test for a whole filter: `,` (or) of `;` (and) of comparisons and groups.
175
+ class Parser
176
+ def initialize(tokens)
177
+ @tokens = tokens
178
+ @position = 0
179
+ end
180
+
181
+ def parse
182
+ test = disjunction
183
+ raise Invalid unless @position == @tokens.size
184
+
185
+ test
186
+ end
187
+
188
+ private
189
+
190
+ def peek = @tokens[@position]
191
+
192
+ def take
193
+ token = @tokens[@position] || raise(Invalid)
194
+ @position += 1
195
+ token
196
+ end
197
+
198
+ def take_if(token) = peek == token && take
199
+
200
+ def disjunction
201
+ alternatives = [conjunction]
202
+ alternatives << conjunction while take_if([:logic, ','])
203
+ ->(item) { alternatives.any? { |test| test.call(item) } }
204
+ end
205
+
206
+ def conjunction
207
+ terms = [term]
208
+ terms << term while take_if([:logic, ';'])
209
+ ->(item) { terms.all? { |test| test.call(item) } }
210
+ end
211
+
212
+ def term
213
+ if take_if([:paren, '('])
214
+ test = disjunction
215
+ raise Invalid unless take == [:paren, ')']
216
+
217
+ return test
218
+ end
219
+ selector = take
220
+ operator = take
221
+ raise Invalid unless selector[0] == :unquoted && operator[0] == :operator
222
+
223
+ RSQL.comparison(selector[1], operator[1], arguments)
224
+ end
225
+
226
+ def arguments
227
+ return [value] unless take_if([:paren, '('])
228
+
229
+ values = [value]
230
+ values << value while take_if([:logic, ','])
231
+ raise Invalid unless take == [:paren, ')']
232
+
233
+ values
234
+ end
235
+
236
+ def value
237
+ token = take
238
+ raise Invalid unless %i[unquoted quoted].include?(token[0])
239
+
240
+ token[1]
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'classification'
4
+
5
+ module CalmLens
6
+ module TestInputs
7
+ # Checks a test scenario's `detections` (test_input_scenario.rb).
8
+ # @api private
9
+ module Detections
10
+ module_function
11
+
12
+ # The people and faces a test image reports, each a list of boxes or `unavailable`.
13
+ def validate(value)
14
+ value = Scenario.fields(value, 'detections', %w[people faces], [])
15
+ checked = {}
16
+ checked['people'] = list(value['people'], 'people', %w[box confidence]) if value.key?('people')
17
+ checked['faces'] = list(value['faces'], 'faces', %w[box confidence age]) if value.key?('faces')
18
+ checked
19
+ end
20
+
21
+ def list(value, name, allowed)
22
+ return value if value == 'unavailable'
23
+
24
+ Scenario.check(value.is_a?(Array), "Detection #{name} must be an Array or 'unavailable'")
25
+ Scenario.array(value, name, MockClassification::TEST_DETECTIONS_MAX).map do |entry|
26
+ entry = Scenario.fields(entry, 'detection', allowed, %w[box])
27
+ checked = { 'box' => check_box(entry['box']) }
28
+ if entry.key?('confidence')
29
+ Scenario.check(integer_between?(entry['confidence'], 0, 100), 'Detection confidence must be an Integer from 0 to 100')
30
+ checked['confidence'] = entry['confidence']
31
+ end
32
+ checked['age'] = check_age(entry['age']) if entry.key?('age')
33
+ checked
34
+ end
35
+ end
36
+
37
+ # `[left, top, width, height]` in basis points of the image, inside it.
38
+ def check_box(box)
39
+ max = MockClassification::TEST_DETECTION_BOX_MAX
40
+ Scenario.check(box.is_a?(Array) && box.size == 4 && box.all? { |item| integer_between?(item, 0, max) } &&
41
+ box[0] + box[2] <= max && box[1] + box[3] <= max,
42
+ "A detection box is [left, top, width, height]: Integers from 0 to #{max} inside the image")
43
+ box.dup
44
+ end
45
+
46
+ def check_age(age)
47
+ return age if age.is_a?(String) && MockClassification::TEST_FACE_AGE_PRESETS.include?(age)
48
+
49
+ max = MockClassification::TEST_FACE_AGE_MAX
50
+ age = Scenario.fields(age, 'age', %w[low high], %w[low high]) if age.is_a?(Hash)
51
+ Scenario.check(age.is_a?(Hash) && integer_between?(age['low'], 0, max) && integer_between?(age['high'], age['low'], max),
52
+ "A face age is #{MockClassification::TEST_FACE_AGE_PRESETS.join(' or ')}, or Integers low to high from 0 to #{max}")
53
+ { 'low' => age['low'], 'high' => age['high'] }
54
+ end
55
+
56
+ def integer_between?(value, min, max) = value.is_a?(Integer) && value.between?(min, max)
57
+ end
58
+
59
+ private_constant :Detections
60
+ end
61
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'classification'
4
+ require_relative 'test_input_detections'
4
5
 
5
6
  module CalmLens
6
7
  # The scenario checks behind the signed test inputs (test_inputs.rb).
@@ -23,6 +24,7 @@ module CalmLens
23
24
  when Hash then "{#{value.map { |key, item| "\"#{key}\":#{canonical_json(item)}" }.join(',')}}"
24
25
  when Array then "[#{value.map { |item| canonical_json(item) }.join(',')}]"
25
26
  when Float then js_number(value)
27
+ when Integer then value.to_s
26
28
  else "\"#{value}\""
27
29
  end
28
30
  end
@@ -49,7 +51,7 @@ module CalmLens
49
51
 
50
52
  # Checks a scenario and returns it with camelCase String keys in canonical order.
51
53
  def validate(input)
52
- input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances], %w[instances])
54
+ input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances detections], %w[instances])
53
55
  instances = array(input['instances'], 'instances', 128).map { |instance| check_instance(instance) }
54
56
  with_action = instances.count { |instance| instance.key?('action') }
55
57
  check([0, instances.size].include?(with_action), 'Set an action for every instance or omit all actions')
@@ -72,7 +74,9 @@ module CalmLens
72
74
  end
73
75
  checked['unassessedCategories'] = unassessed
74
76
  end
75
- checked.merge('instances' => instances)
77
+ checked['instances'] = instances
78
+ checked['detections'] = Detections.validate(input['detections']) if input.key?('detections')
79
+ checked
76
80
  end
77
81
 
78
82
  def check_instance(instance)
@@ -8,8 +8,9 @@ require_relative 'test_image_grid'
8
8
  module CalmLens
9
9
  # Signed test inputs: text codes, test images and media phrases that a project's moderation
10
10
  # recognises as a declared scenario when they are signed with its secret test input key (shown in
11
- # the portal's project settings), so CalmLens returns that scenario instead of classifying them.
12
- # The formats match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
11
+ # the portal's project settings to those who can read them) and the project has "Recognise test
12
+ # inputs" turned on, so CalmLens returns that scenario instead of classifying them. The formats
13
+ # match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
13
14
  #
14
15
  # A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
15
16
  # to camelCase like other options):
@@ -20,8 +21,14 @@ module CalmLens
20
21
  # 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
21
22
  # optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
22
23
  # `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
23
- # {REGIONS}. Values are Strings and Numerics. An absent list is left out of the signed scenario;
24
- # an empty one is sent as `[]`. Anything else raises `sdk/invalid-argument`.
24
+ # {REGIONS}. `detections` (optional, `people` and `faces` each optional) sets the person and face
25
+ # detection results a test image reports: each list holds at most 32 entries, or is the String
26
+ # `'unavailable'` to report the feature as not evaluated. An entry has a `box`, `[left, top, width,
27
+ # height]` as Integers in basis points of the image (0 to 10000, inside it), and an optional
28
+ # Integer `confidence` from 0 to 100; a face may also have an `age`, `'adult'`, `'minor'` or
29
+ # `{ 'low' => 30, 'high' => 40 }` (Integers from 0 to 120). Values are Strings and Numerics. An
30
+ # absent list is left out of the signed scenario; an empty one is sent as `[]`. Anything else
31
+ # raises `sdk/invalid-argument`.
25
32
  #
26
33
  # @example
27
34
  # code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
@@ -47,8 +54,11 @@ module CalmLens
47
54
  value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
48
55
  value.freeze
49
56
  end
57
+ person = { 'box' => [2500, 1500, 5000, 8500] }
58
+ face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
50
59
  # Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
51
- # regional delivery, content restricted in Germany, and mixed instances in one category.
60
+ # regional delivery, content restricted in Germany, mixed instances in one category, and test
61
+ # images with one person and face, nobody, an adult's face, a minor's face and a group of three.
52
62
  PRESETS = deep_freeze.call(
53
63
  clean: { 'instances' => [] },
54
64
  limited: one.call('harassment', 'harassment-abuse'),
@@ -59,7 +69,15 @@ module CalmLens
59
69
  { 'region' => 'FR', 'action' => 'limit' }] },
60
70
  germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
61
71
  mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
62
- { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
72
+ { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
73
+ one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
74
+ no_face: { 'instances' => [], 'detections' => { 'people' => [], 'faces' => [] } },
75
+ adult_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('adult')] } },
76
+ minor_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('minor')] } },
77
+ group: { 'instances' => [], 'detections' => {
78
+ 'people' => [500, 3600, 6700].map { |left| { 'box' => [left, 2000, 2800, 8000] } },
79
+ 'faces' => [1200, 4300, 7400].map { |left| { 'box' => [left, 2500, 1400, 2000] } }
80
+ } }
63
81
  )
64
82
 
65
83
  HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
@@ -90,8 +108,8 @@ module CalmLens
90
108
  Marshal.load(Marshal.dump(preset))
91
109
  end
92
110
 
93
- # The signed text code for a scenario or a signal (one of {SIGNALS}): put it anywhere in a text
94
- # input.
111
+ # The signed text code for a scenario or a signal (one of {SIGNALS}). Submit it as the whole
112
+ # text: CalmLens moderates text with anything else around the code as ordinary content.
95
113
  # @param input [Hash, String] a scenario or a signal
96
114
  # @param test_input_key [String] the project's secret test input key from the portal
97
115
  # @return [String]
@@ -101,7 +119,9 @@ module CalmLens
101
119
  end
102
120
 
103
121
  # Paints the signed test image for a scenario or a signal (one of {SIGNALS}): a grid that
104
- # survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format.
122
+ # survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format, as
123
+ # the whole image, optionally on a plain single-colour border: CalmLens moderates an image with
124
+ # anything else in it as ordinary content.
105
125
  # @param input [Hash, String] a scenario or a signal
106
126
  # @param test_input_key [String] the project's secret test input key from the portal
107
127
  # @return [Image]
@@ -136,11 +156,12 @@ module CalmLens
136
156
  Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
137
157
  end
138
158
 
159
+ # The signed payload is the scenario's canonical JSON.
139
160
  def text_code(checked, test_input_key)
140
161
  payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
141
162
  raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
142
163
 
143
- "<!calmlens-test #{tag(test_input_key, "calmlens-test:v1:#{payload}")} #{payload}>"
164
+ "<!calmlens-test #{tag(test_input_key, "calmlens-test:v2:#{payload}")} #{payload}>"
144
165
  end
145
166
 
146
167
  def check_key(key)
@@ -22,9 +22,12 @@ module CalmLens
22
22
  # A `purged` outcome removes the content with reason `policy`. The CDN answers like the real one:
23
23
  # `asset/not-available` (403) for blocked, purged, failed or removed content,
24
24
  # `viewer/age-confirmation-required` (403) for an age-restricted asset without
25
- # `viewer_claims: {age_confirmed: true}`, whatever the key, and `asset/api-key-missing-permission` (403)
25
+ # `viewer_claims: {age_confirmed: true}`, whatever the key, and `auth/permission-denied` (403)
26
26
  # for a private asset unless `api_key` comes from {.create_api_key} with an `assets:download`
27
- # grant. The default key has no grants. {#decide} applies a reviewer's decision, and
27
+ # grant (the default key has no grants), or the URL is signed: {CDN#sign_asset_url} returns
28
+ # `{cdn_base_url}/assets/{project_id}/sig_…`, a readable grant that only the mock's CDN accepts,
29
+ # which serves `original` only when signed with `original: true`. {#fetch_cdn} loads such a URL
30
+ # without the API key, as a browser would. {#decide} applies a reviewer's decision, and
28
31
  # {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
29
32
  # Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
30
33
  # `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
@@ -60,13 +63,15 @@ module CalmLens
60
63
  # Simulated failures for {#fail_next_request}, besides API error codes and exceptions.
61
64
  REQUEST_FAILURES = %w[transport-error timeout].freeze
62
65
  # The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
63
- OPERATIONS = { assets: %i[search list retrieve list_children update create classify upload_part create_signed_url move
64
- retry explain wait_for_decision delete_content delete batch],
66
+ OPERATIONS = { assets: %i[search list retrieve list_children list_timeline update create classify upload_part move
67
+ retry explain wait_for_decision remove_content delete batch],
65
68
  folders: %i[list retrieve create update delete move],
66
69
  hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
67
- policies: %i[list retrieve create update apply_preset set_automatic_updates delete assign],
70
+ policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
71
+ assign],
72
+ lenses: %i[list create retrieve update delete],
68
73
  text: %i[classify], events: %i[list],
69
- cdn: %i[serve] }.freeze
74
+ cdn: %i[serve sign_asset_url get_asset_info] }.freeze
70
75
  private_constant :OPERATIONS
71
76
  # The operation names {#fail_next_request} accepts, in their camelCase form.
72
77
  OPERATION_NAMES = (OPERATIONS.flat_map do |resource, methods|
@@ -141,7 +146,9 @@ module CalmLens
141
146
  # queue the work) and returns a status from {MockDecision::STATUSES} or a {MockDecision}. It is
142
147
  # called on uploads (`assets.create`, `assets.classify`, `text.classify` and a final part:
143
148
  # trigger `upload`), `assets.retry` (`retry`), `assets.explain` with `rerun: true` (`rerun`) and
144
- # {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing.
149
+ # {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing;
150
+ # for a re-run it keeps the asset's decision until {#complete_processing}, and a failed re-run
151
+ # keeps it.
145
152
  # Without it, queued work other than `complete` stays `pending` and the rest is `allowed`. It
146
153
  # runs while the mock handles a request, so it must not call the mock.
147
154
  # @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
@@ -162,7 +169,8 @@ module CalmLens
162
169
  super(api_key: api_key, project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url, max_retries: max_retries,
163
170
  **options, transport: method(:receive))
164
171
  @fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
165
- now: now, create_id: create_id, moderate: moderate, grants: MockServerClient.grants_of(api_key))
172
+ now: now, create_id: create_id, moderate: moderate, api_key: api_key,
173
+ grants: MockServerClient.grants_of(api_key))
166
174
  @state = @fake.state
167
175
  # Like the API, the stand-in checks upload sizes itself, in the API's order.
168
176
  @session.defer_upload_checks = true
@@ -174,8 +182,9 @@ module CalmLens
174
182
  end
175
183
 
176
184
  # Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
177
- # status changes (`purged` also removes the content), the review joins `manualReviews`, and an
178
- # `asset.<decision>` event with a `decided-by: reviewer` fact joins the events feed.
185
+ # status changes (`purged` also removes the content), the review joins `manualReviews` with
186
+ # `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, and an
187
+ # `asset.<decision>` event whose `actor` is a `user` joins the events feed.
179
188
  # @param asset_id [String]
180
189
  # @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
181
190
  # @return [Hash] the asset
@@ -193,7 +202,8 @@ module CalmLens
193
202
  # @param decision [String, MockDecision, nil] a status from {MockDecision::STATUSES} or a {MockDecision}
194
203
  # @return [Hash] the asset
195
204
  # @raise [APIError] `asset/not-found` for an unknown asset
196
- # @raise [InvalidArgumentError] when the asset isn't processing, or for an unknown decision
205
+ # @raise [InvalidArgumentError] when the asset isn't processing or awaiting a queued re-run, or for
206
+ # an unknown decision
197
207
  def complete_processing(asset_id, decision = nil)
198
208
  @mock_lock.synchronize { @fake.complete_processing(asset_id, decision) } || raise(not_found)
199
209
  end
@@ -241,6 +251,18 @@ module CalmLens
241
251
  self
242
252
  end
243
253
 
254
+ # Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
255
+ # asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
256
+ # {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
257
+ # raised.
258
+ # @param url [String]
259
+ # @return [Response] with its body read
260
+ def fetch_cdn(url)
261
+ raise Internal.invalid('url must be a String') unless url.is_a?(String)
262
+
263
+ @session.cdn('GET', url, {}, {}, authenticate: false)
264
+ end
265
+
244
266
  # @see ServerClient#ensure_lenses
245
267
  def ensure_lenses(options) = operation('ensureLenses') { super }
246
268
 
@@ -275,7 +297,9 @@ module CalmLens
275
297
  end
276
298
 
277
299
  # Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
278
- # `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in. An event
300
+ # `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in, and `actor`
301
+ # is the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) unless you pass one, such as
302
+ # `actor: { type: 'user', id: nil }`. An event
279
303
  # with the `asset_id` of a stored asset carries that asset's snapshot as it is now, unless you
280
304
  # pass `asset`.
281
305
  # @param event [Hash] at least `type:`; snake_case keys are converted.
@@ -58,7 +58,7 @@ module CalmLens
58
58
  # Reads a CDN selector, the path text after the resource as sent (still percent-encoded), as the
59
59
  # CDN does: an optional first `@lens`, then `key=value` options with dotted keys for nested ones (a
60
60
  # bare key is `true`). Values are numbers, `true`, `false`, hex colours (`#` added) for colour
61
- # options, or text. Raises ArgumentError on anything the CDN refuses as `asset/invalid-transform`.
61
+ # options, or text. Raises ArgumentError on anything the CDN refuses as `transform/invalid`.
62
62
  # Used by the mock CDN.
63
63
  # @return [Array(String, Hash)] the lens and the options, each nil when absent
64
64
  def parse_selector(selector)
@@ -3,7 +3,7 @@
3
3
  # Generated by packages/sdk-contract/generate.ts from packages/shared/src/UploadMimeTypes.ts; do not edit.
4
4
  # After lower-casing and dropping parameters, a type in REJECTED is refused; otherwise one
5
5
  # that starts with a PREFIXES entry, ends with a SUFFIXES entry or is in TYPES is accepted,
6
- # and any other type is refused with upload/invalid-mime-type.
6
+ # and any other type is refused with upload/unsupported-type.
7
7
 
8
8
  module CalmLens
9
9
  module Internal
@@ -32,6 +32,14 @@ module CalmLens
32
32
  'application/msword',
33
33
  'application/rtf',
34
34
  'application/vnd.oasis.opendocument.text',
35
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
36
+ 'application/vnd.ms-excel.sheet.macroenabled.12',
37
+ 'application/vnd.ms-excel',
38
+ 'application/vnd.oasis.opendocument.spreadsheet',
39
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
40
+ 'application/vnd.ms-powerpoint.presentation.macroenabled.12',
41
+ 'application/vnd.ms-powerpoint',
42
+ 'application/vnd.oasis.opendocument.presentation',
35
43
  'application/javascript',
36
44
  'application/json',
37
45
  'application/x-javascript',
@@ -26,7 +26,7 @@ module CalmLens
26
26
  # Signs a single-use token. Hand it to the browser as its bearer token; give each upload its own.
27
27
  # @option options [String] :upload_preset_id the preset the token may upload through
28
28
  # @option options [String] :external_id the external ID the uploaded asset gets (not empty)
29
- # @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
29
+ # @option options [Integer] :expires_in lifetime in seconds, 1 to 3600 (default 600)
30
30
  # @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
31
31
  def sign(options)
32
32
  call(options, __method__) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
@@ -39,9 +39,9 @@ module CalmLens
39
39
  api, = Internal.options(options)
40
40
  preset_id = api['uploadPresetId']
41
41
  Internal.segment(preset_id)
42
- ttl = api.fetch('expiresInSeconds', DEFAULT_TTL_SECONDS)
42
+ ttl = api.fetch('expiresIn', DEFAULT_TTL_SECONDS)
43
43
  unless ttl.is_a?(Integer) && ttl.between?(1, MAX_TTL_SECONDS)
44
- raise Internal.invalid("expiresInSeconds must be a whole number from 1 to #{MAX_TTL_SECONDS}")
44
+ raise Internal.invalid("expiresIn must be a whole number from 1 to #{MAX_TTL_SECONDS}")
45
45
  end
46
46
 
47
47
  external_id = api['externalId']
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.20.0'
5
+ VERSION = '0.21.0'
6
6
  end