active_sanction 1.1.0 → 1.1.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/CONTRIBUTING.md +21 -4
- data/README.md +1 -1
- data/docs/adding_a_source.md +1 -1
- data/docs/api_stability.md +32 -5
- data/lib/active_sanction/client.rb +37 -0
- data/lib/active_sanction/testing/sanction_source.rb +234 -0
- data/lib/active_sanction/testing/storage_adapter.rb +319 -0
- data/lib/active_sanction/testing/storage_adapter_defaults.rb +25 -0
- data/lib/active_sanction/testing.rb +119 -0
- data/lib/active_sanction/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecdae0214290d15cba0bf24838c8e4d2d2e004eaecb344c29aaa8c07d54da36b
|
|
4
|
+
data.tar.gz: c72d3f5baa535d081aae672de2013585e38c85c1cceb5c2a697d6cecc7d5f98a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fc3e4e17279f0220f903792d952d4aa1adfa1068504512f51cbacb2247cc4ae71710b38c4de6374810e0c48bcc03052a7149eff9a69dda9ebc02389e27c8f05
|
|
7
|
+
data.tar.gz: 34df5f7d1103073a4bd50956abc77e1e87a9a3c5a213be9c18a46a6e41ccbdd5db64fcf0c7b7437d4c52e33f6876ea581ba57e04a44baa95d3ecec1666a8e315
|
data/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,61 @@ screening decision would come out as today.
|
|
|
17
17
|
|
|
18
18
|
Nothing yet.
|
|
19
19
|
|
|
20
|
+
## [1.1.1] - 2026-09-14
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **The conformance groups ship** (#144). `require "active_sanction/testing"` loads the two
|
|
25
|
+
shared example groups that define what a source adapter and a storage adapter must do, so
|
|
26
|
+
an adapter written in your own application is held to the same contract the built-in ones
|
|
27
|
+
are:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# spec/spec_helper.rb
|
|
31
|
+
require "active_sanction/testing"
|
|
32
|
+
|
|
33
|
+
# spec/internal_watchlist_spec.rb
|
|
34
|
+
RSpec.describe MyCompany::InternalWatchlist do
|
|
35
|
+
it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
They were always the stated contract — `docs/api_stability.md` calls them "the executable
|
|
40
|
+
statement of what [the extension points] require" — and they lived under `spec/`, which is
|
|
41
|
+
excluded from the packaged gem. The audience for the promise was the one group of people
|
|
42
|
+
who could not run it. Fixture paths resolve under `spec/fixtures` unless
|
|
43
|
+
`ActiveSanction::Testing.fixture_root` says otherwise, and an absolute path is taken as it
|
|
44
|
+
stands.
|
|
45
|
+
|
|
46
|
+
**`require "active_sanction"` does not load any of it**, so nothing reaches a production
|
|
47
|
+
process; requiring it without RSpec says so rather than failing somewhere stranger. The
|
|
48
|
+
group names and the options they take are public API from here, and a group gaining an
|
|
49
|
+
example is a minor with a note here naming it.
|
|
50
|
+
|
|
51
|
+
- **Preparing a release is a button** (#146). `Prepare release` in the Actions tab takes a
|
|
52
|
+
version, bumps `VERSION`, moves the accrued `Unreleased` section under a dated heading,
|
|
53
|
+
fixes the changelog's link definitions, regenerates the site data that carries the
|
|
54
|
+
version, and opens a pull request. `release.yml` verifies all of that and writes none of
|
|
55
|
+
it, so until now it was a hand-edit somebody had to remember — and hand-edits to this
|
|
56
|
+
particular shape have already gone wrong silently once, when two open pull requests
|
|
57
|
+
merged cleanly in sequence and filed a feature under a patch release that did not contain
|
|
58
|
+
it.
|
|
59
|
+
|
|
60
|
+
The edits are [`bin/prepare_release`](bin/prepare_release), with a spec over them, so the
|
|
61
|
+
workflow is reviewable as Ruby and `bin/prepare_release 1.2.3` does the same thing on a
|
|
62
|
+
laptop. It refuses a version that does not come after the current one, and refuses to
|
|
63
|
+
prepare a release out of an empty `Unreleased` — which `release.yml` also refuses, but
|
|
64
|
+
only after the gem is published. Nothing is tagged or pushed to `main`: the output is a
|
|
65
|
+
pull request, and the version number stays a judgment a person makes.
|
|
66
|
+
|
|
67
|
+
- **`Client#supports?`** (#144), so a caller can ask what an implementation does rather than
|
|
68
|
+
find out by rescuing `NoMethodError`. This client supports all of
|
|
69
|
+
`Client::CAPABILITIES`; the method is for the implementations that are not this class —
|
|
70
|
+
one answering screening questions against data somebody else keeps fresh has no `sync!`
|
|
71
|
+
to offer. An unrecognised capability is `false` rather than an error, deliberately unlike
|
|
72
|
+
`Configuration`: the caller asking is usually written against a newer version than the one
|
|
73
|
+
answering, and wants a fallback path rather than an exception.
|
|
74
|
+
|
|
20
75
|
## [1.1.0] - 2026-09-14
|
|
21
76
|
|
|
22
77
|
**A minor, because it adds.** Nothing that existed changed: no behaviour under `lib/`
|
|
@@ -652,7 +707,8 @@ summarized here because they are what a reader of a first release most needs:
|
|
|
652
707
|
- Recall at the default threshold is 0.939 overall on the labeled set, and every record this
|
|
653
708
|
version misses is named in the committed accuracy report.
|
|
654
709
|
|
|
655
|
-
[Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.
|
|
710
|
+
[Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.1...main
|
|
711
|
+
[1.1.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...v1.1.1
|
|
656
712
|
[1.1.0]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...v1.1.0
|
|
657
713
|
[1.0.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.0...v1.0.1
|
|
658
714
|
[1.0.0]: https://github.com/Babystep-Technologies/active_sanction/releases/tag/v1.0.0
|
data/CONTRIBUTING.md
CHANGED
|
@@ -234,10 +234,27 @@ comment restating the name would be noise.
|
|
|
234
234
|
|
|
235
235
|
`bundle exec rake install` installs the gem locally.
|
|
236
236
|
|
|
237
|
-
A release is a tag
|
|
238
|
-
|
|
239
|
-
`
|
|
240
|
-
|
|
237
|
+
A release is a tag, and two facts have to be true on `main` before one can
|
|
238
|
+
exist: `VERSION` says the version, and `CHANGELOG.md` has a section for it.
|
|
239
|
+
`release.yml` checks both and writes neither.
|
|
240
|
+
|
|
241
|
+
**Putting them there is a button too.** Open
|
|
242
|
+
[`Prepare release`](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/prepare-release.yml),
|
|
243
|
+
press **Run workflow**, enter `1.2.3`. It bumps `VERSION`, moves the accrued
|
|
244
|
+
`Unreleased` section under a dated `## [1.2.3]` heading, leaves a fresh empty
|
|
245
|
+
`Unreleased` behind, fixes the link definitions at the foot of the changelog,
|
|
246
|
+
regenerates the one site data file that carries the version, and opens a pull
|
|
247
|
+
request. CI runs on it like any other.
|
|
248
|
+
|
|
249
|
+
The edits are [`bin/prepare_release`](bin/prepare_release), so
|
|
250
|
+
`bin/prepare_release 1.2.3` does exactly the same thing on your machine if you
|
|
251
|
+
would rather commit it yourself. It refuses a version that does not come after
|
|
252
|
+
the current one, and refuses to prepare a release out of an empty `Unreleased`
|
|
253
|
+
— which `release.yml` also refuses, but only in the job that writes the GitHub
|
|
254
|
+
release, long after the gem is published.
|
|
255
|
+
|
|
256
|
+
**Read the moved changelog section before merging.** It becomes the release
|
|
257
|
+
note verbatim.
|
|
241
258
|
|
|
242
259
|
Then the tag, which you can let the workflow write or write yourself.
|
|
243
260
|
|
data/README.md
CHANGED
|
@@ -288,7 +288,7 @@ A source registered from outside this gem is a first-class source: a bank's inte
|
|
|
288
288
|
|
|
289
289
|
There is no scaffold generator, deliberately. Roughly eight adapters at maturity do not repay one that has to be kept in step with `Sources::Base`, the conformance spec and the parser toolkits, and that goes stale silently when it is not; the document plus the closest existing adapter to copy does the same job with none of the upkeep.
|
|
290
290
|
|
|
291
|
-
Every source adapter is held to one shared example group, `"a sanction source"`
|
|
291
|
+
Every source adapter is held to one shared example group, `"a sanction source"` — shipped, so an adapter written outside this repository is held to it too: `require "active_sanction/testing"`. It checks what everything downstream of an adapter assumes and cannot check for itself — a declared key, jurisdiction, authority and URL; `Entity` objects with unique, deterministic ids; dates as `PartialDate`; a round trip through `#to_h`; the publisher's own text kept in `remarks`. It is the floor and not the ceiling, so every adapter still writes its own spec on top.
|
|
292
292
|
|
|
293
293
|
## How it works
|
|
294
294
|
|
data/docs/adding_a_source.md
CHANGED
|
@@ -691,7 +691,7 @@ There is one option: `remarks: false`, for a list that publishes no free text of
|
|
|
691
691
|
its own anywhere — Canada is the only launch source that qualifies. It has to be
|
|
692
692
|
asked for, so that dropping a remark by accident stays a failure.
|
|
693
693
|
|
|
694
|
-
The group lives in `
|
|
694
|
+
The group lives in `lib/active_sanction/testing/sanction_source.rb`, reaches a suite through `require "active_sanction/testing"`, and checks
|
|
695
695
|
what everything downstream assumes and cannot check for itself: that the adapter
|
|
696
696
|
declares a key, a jurisdiction, an authority and a URL and registers itself; that
|
|
697
697
|
`#parse` returns Entities with unique, deterministic ids, a canonical type and at
|
data/docs/api_stability.md
CHANGED
|
@@ -96,10 +96,11 @@ watchlist, a store backed by somebody's own database — and those authors are
|
|
|
96
96
|
not reading this repository's release notes. So the required methods of each,
|
|
97
97
|
their arguments and what they must return do not change within a major version,
|
|
98
98
|
and the shared conformance groups
|
|
99
|
-
([`
|
|
100
|
-
[`
|
|
99
|
+
([`lib/active_sanction/testing/sanction_source.rb`](../lib/active_sanction/testing/sanction_source.rb),
|
|
100
|
+
[`lib/active_sanction/testing/storage_adapter.rb`](../lib/active_sanction/testing/storage_adapter.rb))
|
|
101
101
|
are the executable statement of what they require. An adapter that passes them
|
|
102
|
-
today passes them for the life of the major version
|
|
102
|
+
today passes them for the life of the major version — and **they ship**, so an
|
|
103
|
+
adapter outside this repository can run them: `require "active_sanction/testing"`.
|
|
103
104
|
|
|
104
105
|
New *optional* hooks may be added — a method with a default implementation on
|
|
105
106
|
the base class is not a break, because an adapter that does not define it goes
|
|
@@ -193,8 +194,10 @@ oversight:
|
|
|
193
194
|
themselves are public — they are how an adapter is written — but the classes
|
|
194
195
|
that do the reading underneath them are not.
|
|
195
196
|
- **Anything under `spec/`, `benchmark/`, `canary/` or `bin/`.** None of it
|
|
196
|
-
ships in the gem. The
|
|
197
|
-
|
|
197
|
+
ships in the gem. The two conformance groups used to be the exception in
|
|
198
|
+
spirit — expected to be run by adapter authors who had no way to get them —
|
|
199
|
+
and they are now under `lib/active_sanction/testing/` and public, which is
|
|
200
|
+
what the section above is about.
|
|
198
201
|
|
|
199
202
|
## The enumerated public surface
|
|
200
203
|
|
|
@@ -207,6 +210,7 @@ ActiveSanction
|
|
|
207
210
|
ActiveSanction::VERSION
|
|
208
211
|
ActiveSanction::MATCHER_VERSION
|
|
209
212
|
ActiveSanction::Client
|
|
213
|
+
ActiveSanction::Client::CAPABILITIES
|
|
210
214
|
ActiveSanction::Configuration
|
|
211
215
|
ActiveSanction::Deprecation
|
|
212
216
|
ActiveSanction::Configuration::DEFAULT_CACHE_DIRNAME
|
|
@@ -300,6 +304,29 @@ ActiveSanction::Parsers::Warning
|
|
|
300
304
|
ActiveSanction::Parsers::ColumnShape
|
|
301
305
|
```
|
|
302
306
|
|
|
307
|
+
### Testing your own adapter
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
ActiveSanction::Testing
|
|
311
|
+
ActiveSanction::Testing::DEFAULT_FIXTURE_ROOT
|
|
312
|
+
ActiveSanction::Testing::StorageAdapterDefaults
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Loaded by `require "active_sanction/testing"`, never by `require
|
|
316
|
+
"active_sanction"`. The two shared example group **names** — `"a sanction
|
|
317
|
+
source"` and `"a storage adapter"` — are public on the same terms as the
|
|
318
|
+
constants: a group is not renamed or removed within a major version, and the
|
|
319
|
+
options it accepts do not change meaning under an adapter that passes it
|
|
320
|
+
today.
|
|
321
|
+
|
|
322
|
+
New examples may be **added** to a group, and that is not a breaking change
|
|
323
|
+
even though it can turn a passing adapter red. It is the same promise the
|
|
324
|
+
extension points make read from the other side: what the group checks is what
|
|
325
|
+
`Sources::Base` and `Storage::Base` required all along, and an adapter that
|
|
326
|
+
fails a newly added example was always violating the contract — the group
|
|
327
|
+
merely started saying so. Additions land in a minor, with a `CHANGELOG.md`
|
|
328
|
+
entry naming them.
|
|
329
|
+
|
|
303
330
|
### Storage, and what a fetch remembers
|
|
304
331
|
|
|
305
332
|
```
|
|
@@ -79,6 +79,15 @@ module ActiveSanction
|
|
|
79
79
|
class Client
|
|
80
80
|
extend T::Sig
|
|
81
81
|
|
|
82
|
+
# What a client may be asked to do. Every one of them is a public method
|
|
83
|
+
# on this class, and this class supports all of them; see #supports?, and
|
|
84
|
+
# the note there on why an unknown name is false rather than an error.
|
|
85
|
+
#
|
|
86
|
+
# A name is not removed from this list within a major version, and a new
|
|
87
|
+
# one may be added -- which is the same promise the enumerated public
|
|
88
|
+
# surface makes, for the same reason.
|
|
89
|
+
CAPABILITIES = T.let(%i[screen screen_all sync diff rescreen doctor export import].freeze, T::Array[Symbol])
|
|
90
|
+
|
|
82
91
|
# The settings this client answers from, frozen. Reading one is how a host
|
|
83
92
|
# asks what a client is: `client.configuration.user_agent`.
|
|
84
93
|
sig { returns(Configuration) }
|
|
@@ -330,6 +339,34 @@ module ActiveSanction
|
|
|
330
339
|
sig { returns(T::Boolean) }
|
|
331
340
|
def loaded? = !@lock.synchronize { @matcher }.nil?
|
|
332
341
|
|
|
342
|
+
# Whether this client does a thing, asked rather than assumed:
|
|
343
|
+
#
|
|
344
|
+
# client.supports?(:sync) # => true
|
|
345
|
+
# client.supports?(:diff) # => true
|
|
346
|
+
#
|
|
347
|
+
# This client supports all of CAPABILITIES -- it fetches, parses, stores
|
|
348
|
+
# and screens on the machine it runs on, and there is nothing in the list
|
|
349
|
+
# it cannot do. The method exists for the ones that are not this class.
|
|
350
|
+
# #56 was closed because `Client` already is the interface an out-of-tree
|
|
351
|
+
# implementation duck-types, and the one thing a duck-typed interface
|
|
352
|
+
# cannot express is *absence*: an implementation that answers screening
|
|
353
|
+
# questions against data somebody else keeps fresh has no `sync!` to
|
|
354
|
+
# offer, and a caller that cannot ask ends up finding out by rescuing
|
|
355
|
+
# NoMethodError.
|
|
356
|
+
#
|
|
357
|
+
# ### An unknown capability is false rather than an error
|
|
358
|
+
#
|
|
359
|
+
# Deliberately unlike Configuration, where an unrecognised setting raises
|
|
360
|
+
# because a silently dropped one is a client running on a default somebody
|
|
361
|
+
# thinks they changed. The direction here is the opposite: this is a
|
|
362
|
+
# forward-compatibility question, and the caller asking it is usually
|
|
363
|
+
# written against a newer version than the one answering. A host asking
|
|
364
|
+
# `supports?(:something_1_4_added)` of a 1.2 client wants `false` and a
|
|
365
|
+
# fallback path, not an exception -- raising would make the method useless
|
|
366
|
+
# for the one job it has.
|
|
367
|
+
sig { params(capability: T.untyped).returns(T::Boolean) }
|
|
368
|
+
def supports?(capability) = CAPABILITIES.include?(capability.to_sym)
|
|
369
|
+
|
|
333
370
|
sig { returns(String) }
|
|
334
371
|
def inspect
|
|
335
372
|
named = sources ? T.must(sources).join(", ") : "every registered source"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The contract every source adapter must satisfy, written once.
|
|
5
|
+
#
|
|
6
|
+
# Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
|
|
7
|
+
# which is where the entry point and the fixture root are documented.
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe ActiveSanction::Sources::CanadaSema do
|
|
10
|
+
# it_behaves_like "a sanction source", fixture: "canada_sema/sema.xml"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# A list its publisher splits across several files names each one, under the
|
|
14
|
+
# keys the adapter declared its URLs with:
|
|
15
|
+
#
|
|
16
|
+
# it_behaves_like "a sanction source",
|
|
17
|
+
# fixture: { sdn: "ofac_sdn/SDN.CSV", alt: "ofac_sdn/ALT.CSV", add: "ofac_sdn/ADD.CSV" }
|
|
18
|
+
#
|
|
19
|
+
# Paths are relative to `spec/fixtures` -- or to whatever
|
|
20
|
+
# `ActiveSanction::Testing.fixture_root` names, and an absolute path is taken
|
|
21
|
+
# as it stands. The bytes reach #parse exactly as they were committed --
|
|
22
|
+
# undecoded, so an adapter reading the Windows-1252 OFAC serves is held to
|
|
23
|
+
# doing its own decoding rather than to being handed a String somebody already
|
|
24
|
+
# fixed up.
|
|
25
|
+
#
|
|
26
|
+
# ### What this is for
|
|
27
|
+
#
|
|
28
|
+
# Everything downstream of an adapter -- storage, the diff between two syncs,
|
|
29
|
+
# the index, the matcher, the report an examiner reads -- is written against
|
|
30
|
+
# Entity and never against a list. That only holds if every adapter really
|
|
31
|
+
# does produce the same shape, and "the same shape" is otherwise a paragraph
|
|
32
|
+
# in a design document that each new adapter re-interprets. Here it is
|
|
33
|
+
# executable, so adding a jurisdiction is a checklist rather than an open
|
|
34
|
+
# research question.
|
|
35
|
+
#
|
|
36
|
+
# ### What it does not do
|
|
37
|
+
#
|
|
38
|
+
# It does not check that the adapter read its list *correctly*. Nothing here
|
|
39
|
+
# knows that OFAC writes `-0-` for null, that the UN means two different
|
|
40
|
+
# things by QUALITY, or which of the fixture's records is a vessel. Only a
|
|
41
|
+
# spec that knows what is in the fixture can check that, so every adapter
|
|
42
|
+
# still writes its own; this group is the floor, not the ceiling.
|
|
43
|
+
#
|
|
44
|
+
# ### Options
|
|
45
|
+
#
|
|
46
|
+
# fixture: required. A path under the fixture root, or a Hash of one path
|
|
47
|
+
# per declared URL. Real published records, not invented ones --
|
|
48
|
+
# a conformance run against a fixture somebody wrote to pass it
|
|
49
|
+
# proves nothing about the list.
|
|
50
|
+
#
|
|
51
|
+
# remarks: pass `remarks: false` for a list that publishes no free text of
|
|
52
|
+
# its own at all, so the check that the publisher's own words
|
|
53
|
+
# survive into `remarks` is skipped. Every list at launch does
|
|
54
|
+
# publish some, which is why the default is to require it: a
|
|
55
|
+
# remark quietly dropped is how a place of birth or a passport
|
|
56
|
+
# number stops reaching #19.
|
|
57
|
+
RSpec.shared_examples "a sanction source" do |options = {}|
|
|
58
|
+
publishes_remarks = options.fetch(:remarks, true)
|
|
59
|
+
|
|
60
|
+
# Read through a `let` rather than closed over directly, so that a group
|
|
61
|
+
# that forgot to name a fixture says so when an example asks for one rather
|
|
62
|
+
# than while the suite is still loading.
|
|
63
|
+
let(:fixture) do
|
|
64
|
+
options.fetch(:fixture) do
|
|
65
|
+
raise ArgumentError, %(pass the fixture to parse: it_behaves_like "a sanction source", fixture: "list.xml")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
let(:source) { described_class.new }
|
|
70
|
+
let(:payload) { fixture_payload(fixture) { |bytes| bytes } }
|
|
71
|
+
let(:records) { source.parse(payload) }
|
|
72
|
+
|
|
73
|
+
# Fixture bytes in the shape this adapter's #parse takes: the bytes
|
|
74
|
+
# themselves for a source declaring one file, a Hash keyed by declaration
|
|
75
|
+
# name for one declaring several. The same rule Base applies before calling
|
|
76
|
+
# #parse, so an adapter is exercised here exactly as `sync` exercises it.
|
|
77
|
+
def fixture_payload(paths)
|
|
78
|
+
bytes = named_fixtures(paths).to_h { |name, path| [name, yield(File.binread(fixture_path(path)))] }
|
|
79
|
+
described_class.multi_url? ? bytes : bytes.values.first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# One fixture path per declared file. A source declaring one file may name it
|
|
83
|
+
# as a bare path, which is what reading a fixture off disk looks like.
|
|
84
|
+
def named_fixtures(paths)
|
|
85
|
+
files = paths.is_a?(Hash) ? paths : { described_class.urls.keys.first => paths }
|
|
86
|
+
missing = described_class.urls.keys - files.keys
|
|
87
|
+
raise ArgumentError, "no fixture given for the #{missing.join(", ")} file(s) this source declares" if missing.any?
|
|
88
|
+
|
|
89
|
+
files
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Resolved through Testing rather than relative to this file, which is what
|
|
93
|
+
# lets the group work for a project whose fixtures are its own. See
|
|
94
|
+
# ActiveSanction::Testing.fixture_root.
|
|
95
|
+
def fixture_path(path) = ActiveSanction::Testing.fixture_path(path)
|
|
96
|
+
|
|
97
|
+
# Every date any entity carries, wherever the model puts them.
|
|
98
|
+
def entity_dates(entities)
|
|
99
|
+
entities.flat_map do |entity|
|
|
100
|
+
[entity.listed_on, *entity.dates_of_birth,
|
|
101
|
+
*entity.identifiers.flat_map { |identifier| [identifier.issued_on, identifier.expires_on] }]
|
|
102
|
+
end.compact
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The serialized records half the bytes produce, or the marker that reading
|
|
106
|
+
# them raised -- which is the other acceptable answer, and never equal to a
|
|
107
|
+
# complete parse.
|
|
108
|
+
def parsed_truncated(paths)
|
|
109
|
+
truncated = fixture_payload(paths) { |bytes| bytes[0, bytes.bytesize / 2] }
|
|
110
|
+
described_class.new.parse(truncated).map(&:to_h)
|
|
111
|
+
rescue ActiveSanction::Error
|
|
112
|
+
:raised
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "what it declares" do
|
|
116
|
+
it "declares a key, which is the name the list answers to everywhere" do
|
|
117
|
+
expect(described_class.key.to_s).to match(ActiveSanction::Sources::Definition::KEY_PATTERN)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "declares the jurisdiction behind the list" do
|
|
121
|
+
expect(described_class.jurisdiction).to be_a(Symbol)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "declares the authority a compliance report has to print beside a hit" do
|
|
125
|
+
expect(described_class.authority).to be_a(String)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "declares at least one URL to fetch the list from" do
|
|
129
|
+
expect(described_class.urls).not_to be_empty
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# An adapter whose file is required but which never registers is invisible:
|
|
133
|
+
# `config.sources` cannot name it and `sync` will not run it, and nothing
|
|
134
|
+
# says so out loud.
|
|
135
|
+
it "registers itself, so requiring its file is enough to reach it" do
|
|
136
|
+
expect(ActiveSanction::Sources[described_class.key]).to eq(described_class)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "what #parse returns" do
|
|
141
|
+
it "builds at least one record from the fixture" do
|
|
142
|
+
expect(records).not_to be_empty
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "returns Entities and nothing else" do
|
|
146
|
+
expect(records).to all(be_an(ActiveSanction::Entity))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "stamps every entity with this adapter's own key" do
|
|
150
|
+
expect(records.map(&:source).uniq).to eq([described_class.key])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "gives every entity an id" do
|
|
154
|
+
expect(records.map(&:id)).to all(match(/\S/))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A record with no name cannot be screened against, so it is not a record.
|
|
158
|
+
it "gives every entity at least one name" do
|
|
159
|
+
expect(records.reject { |record| record.names.any? }).to be_empty
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Types are the matcher's coarsest filter: a search for a person that can
|
|
163
|
+
# rank a ship is the failure this closes. Entity refuses anything else on
|
|
164
|
+
# construction, so this fails only for something that is not an Entity.
|
|
165
|
+
it "types every entity as one of the four canonical types" do
|
|
166
|
+
expect(records.map(&:type).uniq - ActiveSanction::Entity::TYPES).to be_empty
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Two records under one id are one record to storage, and the second
|
|
170
|
+
# silently replaces the first.
|
|
171
|
+
it "gives no two entities the same id" do
|
|
172
|
+
expect(records.map(&:id).tally.select { |_id, count| count > 1 }).to be_empty
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# #35 diffs two syncs by comparing records under their ids. An id that moves
|
|
177
|
+
# because a Hash iterated differently, or because a counter was involved,
|
|
178
|
+
# reports the whole list as removed and re-added -- and a diff that says
|
|
179
|
+
# everything changed says nothing at all.
|
|
180
|
+
describe "reading the same bytes twice" do
|
|
181
|
+
it "produces the same ids" do
|
|
182
|
+
expect(described_class.new.parse(payload).map(&:id)).to eq(records.map(&:id))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "produces the same records, in the same order" do
|
|
186
|
+
expect(described_class.new.parse(payload).map(&:to_h)).to eq(records.map(&:to_h))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe "the canonical model it produces" do
|
|
191
|
+
# A date left as the publisher's string cannot be compared with one from
|
|
192
|
+
# another list, and PartialDate exists because none of these lists agree on
|
|
193
|
+
# how precise a date of birth is. Entity does not coerce, so an adapter
|
|
194
|
+
# that forgets is caught here rather than in the scorer.
|
|
195
|
+
it "publishes every date as a PartialDate, never as the string it was written as" do
|
|
196
|
+
expect(entity_dates(records)).to all(be_a(ActiveSanction::PartialDate))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# What storage (#24) does to every record on the way to disk and back.
|
|
200
|
+
it "round-trips every entity through the serialized form" do
|
|
201
|
+
expect(records.map { |record| ActiveSanction::Entity.from_h(record.to_h) }).to eq(records)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if publishes_remarks
|
|
205
|
+
# The publisher's own prose is where the fields the canonical model has
|
|
206
|
+
# no home for still live -- OFAC's dates of birth and passport numbers
|
|
207
|
+
# are nowhere else -- so an adapter that drops it loses data no later
|
|
208
|
+
# issue can get back.
|
|
209
|
+
it "keeps the publisher's own text in remarks" do
|
|
210
|
+
expect(records.filter_map { |record| ActiveSanction::Sources::Remarks.published(record.remarks) })
|
|
211
|
+
.not_to be_empty
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
describe "a payload that is not the list" do
|
|
217
|
+
# No sanctions list has ever been empty, so an empty payload is a failed
|
|
218
|
+
# download, a moved URL or an outage -- never a day on which nobody is
|
|
219
|
+
# sanctioned. Returning [] lets a sync succeed at screening against
|
|
220
|
+
# nothing, which is the most expensive way this library can fail.
|
|
221
|
+
it "refuses an empty payload rather than reporting a list with nobody on it" do
|
|
222
|
+
expect { described_class.new.parse(fixture_payload(fixture) { "" }) }
|
|
223
|
+
.to raise_error(ActiveSanction::Error)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A truncated download may still be worth salvaging -- the XML toolkit
|
|
227
|
+
# keeps the records it read before the break and warns -- so what is
|
|
228
|
+
# required is not that it raises, only that half a list never comes back
|
|
229
|
+
# looking exactly like the whole one.
|
|
230
|
+
it "does not report a truncated document as though it were the whole list" do
|
|
231
|
+
expect(parsed_truncated(fixture)).not_to eq(records.map(&:to_h))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The contract every storage adapter must satisfy, written once.
|
|
5
|
+
#
|
|
6
|
+
# Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
|
|
7
|
+
# which is where the entry point and the fixture root are documented.
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe ActiveSanction::Storage::Memory do
|
|
10
|
+
# it_behaves_like "a storage adapter"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# An adapter that needs something to be constructed -- a root directory, a
|
|
14
|
+
# connection -- says how in the customization block, which is evaluated after
|
|
15
|
+
# this group and so wins. It is a method rather than a `let` because a couple
|
|
16
|
+
# of the examples need a second, independent store:
|
|
17
|
+
#
|
|
18
|
+
# RSpec.describe ActiveSanction::Storage::FileSystem do
|
|
19
|
+
# it_behaves_like "a storage adapter" do
|
|
20
|
+
# def build_store = described_class.new(root: Dir.mktmpdir)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# ### What this is for
|
|
25
|
+
#
|
|
26
|
+
# The matcher (#33) is written against Storage::Base and never against a
|
|
27
|
+
# concrete store, which is the whole point of the interface: an installation
|
|
28
|
+
# swaps gzipped JSON for Postgres without touching the code that decides
|
|
29
|
+
# whether two names are the same person. That only holds if every adapter
|
|
30
|
+
# really does mean the same thing by these five methods, and "the same thing"
|
|
31
|
+
# is otherwise a paragraph in a design document that each new adapter
|
|
32
|
+
# re-interprets. Here it is executable.
|
|
33
|
+
#
|
|
34
|
+
# Most of what it checks is a way of losing records quietly. A store that
|
|
35
|
+
# returns an empty snapshot for a source nobody ever synced, one that drops the
|
|
36
|
+
# third of four entities on the way back, one that reorders them, one that
|
|
37
|
+
# accumulates two writes of a list instead of replacing it -- none of those
|
|
38
|
+
# raise, all of them return something that looks like a sanctions list, and the
|
|
39
|
+
# report they produce says the name you screened is clear.
|
|
40
|
+
#
|
|
41
|
+
# ### What it does not do
|
|
42
|
+
#
|
|
43
|
+
# It says nothing about durability, concurrency or performance, which are the
|
|
44
|
+
# things the adapters genuinely differ on: that a FileSystem write is atomic
|
|
45
|
+
# (#24), that an ActiveRecord write is one transaction (#25), that a Memory
|
|
46
|
+
# store is safe to screen from on many threads. Those are properties of one
|
|
47
|
+
# implementation and each adapter's own spec has to make them.
|
|
48
|
+
RSpec.shared_examples "a storage adapter" do
|
|
49
|
+
include ActiveSanction::Testing::StorageAdapterDefaults
|
|
50
|
+
|
|
51
|
+
let(:store) { build_store }
|
|
52
|
+
let(:ofac) { snapshot(:ofac_sdn, %w[2674 1234]) }
|
|
53
|
+
let(:un) { snapshot(:un_consolidated, %w[QDi.001]) }
|
|
54
|
+
|
|
55
|
+
# A record carrying every kind of member the canonical model has, because
|
|
56
|
+
# what a store loses is usually nested: a store that persists names and drops
|
|
57
|
+
# identifiers passes a contract written against bare strings.
|
|
58
|
+
def entity(source, ref)
|
|
59
|
+
ActiveSanction::Entity.new(
|
|
60
|
+
source: source, source_ref: ref, type: :individual,
|
|
61
|
+
names: [ActiveSanction::Name.new(value: "AL ZAWAHIRI #{ref}, Aiman", kind: :primary)],
|
|
62
|
+
addresses: [ActiveSanction::Address.new(country: "EG", city: "Cairo")],
|
|
63
|
+
identifiers: [ActiveSanction::Identifier.new(kind: :passport, value: ref, country: "EG")],
|
|
64
|
+
dates_of_birth: [ActiveSanction::PartialDate.parse("1951-06-19")],
|
|
65
|
+
nationalities: ["EG"], programs: ["SDGT"],
|
|
66
|
+
listed_on: ActiveSanction::PartialDate.parse("2001-09-23"),
|
|
67
|
+
remarks: "DOB 19 Jun 1951; POB Egypt"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def snapshot(source, refs)
|
|
72
|
+
ActiveSanction::Snapshot.new(
|
|
73
|
+
source: source, entities: refs.map { |ref| entity(source, ref) },
|
|
74
|
+
fetched_at: Time.utc(2026, 8, 28, 9, 30, 0), source_version: "Thu, 28 Aug 2026 09:00:00 GMT"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def entity_refs(entities) = entities.map(&:source_ref)
|
|
79
|
+
|
|
80
|
+
describe "a source that has never been synced" do
|
|
81
|
+
# Nil says "we have never fetched this". An empty snapshot says "this list
|
|
82
|
+
# has nobody on it", which no sanctions list has ever said, and a caller
|
|
83
|
+
# that cannot tell them apart screens against nothing and reports clear.
|
|
84
|
+
it "reads back as nil rather than as a list with nobody on it" do
|
|
85
|
+
expect(store.read_snapshot(:ofac_sdn)).to be_nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "has no metadata to report" do
|
|
89
|
+
expect(store.snapshot_meta(:ofac_sdn)).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "is not among the stored sources" do
|
|
93
|
+
expect(store.sources).not_to include(:ofac_sdn)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "is not reported as stored" do
|
|
97
|
+
expect(store).not_to be_stored(:ofac_sdn)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The other half of returning nil: a caller that named the list itself gets
|
|
101
|
+
# an exception rather than an empty result it has to remember to check.
|
|
102
|
+
it "raises when a caller asks for it by name" do
|
|
103
|
+
expect { store.fetch_snapshot(:ofac_sdn) }.to raise_error(ActiveSanction::Storage::MissingSnapshot)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "deletes without complaint, and says there was nothing to delete" do
|
|
107
|
+
expect(store.delete_snapshot(:ofac_sdn)).to be_falsey
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe "what comes back out" do
|
|
112
|
+
before { store.write_snapshot(ofac) }
|
|
113
|
+
|
|
114
|
+
it "returns the snapshot that was written" do
|
|
115
|
+
expect(store.read_snapshot(:ofac_sdn)).to eq(ofac)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Snapshot#== compares checksums, so the example above passes for a store
|
|
119
|
+
# that kept the checksum and lost the list. This is the one that does not.
|
|
120
|
+
it "returns every record, field for field" do
|
|
121
|
+
expect(store.read_snapshot(:ofac_sdn).entities.map(&:to_h)).to eq(ofac.entities.map(&:to_h))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A store that hands back half-deserialized records makes every caller
|
|
125
|
+
# downstream coerce before it can read a name.
|
|
126
|
+
it "returns Entities, not the hashes they serialize to" do
|
|
127
|
+
expect(store.read_snapshot(:ofac_sdn).entities).to all(be_an(ActiveSanction::Entity))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Order is not in the checksum -- Snapshot sorts fingerprints before
|
|
131
|
+
# folding them -- so nothing else catches a store that returns its rows in
|
|
132
|
+
# whatever order the database felt like. A report whose rows move between
|
|
133
|
+
# two runs against an unchanged list is a report an examiner cannot cite.
|
|
134
|
+
it "returns the records in the order they were written" do
|
|
135
|
+
expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(entity_refs(ofac.entities))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "returns the checksum the snapshot was written with" do
|
|
139
|
+
expect(store.read_snapshot(:ofac_sdn).checksum).to eq(ofac.checksum)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# To the second, which is what Snapshot#to_h serializes. An adapter that
|
|
143
|
+
# loses it cannot answer how old the list it is screening against is (#34).
|
|
144
|
+
it "returns the time the list was fetched" do
|
|
145
|
+
expect(store.read_snapshot(:ofac_sdn).fetched_at).to eq(ofac.fetched_at)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "returns the publisher's own version marker" do
|
|
149
|
+
expect(store.read_snapshot(:ofac_sdn).source_version).to eq(ofac.source_version)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Keys arrive as symbols from an adapter and as strings from anything that
|
|
153
|
+
# has been through JSON or a command line.
|
|
154
|
+
it "answers to the source name spelled as a string" do
|
|
155
|
+
expect(store.read_snapshot("ofac_sdn")).to eq(ofac)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "lists the source it stored" do
|
|
159
|
+
expect(store.sources).to eq([:ofac_sdn])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe "what it refuses" do
|
|
164
|
+
# A Hash of the right shape carries a checksum somebody typed; only a
|
|
165
|
+
# Snapshot carries one computed over its own content, and that is the whole
|
|
166
|
+
# basis on which a stored list can be cited months later.
|
|
167
|
+
it "refuses to store something that is not a Snapshot" do
|
|
168
|
+
expect { store.write_snapshot(ofac.to_h) }.to raise_error(ArgumentError)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Source keys are typed by humans and are directory names to any adapter
|
|
172
|
+
# that writes files, so they are held to one rule everywhere.
|
|
173
|
+
it "refuses a name that is not a usable source key" do
|
|
174
|
+
expect { store.read_snapshot("") }.to raise_error(ActiveSanction::Error)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
describe "writing" do
|
|
179
|
+
it "returns the snapshot it stored" do
|
|
180
|
+
expect(store.write_snapshot(ofac)).to eq(ofac)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A sync replaces a list; it does not add to one. A store that accumulates
|
|
184
|
+
# holds a delisted person forever, which is a false hit on every screening
|
|
185
|
+
# run from then on.
|
|
186
|
+
it "replaces a list rather than accumulating two of them" do
|
|
187
|
+
store.write_snapshot(ofac)
|
|
188
|
+
store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
|
|
189
|
+
|
|
190
|
+
expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(%w[9999])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "files a rewritten source once" do
|
|
194
|
+
store.write_snapshot(ofac)
|
|
195
|
+
store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
|
|
196
|
+
|
|
197
|
+
expect(store.sources).to eq([:ofac_sdn])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Per-source isolation is the rule sync orchestration (#34) is built on: one
|
|
201
|
+
# list failing must leave the others exactly as they were.
|
|
202
|
+
it "leaves the other lists alone" do
|
|
203
|
+
store.write_snapshot(un)
|
|
204
|
+
store.write_snapshot(ofac)
|
|
205
|
+
|
|
206
|
+
expect(store.read_snapshot(:un_consolidated)).to eq(un)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "sorts the sources it lists, so a summary does not reshuffle itself" do
|
|
210
|
+
store.write_snapshot(un)
|
|
211
|
+
store.write_snapshot(ofac)
|
|
212
|
+
|
|
213
|
+
expect(store.sources).to eq(%i[ofac_sdn un_consolidated])
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
describe "#snapshot_meta" do
|
|
218
|
+
before { store.write_snapshot(ofac) }
|
|
219
|
+
|
|
220
|
+
it "reports what was stored without being asked for the list" do
|
|
221
|
+
expect(store.snapshot_meta(:ofac_sdn)).to eq(ActiveSanction::Storage::Meta.from_snapshot(ofac))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "counts the records" do
|
|
225
|
+
expect(store.snapshot_meta(:ofac_sdn).record_count).to eq(2)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# What a match result cites (#33). A meta whose checksum is not the stored
|
|
229
|
+
# snapshot's cannot answer which list version cleared a customer.
|
|
230
|
+
it "carries the stored snapshot's checksum" do
|
|
231
|
+
expect(store.snapshot_meta(:ofac_sdn).checksum).to eq(ofac.checksum)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
describe "#each_entity" do
|
|
236
|
+
before do
|
|
237
|
+
store.write_snapshot(ofac)
|
|
238
|
+
store.write_snapshot(un)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "yields every record of every stored list" do
|
|
242
|
+
expect(store.each_entity.to_a.size).to eq(3)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "yields Entities" do
|
|
246
|
+
expect(store.each_entity.to_a).to all(be_an(ActiveSanction::Entity))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The index build (#31) walks every entity of every list. Handing it an
|
|
250
|
+
# array of ~25,000 records built before the first one is yielded is a cost
|
|
251
|
+
# nothing here needs to pay.
|
|
252
|
+
it "returns an Enumerator rather than an array when given no block" do
|
|
253
|
+
expect(store.each_entity).to be_an(Enumerator)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "stops when the caller stops" do
|
|
257
|
+
yielded = []
|
|
258
|
+
store.each_entity do |entity|
|
|
259
|
+
yielded << entity
|
|
260
|
+
break if yielded.any?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
expect(yielded.size).to eq(1)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "yields only the lists it was asked for" do
|
|
267
|
+
expect(entity_refs(store.each_entity(sources: %i[un_consolidated]).to_a)).to eq(%w[QDi.001])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "takes one source named on its own" do
|
|
271
|
+
expect(store.each_entity(sources: :un_consolidated).to_a.size).to eq(1)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Screening two of the three lists an application configured, and saying
|
|
275
|
+
# nothing about the third, is the failure this contract exists to prevent.
|
|
276
|
+
it "refuses to quietly skip a named list that was never synced" do
|
|
277
|
+
expect { store.each_entity(sources: %i[ofac_sdn eu_fsf]).to_a }
|
|
278
|
+
.to raise_error(ActiveSanction::Storage::MissingSnapshot)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "yields nothing from an empty store" do
|
|
282
|
+
expect(build_store.each_entity.to_a).to be_empty
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
describe "removing a list" do
|
|
287
|
+
before { store.write_snapshot(ofac) }
|
|
288
|
+
|
|
289
|
+
it "reports that there was one to remove" do
|
|
290
|
+
expect(store.delete_snapshot(:ofac_sdn)).to be_truthy
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "forgets the snapshot" do
|
|
294
|
+
store.delete_snapshot(:ofac_sdn)
|
|
295
|
+
|
|
296
|
+
expect(store.read_snapshot(:ofac_sdn)).to be_nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "stops listing the source" do
|
|
300
|
+
store.delete_snapshot(:ofac_sdn)
|
|
301
|
+
|
|
302
|
+
expect(store.sources).to be_empty
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "leaves the other lists alone" do
|
|
306
|
+
store.write_snapshot(un)
|
|
307
|
+
store.delete_snapshot(:ofac_sdn)
|
|
308
|
+
|
|
309
|
+
expect(store.read_snapshot(:un_consolidated)).to eq(un)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "empties the whole store on #clear" do
|
|
313
|
+
store.write_snapshot(un)
|
|
314
|
+
store.clear
|
|
315
|
+
|
|
316
|
+
expect(store).to be_empty
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# How the storage conformance group builds the adapter it is testing.
|
|
5
|
+
#
|
|
6
|
+
# In a module, and in a file of its own, for two reasons that are both about
|
|
7
|
+
# not defining a method twice. A group is free to override `build_store` in the
|
|
8
|
+
# customization block it passes to `it_behaves_like` -- which is how an adapter
|
|
9
|
+
# that cannot be built by `.new` alone gets held to the contract -- and
|
|
10
|
+
# defining a method over one the group already had would warn, since this suite
|
|
11
|
+
# runs with Ruby warnings on. Keeping it out of the shared example group's own
|
|
12
|
+
# file is the same rule one level up: the conformance spec loads that file
|
|
13
|
+
# again inside an RSpec sandbox, and a module reopened there would redefine
|
|
14
|
+
# whatever it holds.
|
|
15
|
+
#
|
|
16
|
+
# Namespaced, because it ships: a bare `StorageAdapterDefaults` at the top
|
|
17
|
+
# level would be this gem putting a name into a host's global namespace to
|
|
18
|
+
# save itself two words.
|
|
19
|
+
module ActiveSanction
|
|
20
|
+
module Testing
|
|
21
|
+
module StorageAdapterDefaults
|
|
22
|
+
def build_store = described_class.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
|
|
7
|
+
require "active_sanction"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
# The conformance groups, shipped, so that an adapter written outside this
|
|
11
|
+
# repository can prove it conforms.
|
|
12
|
+
#
|
|
13
|
+
# # spec/spec_helper.rb, in your application
|
|
14
|
+
# require "active_sanction/testing"
|
|
15
|
+
#
|
|
16
|
+
# # spec/internal_watchlist_spec.rb
|
|
17
|
+
# RSpec.describe MyCompany::InternalWatchlist do
|
|
18
|
+
# it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# RSpec.describe MyCompany::PostgresStore do
|
|
22
|
+
# it_behaves_like "a storage adapter" do
|
|
23
|
+
# def build_store = described_class.new(url: ENV.fetch("DATABASE_URL"))
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Two groups: **"a sanction source"** for a Sources::Base subclass, and
|
|
28
|
+
# **"a storage adapter"** for a Storage::Base implementation. Each is
|
|
29
|
+
# documented at the top of its own file.
|
|
30
|
+
#
|
|
31
|
+
# ### Why this is in `lib/` and not in `spec/`
|
|
32
|
+
#
|
|
33
|
+
# `docs/api_stability.md` names Sources::Base, Storage::Base and
|
|
34
|
+
# ValidatorStore as the three extension points carrying the strongest
|
|
35
|
+
# guarantee, and says of these groups that they "are the executable
|
|
36
|
+
# statement of what they require. An adapter that passes them today passes
|
|
37
|
+
# them for the life of the major version."
|
|
38
|
+
#
|
|
39
|
+
# That promise is addressed to people who install this gem, and until this
|
|
40
|
+
# file existed it was kept only for adapters living in this repository:
|
|
41
|
+
# `spec/` is excluded from the package, so the executable statement shipped
|
|
42
|
+
# nowhere and the guarantee was a paragraph again for everyone it was
|
|
43
|
+
# written for. A conformance suite that its audience cannot run is a
|
|
44
|
+
# conformance suite in name.
|
|
45
|
+
#
|
|
46
|
+
# ### Requiring it is opt-in, and costs a host nothing
|
|
47
|
+
#
|
|
48
|
+
# `require "active_sanction"` does not load this, and nothing under `lib/`
|
|
49
|
+
# requires it. It is loaded by a suite that asked for it, needs RSpec
|
|
50
|
+
# already loaded, and says so rather than failing somewhere stranger if it
|
|
51
|
+
# is not.
|
|
52
|
+
#
|
|
53
|
+
# ### What they do not do
|
|
54
|
+
#
|
|
55
|
+
# Neither group knows anything about *your* list. They are the floor: that
|
|
56
|
+
# an adapter declares what it must, produces Entities of the right shape,
|
|
57
|
+
# gives every record a stable id, and reports what it could not read. Only a
|
|
58
|
+
# spec that knows which of your fixture's records is a vessel can check that
|
|
59
|
+
# you read it correctly, and you still write that one.
|
|
60
|
+
module Testing
|
|
61
|
+
extend T::Sig
|
|
62
|
+
|
|
63
|
+
# Where "a sanction source" looks for the fixture a group names, relative
|
|
64
|
+
# to the working directory a suite runs from. `spec/fixtures` is where
|
|
65
|
+
# RSpec projects put them, which makes the default right for most callers
|
|
66
|
+
# and the setting below right for the rest.
|
|
67
|
+
DEFAULT_FIXTURE_ROOT = T.let("spec/fixtures", String)
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
extend T::Sig
|
|
71
|
+
|
|
72
|
+
# Where fixture paths are resolved from. Set it when a project keeps
|
|
73
|
+
# them somewhere other than `spec/fixtures`:
|
|
74
|
+
#
|
|
75
|
+
# ActiveSanction::Testing.fixture_root = "test/data/sanctions"
|
|
76
|
+
#
|
|
77
|
+
# Relative to the working directory, which for a suite is the project
|
|
78
|
+
# root. An absolute path passed to `fixture:` ignores this entirely.
|
|
79
|
+
sig { params(value: T.untyped).void }
|
|
80
|
+
def fixture_root=(value)
|
|
81
|
+
@fixture_root = T.let(value&.to_s, T.nilable(String))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns(String) }
|
|
85
|
+
def fixture_root
|
|
86
|
+
@fixture_root ||= T.let(nil, T.nilable(String))
|
|
87
|
+
File.expand_path(@fixture_root || DEFAULT_FIXTURE_ROOT, Dir.pwd)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One fixture, as an absolute path. An absolute path is returned as it
|
|
91
|
+
# stands, so a project that generates a fixture into a temporary
|
|
92
|
+
# directory can name it directly.
|
|
93
|
+
#
|
|
94
|
+
# Missing files are refused here rather than at `File.binread`, because
|
|
95
|
+
# a conformance run against a fixture that is not there should say which
|
|
96
|
+
# fixture and where it looked -- the two facts needed to fix it.
|
|
97
|
+
sig { params(path: T.untyped).returns(String) }
|
|
98
|
+
def fixture_path(path)
|
|
99
|
+
name = path.to_s
|
|
100
|
+
resolved = Pathname.new(name).absolute? ? name : File.expand_path(name, fixture_root)
|
|
101
|
+
return resolved if File.file?(resolved)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"no fixture at #{resolved}. Paths are resolved under #{fixture_root} -- " \
|
|
105
|
+
"set ActiveSanction::Testing.fixture_root if yours live somewhere else."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless defined?(RSpec)
|
|
112
|
+
raise LoadError,
|
|
113
|
+
"active_sanction/testing defines RSpec shared example groups, and RSpec is not loaded. " \
|
|
114
|
+
"Require it from your spec_helper, after rspec itself."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
require "active_sanction/testing/storage_adapter_defaults"
|
|
118
|
+
require "active_sanction/testing/sanction_source"
|
|
119
|
+
require "active_sanction/testing/storage_adapter"
|
|
@@ -6,7 +6,7 @@ module ActiveSanction
|
|
|
6
6
|
# new source adapter, a storage fix or a documentation release -- none of
|
|
7
7
|
# which change what a name scores. MATCHER_VERSION, below, is the one that
|
|
8
8
|
# answers that question.
|
|
9
|
-
VERSION = "1.1.
|
|
9
|
+
VERSION = "1.1.1"
|
|
10
10
|
|
|
11
11
|
# Which matching pipeline scored a decision, stamped onto every MatchResult
|
|
12
12
|
# and bumped whenever a change to the normalizer, the index, the similarity
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_sanction
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marshall Shen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: csv
|
|
@@ -201,6 +201,10 @@ files:
|
|
|
201
201
|
- lib/active_sanction/sync.rb
|
|
202
202
|
- lib/active_sanction/sync/report.rb
|
|
203
203
|
- lib/active_sanction/sync/result.rb
|
|
204
|
+
- lib/active_sanction/testing.rb
|
|
205
|
+
- lib/active_sanction/testing/sanction_source.rb
|
|
206
|
+
- lib/active_sanction/testing/storage_adapter.rb
|
|
207
|
+
- lib/active_sanction/testing/storage_adapter_defaults.rb
|
|
204
208
|
- lib/active_sanction/validator_store.rb
|
|
205
209
|
- lib/active_sanction/validator_store/file_system.rb
|
|
206
210
|
- lib/active_sanction/validator_store/memory.rb
|