-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathidentitycode_test.go
More file actions
498 lines (457 loc) · 20.2 KB
/
Copy pathidentitycode_test.go
File metadata and controls
498 lines (457 loc) · 20.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
package identitycode
import (
"errors"
"strings"
"testing"
)
// Every identity code in this file is assembled from its parts at run time.
// An identity-code-shaped literal in the source is indistinguishable from a
// real person's code to a reader and from a credential to a secret scanner;
// the groups below carry no shape of their own, and the digits differ between
// groups so a test can tell a right split from a wrong one.
const (
lvHead = "123456" // leading group of a Latvian personal code (six digits)
lvTail = "78901" // its serial (five digits)
eeHead = "234567" // an Estonian personal code, which is written unsplit
eeTail = "89012"
ntrHead = "345678" // a Latvian trade-register number, also eleven digits
ntrTail = "90123"
ltHead = "456789" // a Lithuanian personal code
ltTail = "01234"
pasBody = "AB987654" // a passport number: letters and digits
// An identifier that begins with five letters, which is the shape of an
// identity type and a country and so cannot be read back on its own.
prefixLike = "PASSK123"
)
// lvSpelt is the Latvian personal code as a person writes it.
func lvSpelt() string { return lvHead + "-" + lvTail }
// lvStored is the one spelling the platform stores it as.
func lvStored() string { return "PNOLV-" + lvHead + lvTail }
// The transformation, spelling by spelling. These rows are the normative
// vector list: each is a way the same code really arrives, and every one of
// them has to become the same stored value or the person is two people.
func TestCanonicalSpellings(t *testing.T) {
cases := []struct {
name string
raw string
country string
want string
}{
{"card certificate, hyphenated", "PNOLV-" + lvHead + "-" + lvTail, "", lvStored()},
{"provider claim, already stored form", lvStored(), "", lvStored()},
{"typed with the separator, country from the screen", lvSpelt(), "LV", lvStored()},
{"typed without the separator", lvHead + lvTail, "LV", lvStored()},
{"lower case and surrounding space", " pnolv-" + lvHead + "-" + lvTail + " ", "", lvStored()},
{"dots and slashes as separators", lvHead + "." + lvTail, "LV", lvStored()},
{"an Estonian code, written unsplit", "PNOEE-" + eeHead + eeTail, "", "PNOEE-" + eeHead + eeTail},
{"an organisation, from its trade register", "NTRLV-" + ntrHead + ntrTail, "", "NTRLV-" + ntrHead + ntrTail},
{"a passport number", "PASSK-" + pasBody, "", "PASSK-" + pasBody},
{"a passport number written in lower case", strings.ToLower("PASSK-" + pasBody), "", "PASSK-" + pasBody},
{"the cross-border login form", "LV/LV/" + lvSpelt(), "", lvStored()},
{"the cross-border form, foreign identifier", "LT/LV/" + ltHead + ltTail, "", "PNOLT-" + ltHead + ltTail},
{"the cross-border form wrapping a prefixed value", "LV/LV/PNOLV-" + lvHead + "-" + lvTail, "", lvStored()},
{"a country hint is only a hint", lvSpelt(), "lv", lvStored()},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Canonical(c.raw, c.country)
if err != nil {
t.Fatalf("Canonical(%q, %q) refused it: %v", c.raw, c.country, err)
}
if got != c.want {
t.Fatalf("Canonical(%q, %q) = %q, want %q", c.raw, c.country, got, c.want)
}
})
}
}
// Every identity type this package recognises, crossed with both spellings a
// prefixed code arrives in: with the national separator and without it.
//
// It is a MATRIX rather than a row per type on purpose. The separator handling
// and the type handling are independent in the code, so a table that crosses
// them catches a rule that was only ever written for personal numbers — and
// until this test, two of the five types had no vector at all here. A tax
// number appeared in neither this suite nor the store's, and an identity card
// only in a display case, so a code arriving under either was in practice
// untested on the one path that decides who a person is.
//
// Each type carries its own digits, so a wrong answer names the type it came
// from instead of matching another row by accident.
func TestEveryIdentityTypeInBothPrefixedSpellings(t *testing.T) {
types := []struct {
semantics string
country string
head string
tail string
person bool
what string
}{
{"PNO", "LV", lvHead, lvTail, true, "a national personal number"},
{"NTR", "LV", ntrHead, ntrTail, false, "an organisation's trade register number"},
{"PAS", "SK", "AB98", "7654", true, "a passport number: letters and digits"},
{"IDC", "BE", "590082", "394654", true, "a national identity card number"},
{"TIN", "EE", "765432", "10987", true, "a tax identification number"},
}
for _, ty := range types {
want := ty.semantics + ty.country + "-" + ty.head + ty.tail
spellings := []struct{ name, raw string }{
{"with the national separator", ty.semantics + ty.country + "-" + ty.head + "-" + ty.tail},
{"without the separator", ty.semantics + ty.country + "-" + ty.head + ty.tail},
{"in lower case", strings.ToLower(ty.semantics + ty.country + "-" + ty.head + "-" + ty.tail)},
}
for _, sp := range spellings {
t.Run(ty.semantics+"/"+sp.name, func(t *testing.T) {
// The hint contradicts the value on every row, because a country
// the value states must win for every type and not only for the
// one the rule was written against.
got, err := Canonical(sp.raw, "ZZ")
if err != nil {
t.Fatalf("Canonical(%q) refused %s: %v", sp.raw, ty.what, err)
}
if got != want {
t.Fatalf("Canonical(%q) = %q, want %q (%s)", sp.raw, got, want, ty.what)
}
// And it comes apart again into the three things it is made of,
// which is what every caller reading a stored value depends on.
c, err := Parse(got)
if err != nil {
t.Fatalf("Parse(%q): %v", got, err)
}
if c.Semantics != ty.semantics || c.Country != ty.country || c.Identifier != ty.head+ty.tail {
t.Fatalf("Parse(%q) = %+v, want %s/%s/%s", got, c, ty.semantics, ty.country, ty.head+ty.tail)
}
// And it says whether it belongs to a person, on the code the
// package itself produced rather than on a hand-built one — this
// is the answer a caller matching a code against a login acts on,
// and it must not depend on how the code was spelt on arrival.
if c.IsNaturalPerson() != ty.person {
t.Fatalf("Parse(%q).IsNaturalPerson() = %v, want %v (%s)", got, c.IsNaturalPerson(), ty.person, ty.what)
}
if Key(got) != want {
t.Fatalf("Key(%q) = %q, want %q", got, Key(got), want)
}
// Nothing but a Latvian personal number is ever shown split, so
// for every other row the whole code — type and country included —
// is what a person sees. That is the guard against a namesake and
// an organisation rendering as one string.
if shown := Display(got); ty.semantics != SemanticsPersonalNumber || ty.country != "LV" {
if shown != want {
t.Fatalf("Display(%q) = %q, want the whole code %q for %s", got, shown, want, ty.what)
}
}
})
}
}
}
// The natural/legal split, asserted against both of the standard's lists
// rather than against the handful of types this package currently admits.
//
// Both halves are here on purpose. The question is asked in order to REFUSE, so
// a predicate that answered true for everything would satisfy a test of the
// natural list alone and let an organisation through every door that asks — and
// one that answered false for everything would satisfy the legal list and turn
// every person away. Only the two together say the split is the standard's.
func TestIsNaturalPersonFollowsTheStandardsTwoLists(t *testing.T) {
// [ETSI EN 319 412-1 V1.7.1 §5.1.3] — carried in a certificate's serialNumber.
natural := []struct{ semantics, what string }{
{"PAS", "a passport number"},
{"IDC", "a national identity card number"},
{"PNO", "a national personal number"},
{"TAX", "a personal tax reference number, the deprecated spelling of TIN"},
{"TIN", "a tax identification number"},
{"EID", "an electronic identification means"},
}
// [ETSI EN 319 412-1 V1.7.1 §5.1.4] — carried in an organizationIdentifier.
legal := []struct{ semantics, what string }{
{"VAT", "a national value added tax number"},
{"NTR", "a national trade register number"},
{"PSD", "a payment service provider's authorisation number"},
{"LEI", "a global legal entity identifier"},
{"EOR", "an economic operator's registration and identification number"},
{"EXC", "an excise number"},
}
for _, ty := range natural {
t.Run("natural/"+ty.semantics, func(t *testing.T) {
c := Code{Semantics: ty.semantics, Country: "LV", Identifier: lvHead + lvTail}
if !c.IsNaturalPerson() {
t.Fatalf("%s (%s) is a natural person's identity type, and was not read as one", ty.semantics, ty.what)
}
})
}
for _, ty := range legal {
t.Run("legal/"+ty.semantics, func(t *testing.T) {
c := Code{Semantics: ty.semantics, Country: "LV", Identifier: ntrHead + ntrTail}
if c.IsNaturalPerson() {
t.Fatalf("%s (%s) identifies an organisation, and was read as a person", ty.semantics, ty.what)
}
})
}
// And everything that is neither list, because the caller asking is about to
// let somebody in or turn them away and there is no third answer to give it.
for _, c := range []struct {
name string
code Code
}{
{"nothing at all", Code{}},
{"a locally defined type, which the standard leaves to a national definition", Code{Semantics: "EI", Country: "SE", Identifier: lvHead + lvTail}},
{"a type no edition of the standard defines", Code{Semantics: "ZZZ", Country: "LV", Identifier: lvHead + lvTail}},
{"the type in lower case, which is not how a stored code is written", Code{Semantics: "pno", Country: "LV", Identifier: lvHead + lvTail}},
} {
t.Run("neither/"+c.name, func(t *testing.T) {
if c.code.IsNaturalPerson() {
t.Fatalf("%s was read as a natural person", c.name)
}
})
}
}
// A country in the value always beats the hint. A partner's system may or may
// not put the country on the wire, and when it does, that is the fact about
// the person — our screen's default is not.
func TestPrefixInTheValueWinsOverTheHint(t *testing.T) {
got, err := Canonical("PNOLT-"+ltHead+ltTail, "LV")
if err != nil {
t.Fatalf("refused a Lithuanian code carrying its own country: %v", err)
}
if want := "PNOLT-" + ltHead + ltTail; got != want {
t.Fatalf("Canonical = %q, want %q — the hint must not overwrite a country the value states", got, want)
}
}
// The country is part of the identity. Two people in two countries can hold
// the same digits, and cross-border co-signing means both can reach the same
// document, so stripping the country would hand one person the other's papers.
func TestTheCountryIsPartOfTheIdentity(t *testing.T) {
lv, err := Canonical(lvHead+lvTail, "LV")
if err != nil {
t.Fatal(err)
}
ee, err := Canonical(lvHead+lvTail, "EE")
if err != nil {
t.Fatal(err)
}
if lv == ee {
t.Fatalf("the same digits in two countries produced one identity (%q)", lv)
}
if Key(lv) == Key(ee) {
t.Fatalf("the same digits in two countries compare equal (%q)", Key(lv))
}
}
// What is refused, and why each refusal exists rather than a guess.
func TestCanonicalRefusals(t *testing.T) {
cases := []struct {
name string
raw string
country string
want error
}{
{"nothing at all", "", "", ErrEmpty},
{"only separators", " - . / ", "LV", ErrMalformed},
{"a bare code and no country anywhere", lvHead + lvTail, "", ErrCountryRequired},
{"a country hint that is not a country", lvHead + lvTail, "LVA", ErrCountryInvalid},
{"a country hint that is not letters", lvHead + lvTail, "L1", ErrCountryInvalid},
{"an identity type we do not recognise", "VATLV-" + ntrHead + ntrTail, "LV", ErrUnknownSemantics},
{"a national eID type, not in the recognised set", "EIDLV-" + lvHead + lvTail, "LV", ErrUnknownSemantics},
{"a locally defined identity type", "EI:SE-" + lvHead + lvTail, "LV", ErrUnknownSemantics},
{"a prefix with no identifier behind it", "PNOLV-", "LV", ErrMalformed},
{"characters that are not letters or digits", "PNOLV-" + lvHead + "#" + lvTail, "", ErrMalformed},
{"a subdivision in the country field", "NTRDE+HE-" + ntrHead, "DE", ErrMalformed},
{"a prefix written without its hyphen", "PNOLV" + lvHead + lvTail, "LV", ErrAmbiguous},
{"an unrecognised prefix written without its hyphen", "VATLV" + ntrHead + ntrTail, "LV", ErrAmbiguous},
{"a look-alike letter from another alphabet", "PNOLV-" + lvHead + "Х" + lvTail, "", ErrMalformed},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Canonical(c.raw, c.country)
if !errors.Is(err, c.want) {
t.Fatalf("Canonical(%q, %q) = %q, %v — want the refusal %v", c.raw, c.country, got, err, c.want)
}
if got != "" {
t.Fatalf("a refused code still returned a value (%q) — a caller that ignores the error would store it", got)
}
})
}
}
// Nothing this package produces may ever need producing twice. The store's own
// constraint is exactly this assertion — a stored value has to equal its own
// canonical form — so a value that is not already canonical is a value the
// database will refuse at the door.
func TestCanonicalIsAlreadyCanonical(t *testing.T) {
raws := []struct{ raw, country string }{
{lvSpelt(), "LV"},
{lvHead + lvTail, "LV"},
{"PNOLV-" + lvHead + "-" + lvTail, ""},
{"PNOEE-" + eeHead + eeTail, ""},
{"NTRLV-" + ntrHead + ntrTail, ""},
{"PASSK-" + pasBody, ""},
{"LV/LV/" + lvSpelt(), ""},
}
for _, r := range raws {
stored, err := Canonical(r.raw, r.country)
if err != nil {
t.Fatalf("Canonical(%q, %q): %v", r.raw, r.country, err)
}
if stored == "" {
t.Fatalf("Canonical(%q, %q) accepted the code and produced nothing", r.raw, r.country)
}
again, err := Canonical(stored, "")
if err != nil {
t.Fatalf("a stored value was refused on its way back in: Canonical(%q, \"\"): %v", stored, err)
}
if again != stored {
t.Fatalf("Canonical is not settled: %q became %q", stored, again)
}
if _, err := Parse(stored); err != nil {
t.Fatalf("Canonical produced a value it cannot take apart (%q): %v", stored, err)
}
}
}
// Key is for the caller holding a value of unknown provenance that it wants to
// compare rather than store: every spelling of one code answers with the same
// key, and a value it cannot make sense of stays distinct from every other.
func TestKey(t *testing.T) {
spellings := []string{
"PNOLV-" + lvHead + "-" + lvTail,
lvStored(),
" pnolv-" + lvHead + lvTail + " ",
"LV/LV/" + lvSpelt(),
}
want := Key(lvStored())
if want != lvStored() {
t.Fatalf("Key of a stored code = %q, want the code itself (%q)", want, lvStored())
}
for _, s := range spellings {
if got := Key(s); got != want {
t.Fatalf("Key(%q) = %q, want %q", s, got, want)
}
}
// A bare code has no country, so Key cannot know whose it is. It must not
// answer with a Latvian identity, and it must not answer with something a
// real identity could collide with.
bare := Key(lvSpelt())
if bare == want {
t.Fatalf("Key guessed a country for a bare code (%q)", bare)
}
// Two values it cannot make sense of stay two values. Collapsing them
// would be the same failure as the one this package exists to end.
a, b := Key("VATLV-"+ntrHead), Key("VATLV-"+ntrTail)
if a == b {
t.Fatalf("two different unrecognised codes share one key (%q)", a)
}
if lower := Key(strings.ToLower("VATLV-" + ntrHead)); lower != a {
t.Fatalf("Key(%q) = %q, want %q — case is not part of an identity", strings.ToLower("VATLV-"+ntrHead), lower, a)
}
for _, k := range []string{bare, a, b} {
if strings.Contains(k, "-") {
t.Fatalf("the key of an unrecognised value (%q) carries a hyphen, so it can collide with a stored identity", k)
}
}
}
// What a person sees is their own national code, written the way their country
// writes it — never the prefix the platform keys on.
func TestDisplay(t *testing.T) {
cases := []struct{ name, stored, want string }{
{"a Latvian personal code is split", lvStored(), lvSpelt()},
{"a country with no known spelling keeps its whole code", "PNOEE-" + eeHead + eeTail, "PNOEE-" + eeHead + eeTail},
{"an organisation number is not split, and stays an organisation number", "NTRLV-" + ntrHead + ntrTail, "NTRLV-" + ntrHead + ntrTail},
{"a passport number is shown as it is stored", "PASSK-" + pasBody, "PASSK-" + pasBody},
{"something unparseable is shown unchanged", "not a code", "not a code"},
{"a code of the wrong length for its country is not forced", "PNOLV-" + lvHead, "PNOLV-" + lvHead},
{"an identifier that reads like a prefix keeps the code it belongs to", "PNOLV-" + prefixLike, "PNOLV-" + prefixLike},
{"a personal number that is not all digits is not split", "PNOLV-" + lvHead + "7890A", "PNOLV-" + lvHead + "7890A"},
}
// The point of the whole function, asserted directly: five distinct principals
// holding the SAME digits must not render as one string. A person, a foreign
// namesake and an organisation used to be indistinguishable here.
same := []string{
"PNOEE-" + eeHead + eeTail,
"PNOLT-" + eeHead + eeTail,
"NTREE-" + eeHead + eeTail,
"PASEE-" + eeHead + eeTail,
"IDCEE-" + eeHead + eeTail,
}
seen := make(map[string]string, len(same))
for _, stored := range same {
shown := Display(stored)
if other, clash := seen[shown]; clash {
t.Fatalf("%q and %q are different principals but both display as %q", other, stored, shown)
}
seen[shown] = stored
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Display(c.stored); got != c.want {
t.Fatalf("Display(%q) = %q, want %q", c.stored, got, c.want)
}
})
}
}
// Parse accepts the stored spelling and nothing else: a value read from a
// store is already canonical, so anything else is a caller handing Parse a raw
// input where a stored one belongs.
func TestParse(t *testing.T) {
c, err := Parse(lvStored())
if err != nil {
t.Fatalf("Parse(%q): %v", lvStored(), err)
}
if c.Semantics != "PNO" || c.Country != "LV" || c.Identifier != lvHead+lvTail {
t.Fatalf("Parse(%q) = %+v", lvStored(), c)
}
if c.String() != lvStored() {
t.Fatalf("String() = %q, want %q", c.String(), lvStored())
}
for _, bad := range []string{"", lvSpelt(), "pnolv-" + lvHead + lvTail, "PNOLV-" + lvHead + "-" + lvTail, "VATLV-" + ntrHead} {
if _, err := Parse(bad); err == nil {
t.Fatalf("Parse(%q) accepted a value that is not the stored spelling", bad)
}
}
}
// The property a lost person is made of: a code shown to somebody and typed
// straight back in has to key to the identity it came from. It is asserted for
// every national personal number, which is the one kind of code a person types.
// A code of another type legitimately does not survive the trip — Display drops
// the identity type, and a retyped organisation number is read as the personal
// number the typist entered, because that is what typing one into a personal
// code field means.
func FuzzRoundTrip(f *testing.F) {
f.Add("PNOLV-"+lvHead+"-"+lvTail, "")
f.Add(lvSpelt(), "LV")
f.Add(lvHead+lvTail, "LV")
f.Add("PNOEE-"+eeHead+eeTail, "")
f.Add("NTRLV-"+ntrHead+ntrTail, "")
f.Add("LV/LV/"+lvSpelt(), "")
f.Add("", "")
f.Add("-", "LV")
f.Fuzz(func(t *testing.T, raw, country string) {
stored, err := Canonical(raw, country)
if err != nil {
if stored != "" {
t.Fatalf("Canonical(%q, %q) refused with a value (%q)", raw, country, stored)
}
return
}
// Whatever it accepted, the store must accept too: the value equals its
// own canonical form, and it can be taken apart again.
if stored == "" {
t.Fatalf("Canonical(%q, %q) accepted the code and produced nothing", raw, country)
}
c, err := Parse(stored)
if err != nil {
t.Fatalf("Canonical(%q, %q) = %q, which Parse rejects: %v", raw, country, stored, err)
}
if again, err := Canonical(stored, ""); err != nil || again != stored {
t.Fatalf("Canonical(%q) = %q, %v — a stored value must come back unchanged", stored, again, err)
}
if Key(stored) != stored {
t.Fatalf("Key(%q) = %q — a stored value is its own key", stored, Key(stored))
}
if c.Semantics != "PNO" {
return
}
retyped, err := Canonical(Display(stored), c.Country)
if err != nil {
t.Fatalf("a person retyping what they were shown (%q from %q) was refused: %v", Display(stored), stored, err)
}
if Key(retyped) != Key(stored) {
t.Fatalf("retyping %q as %q lost the person: %q vs %q", stored, Display(stored), Key(retyped), Key(stored))
}
})
}