@smileid/usesmileid 12.0.1 → 12.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/dist/camera/camera_defaults.d.ts +2 -0
- package/dist/camera/camera_defaults.d.ts.map +1 -1
- package/dist/camera/camera_defaults.js +2 -0
- package/dist/camera/camera_defaults.js.map +1 -1
- package/dist/core/exception/flow_exceptions.d.ts +4 -2
- package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
- package/dist/core/exception/flow_exceptions.js +3 -2
- package/dist/core/exception/flow_exceptions.js.map +1 -1
- package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
- package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
- package/dist/core/exception/missing_job_type_exception.js +2 -4
- package/dist/core/exception/missing_job_type_exception.js.map +1 -1
- package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
- package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
- package/dist/data/api/smile_id_api.d.ts +4 -0
- package/dist/data/api/smile_id_api.d.ts.map +1 -1
- package/dist/data/api/smile_id_api.js +30 -27
- package/dist/data/api/smile_id_api.js.map +1 -1
- package/dist/data/model/job_request.d.ts +28 -12
- package/dist/data/model/job_request.d.ts.map +1 -1
- package/dist/data/model/token_payload.d.ts +78 -0
- package/dist/data/model/token_payload.d.ts.map +1 -0
- package/dist/data/model/token_payload.js +132 -0
- package/dist/data/model/token_payload.js.map +1 -0
- package/dist/data/model/user_details_payload.d.ts +39 -0
- package/dist/data/model/user_details_payload.d.ts.map +1 -0
- package/dist/data/model/user_details_payload.js +53 -0
- package/dist/data/model/user_details_payload.js.map +1 -0
- package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
- package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
- package/dist/data/util/use_smile_id_token_decoder.js +20 -8
- package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
- package/dist/l10n/strings_en.d.ts +4 -1
- package/dist/l10n/strings_en.d.ts.map +1 -1
- package/dist/l10n/strings_en.js +4 -1
- package/dist/l10n/strings_en.js.map +1 -1
- package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
- package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
- package/dist/l10n/use_smile_id_localizations.js +59 -3
- package/dist/l10n/use_smile_id_localizations.js.map +1 -1
- package/dist/metadata/metadata_configuration_mappers.js +1 -1
- package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
- package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
- package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
- package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
- package/dist/presentation/components/use_smile_id_button.js +8 -2
- package/dist/presentation/components/use_smile_id_button.js.map +1 -1
- package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
- package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
- package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
- package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
- package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
- package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
- package/dist/presentation/flow/flow_navigation_manager.js +77 -45
- package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
- package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
- package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
- package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
- package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
- package/dist/presentation/flow/use_smile_id_flow.js +35 -8
- package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
- package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
- package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
- package/dist/presentation/flow/validation/flow_validator.js +19 -27
- package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
- package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
- package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
- package/dist/presentation/flow/validation/job_type_validator.js +52 -27
- package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
- package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
- package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
- package/dist/presentation/flow/validation/user_details_validator.js +40 -0
- package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
- package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
- package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
- package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
- package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
- package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
- package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/react_native_mock.tsx +22 -0
- package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
- package/src/camera/__tests__/camera_permissions.test.ts +72 -0
- package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
- package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
- package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
- package/src/camera/camera_defaults.ts +3 -0
- package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
- package/src/core/exception/flow_exceptions.test.ts +10 -0
- package/src/core/exception/flow_exceptions.ts +3 -2
- package/src/core/exception/missing_job_type_exception.ts +2 -4
- package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
- package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
- package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
- package/src/data/api/smile_id_api.ts +36 -49
- package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
- package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
- package/src/data/model/__tests__/token_payload.test.ts +224 -0
- package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
- package/src/data/model/job_request.ts +27 -12
- package/src/data/model/token_payload.ts +182 -0
- package/src/data/model/user_details_payload.ts +84 -0
- package/src/data/repository/__tests__/network_retries.test.ts +1 -1
- package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
- package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
- package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
- package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
- package/src/data/util/use_smile_id_token_decoder.ts +29 -8
- package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
- package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
- package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
- package/src/l10n/strings_en.ts +5 -1
- package/src/l10n/use_smile_id_localizations.ts +80 -3
- package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
- package/src/metadata/__tests__/geolocation.test.ts +150 -0
- package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
- package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
- package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
- package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
- package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
- package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
- package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
- package/src/metadata/metadata_configuration_mappers.ts +1 -1
- package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
- package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
- package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
- package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
- package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
- package/src/presentation/components/use_smile_id_button.tsx +7 -2
- package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
- package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
- package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
- package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
- package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
- package/src/presentation/flow/flow_navigation_manager.ts +102 -92
- package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
- package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
- package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
- package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
- package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
- package/src/presentation/flow/validation/flow_validator.ts +30 -31
- package/src/presentation/flow/validation/job_type_validator.ts +73 -39
- package/src/presentation/flow/validation/user_details_validator.ts +53 -0
- package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
- package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
- package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
- package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
- package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
- package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
- package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import Module from "node:module";
|
|
3
|
+
|
|
4
|
+
import { SigningCrypto, base64Encode, utf8Bytes } from "../signing_crypto";
|
|
5
|
+
import { SmileIDCryptoError } from "../smile_id_crypto_error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Provider resolution for request signing. Node supplies a global
|
|
9
|
+
* `crypto.subtle`, so the on-device path — the lazy
|
|
10
|
+
* `require("react-native-quick-crypto")` — is only reachable with the
|
|
11
|
+
* global removed and the CommonJS loader intercepted. `resolveSubtle`
|
|
12
|
+
* caches the first provider it finds, so these tests are ordered:
|
|
13
|
+
* the not-found case runs before anything is cached.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type SubtleStub = {
|
|
17
|
+
importKey: ReturnType<typeof vi.fn>;
|
|
18
|
+
deriveBits: ReturnType<typeof vi.fn>;
|
|
19
|
+
sign: ReturnType<typeof vi.fn>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Loader = (id: string, parent: unknown, isMain: boolean) => unknown;
|
|
23
|
+
const loaderHost = Module as unknown as { _load: Loader };
|
|
24
|
+
const originalLoad: Loader = loaderHost._load;
|
|
25
|
+
|
|
26
|
+
const QUICK_CRYPTO = "react-native-quick-crypto";
|
|
27
|
+
|
|
28
|
+
let quickCryptoExports: unknown | null = null;
|
|
29
|
+
let cryptoDescriptor: PropertyDescriptor | undefined;
|
|
30
|
+
|
|
31
|
+
function makeSubtle(): SubtleStub {
|
|
32
|
+
return {
|
|
33
|
+
importKey: vi.fn(async () => ({ handle: "key" })),
|
|
34
|
+
deriveBits: vi.fn(async () => new Uint8Array(32).fill(7).buffer),
|
|
35
|
+
sign: vi.fn(async () => new Uint8Array(32).fill(9).buffer),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
// Hermes exposes no global `crypto.subtle`; reproduce that here so the
|
|
41
|
+
// native-provider branch is the one under test.
|
|
42
|
+
cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
|
43
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
44
|
+
value: undefined,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
});
|
|
48
|
+
vi.spyOn(loaderHost, "_load").mockImplementation((id, parent, isMain) => {
|
|
49
|
+
if (id === QUICK_CRYPTO) {
|
|
50
|
+
if (quickCryptoExports === null) {
|
|
51
|
+
const error = new Error(
|
|
52
|
+
`Cannot find module '${QUICK_CRYPTO}'`,
|
|
53
|
+
) as Error & {
|
|
54
|
+
code: string;
|
|
55
|
+
};
|
|
56
|
+
error.code = "MODULE_NOT_FOUND";
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return quickCryptoExports;
|
|
60
|
+
}
|
|
61
|
+
return originalLoad.call(Module, id, parent, isMain);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(() => {
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
if (cryptoDescriptor) {
|
|
68
|
+
Object.defineProperty(globalThis, "crypto", cryptoDescriptor);
|
|
69
|
+
} else {
|
|
70
|
+
delete (globalThis as { crypto?: unknown }).crypto;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("SigningCrypto — encoding helpers", () => {
|
|
75
|
+
it("UTF-8 encodes multi-byte text", () => {
|
|
76
|
+
expect(Array.from(utf8Bytes("é"))).toEqual([0xc3, 0xa9]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("base64-encodes with the standard alphabet and padding", () => {
|
|
80
|
+
expect(base64Encode(utf8Bytes("any carnal pleasure"))).toBe(
|
|
81
|
+
"YW55IGNhcm5hbCBwbGVhc3VyZQ==",
|
|
82
|
+
);
|
|
83
|
+
expect(base64Encode(utf8Bytes("any carnal pleasur"))).toBe(
|
|
84
|
+
"YW55IGNhcm5hbCBwbGVhc3Vy",
|
|
85
|
+
);
|
|
86
|
+
expect(base64Encode(utf8Bytes("any carnal pleasu"))).toBe(
|
|
87
|
+
"YW55IGNhcm5hbCBwbGVhc3U=",
|
|
88
|
+
);
|
|
89
|
+
expect(base64Encode(new Uint8Array(0))).toBe("");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Ordered: the not-found case must run before a provider is cached.
|
|
94
|
+
describe("SigningCrypto — no provider available", () => {
|
|
95
|
+
it("fails key derivation with actionable install guidance", async () => {
|
|
96
|
+
quickCryptoExports = null;
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
SigningCrypto.deriveKey("secret", new Uint8Array([1, 2, 3])),
|
|
100
|
+
).rejects.toBeInstanceOf(SmileIDCryptoError);
|
|
101
|
+
await expect(
|
|
102
|
+
SigningCrypto.deriveKey("secret", new Uint8Array([1, 2, 3])),
|
|
103
|
+
).rejects.toThrow(/react-native-quick-crypto/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("fails MAC computation the same way", async () => {
|
|
107
|
+
quickCryptoExports = null;
|
|
108
|
+
|
|
109
|
+
await expect(
|
|
110
|
+
SigningCrypto.hmacSha256(new Uint8Array(32), new Uint8Array([1])),
|
|
111
|
+
).rejects.toThrow(/No Web Crypto provider available/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("SigningCrypto — native provider", () => {
|
|
116
|
+
const subtle = makeSubtle();
|
|
117
|
+
|
|
118
|
+
beforeAll(() => {
|
|
119
|
+
// Deepest of the four export shapes the package is read across.
|
|
120
|
+
quickCryptoExports = { default: { webcrypto: { subtle } } };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("derives a 32-byte key through the native subtle", async () => {
|
|
124
|
+
const salt = new Uint8Array([9, 8, 7]);
|
|
125
|
+
const key = await SigningCrypto.deriveKey("secret", salt);
|
|
126
|
+
|
|
127
|
+
expect(key).toHaveLength(32);
|
|
128
|
+
expect(subtle.importKey).toHaveBeenCalledWith(
|
|
129
|
+
"raw",
|
|
130
|
+
expect.anything(),
|
|
131
|
+
"PBKDF2",
|
|
132
|
+
false,
|
|
133
|
+
["deriveBits"],
|
|
134
|
+
);
|
|
135
|
+
const deriveArgs = subtle.deriveBits.mock.calls[0] as [
|
|
136
|
+
{ name: string; iterations: number; hash: string; salt: ArrayBuffer },
|
|
137
|
+
unknown,
|
|
138
|
+
number,
|
|
139
|
+
];
|
|
140
|
+
expect(deriveArgs[0].name).toBe("PBKDF2");
|
|
141
|
+
expect(deriveArgs[0].iterations).toBe(200000);
|
|
142
|
+
expect(deriveArgs[2]).toBe(256);
|
|
143
|
+
// The salt crosses as its exact bytes, not the whole backing buffer.
|
|
144
|
+
expect(Array.from(new Uint8Array(deriveArgs[0].salt))).toEqual([9, 8, 7]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("computes an HMAC through the native subtle", async () => {
|
|
148
|
+
const mac = await SigningCrypto.hmacSha256(
|
|
149
|
+
new Uint8Array(32).fill(1),
|
|
150
|
+
utf8Bytes("payload"),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(mac).toHaveLength(32);
|
|
154
|
+
expect(subtle.sign).toHaveBeenCalledWith(
|
|
155
|
+
"HMAC",
|
|
156
|
+
expect.anything(),
|
|
157
|
+
expect.anything(),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("passes only the view's own bytes when the input is a subarray", async () => {
|
|
162
|
+
const backing = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
163
|
+
subtle.sign.mockClear();
|
|
164
|
+
await SigningCrypto.hmacSha256(new Uint8Array(32), backing.subarray(2, 5));
|
|
165
|
+
|
|
166
|
+
const dataBuffer = subtle.sign.mock.calls[0]?.[2] as ArrayBuffer;
|
|
167
|
+
expect(Array.from(new Uint8Array(dataBuffer))).toEqual([2, 3, 4]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("wraps a derivation failure in SmileIDCryptoError", async () => {
|
|
171
|
+
subtle.deriveBits.mockRejectedValueOnce(new Error("openssl exploded"));
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
SigningCrypto.deriveKey("secret", new Uint8Array([1])),
|
|
175
|
+
).rejects.toThrow(/Key derivation failed: .*openssl exploded/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rethrows an already-typed crypto error from deriveKey unchanged", async () => {
|
|
179
|
+
const typed = new SmileIDCryptoError("key material rejected");
|
|
180
|
+
subtle.importKey.mockRejectedValueOnce(typed);
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
SigningCrypto.deriveKey("secret", new Uint8Array([1])),
|
|
184
|
+
).rejects.toBe(typed);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("wraps a MAC failure in SmileIDCryptoError", async () => {
|
|
188
|
+
subtle.sign.mockRejectedValueOnce(new Error("bad key handle"));
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
SigningCrypto.hmacSha256(new Uint8Array(32), new Uint8Array([1])),
|
|
192
|
+
).rejects.toThrow(/MAC computation failed: .*bad key handle/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rethrows an already-typed crypto error from hmacSha256 unchanged", async () => {
|
|
196
|
+
const typed = new SmileIDCryptoError("hmac key rejected");
|
|
197
|
+
subtle.importKey.mockRejectedValueOnce(typed);
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
SigningCrypto.hmacSha256(new Uint8Array(32), new Uint8Array([1])),
|
|
201
|
+
).rejects.toBe(typed);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -3,6 +3,10 @@ import {
|
|
|
3
3
|
decodeSmileIDToken,
|
|
4
4
|
decodeSecurityPolicyBitCode,
|
|
5
5
|
} from "../use_smile_id_token_decoder";
|
|
6
|
+
import {
|
|
7
|
+
isTokenConsentComplete,
|
|
8
|
+
bindsRequiredUserDetails,
|
|
9
|
+
} from "../../model/token_payload";
|
|
6
10
|
|
|
7
11
|
// ---------------------------------------------------------------------------
|
|
8
12
|
// Helper — mint a structurally valid JWT with a caller-supplied payload.
|
|
@@ -243,9 +247,184 @@ describe("decodeSmileIDToken — parses a valid sdk_config claim", () => {
|
|
|
243
247
|
makeJwt({}),
|
|
244
248
|
makeJwt({ sdk_config: {} }),
|
|
245
249
|
makeJwt({ sdk_config: { selfie: { quality: 90 } } }),
|
|
250
|
+
makeJwt({ payload: "not-an-object" }),
|
|
251
|
+
makeJwt({ payload: { consent: { granted: "yes" } } }),
|
|
246
252
|
];
|
|
247
253
|
for (const input of inputs) {
|
|
248
254
|
expect(() => decodeSmileIDToken(input)).not.toThrow();
|
|
249
255
|
}
|
|
250
256
|
});
|
|
251
257
|
});
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Token-bound user details & consent — the top-level `payload` claim
|
|
261
|
+
// (a sibling of `sdk_config`). Values are opaque vault references; only
|
|
262
|
+
// key presence is meaningful. Consent subfields are plaintext.
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
describe("decodeSmileIDToken — payload claim (token-bound user details & consent)", () => {
|
|
265
|
+
it("parses a full payload claim into all four presence flags plus complete consent", () => {
|
|
266
|
+
const token = makeJwt({
|
|
267
|
+
payload: {
|
|
268
|
+
given_names: "pii_abc",
|
|
269
|
+
last_name: "pii_def",
|
|
270
|
+
email: "pii_ghi",
|
|
271
|
+
phone_number: "pii_jkl",
|
|
272
|
+
consent: {
|
|
273
|
+
granted: true,
|
|
274
|
+
granted_at: "2026-01-01T09:00:00.000Z",
|
|
275
|
+
notice_language: "EN",
|
|
276
|
+
notice_privacy_policy_url: "https://partner.test/privacy",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const tokenPayload = decodeSmileIDToken(token)?.tokenPayload;
|
|
281
|
+
expect(tokenPayload?.hasGivenNames).toBe(true);
|
|
282
|
+
expect(tokenPayload?.hasLastName).toBe(true);
|
|
283
|
+
expect(tokenPayload?.hasEmail).toBe(true);
|
|
284
|
+
expect(tokenPayload?.hasPhoneNumber).toBe(true);
|
|
285
|
+
expect(isTokenConsentComplete(tokenPayload?.consent)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("parses a subset claim — exact flags plus a partial (incomplete) consent", () => {
|
|
289
|
+
const token = makeJwt({
|
|
290
|
+
payload: {
|
|
291
|
+
email: "pii_ghi",
|
|
292
|
+
consent: {
|
|
293
|
+
granted: true,
|
|
294
|
+
granted_at: "2026-01-01T09:00:00.000Z",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
const tokenPayload = decodeSmileIDToken(token)?.tokenPayload;
|
|
299
|
+
expect(tokenPayload?.hasGivenNames).toBe(false);
|
|
300
|
+
expect(tokenPayload?.hasLastName).toBe(false);
|
|
301
|
+
expect(tokenPayload?.hasEmail).toBe(true);
|
|
302
|
+
expect(tokenPayload?.hasPhoneNumber).toBe(false);
|
|
303
|
+
expect(tokenPayload?.consent).toEqual({
|
|
304
|
+
granted: true,
|
|
305
|
+
grantedAt: "2026-01-01T09:00:00.000Z",
|
|
306
|
+
});
|
|
307
|
+
expect(isTokenConsentComplete(tokenPayload?.consent)).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("leaves tokenPayload undefined when the payload claim is absent", () => {
|
|
311
|
+
const token = makeJwt({ sub: "partner-1", iat: 1_700_000_000 });
|
|
312
|
+
const result = decodeSmileIDToken(token);
|
|
313
|
+
expect(result).toBeDefined();
|
|
314
|
+
expect(result?.tokenPayload).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("leaves tokenPayload undefined when the payload claim is not an object", () => {
|
|
318
|
+
for (const claim of ["a-string", ["an", "array"], 42, null]) {
|
|
319
|
+
const result = decodeSmileIDToken(makeJwt({ payload: claim }));
|
|
320
|
+
expect(result).toBeDefined();
|
|
321
|
+
expect(result?.tokenPayload).toBeUndefined();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("tolerates malformed consent and unknown keys inside the claim", () => {
|
|
326
|
+
const stringConsent = decodeSmileIDToken(
|
|
327
|
+
makeJwt({ payload: { consent: "granted", last_name: "pii_x" } }),
|
|
328
|
+
)?.tokenPayload;
|
|
329
|
+
expect(stringConsent?.consent).toBeUndefined();
|
|
330
|
+
expect(stringConsent?.hasLastName).toBe(true);
|
|
331
|
+
|
|
332
|
+
const arrayConsent = decodeSmileIDToken(
|
|
333
|
+
makeJwt({ payload: { consent: [true] } }),
|
|
334
|
+
)?.tokenPayload;
|
|
335
|
+
expect(arrayConsent?.consent).toBeUndefined();
|
|
336
|
+
|
|
337
|
+
const emptyConsent = decodeSmileIDToken(
|
|
338
|
+
makeJwt({ payload: { consent: {} } }),
|
|
339
|
+
)?.tokenPayload;
|
|
340
|
+
expect(emptyConsent?.consent).toBeUndefined();
|
|
341
|
+
|
|
342
|
+
const untrustedGranted = decodeSmileIDToken(
|
|
343
|
+
makeJwt({
|
|
344
|
+
payload: {
|
|
345
|
+
consent: { granted: "yes", notice_language: "EN" },
|
|
346
|
+
unknown_key: "ignored",
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
)?.tokenPayload;
|
|
350
|
+
expect(untrustedGranted?.consent?.granted).toBeUndefined();
|
|
351
|
+
expect(untrustedGranted?.consent?.noticeLanguage).toBe("EN");
|
|
352
|
+
expect(isTokenConsentComplete(untrustedGranted?.consent)).toBe(false);
|
|
353
|
+
|
|
354
|
+
const deniedGranted = decodeSmileIDToken(
|
|
355
|
+
makeJwt({ payload: { consent: { granted: false } } }),
|
|
356
|
+
)?.tokenPayload;
|
|
357
|
+
expect(deniedGranted?.consent).toBeUndefined();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("regression: a legacy token (sdk_config only) still parses sdkConfig with tokenPayload unset", () => {
|
|
361
|
+
const token = makeJwt({
|
|
362
|
+
partner_id: "002",
|
|
363
|
+
sdk_config: { selfie: { width: 640, height: 480, quality: 90 } },
|
|
364
|
+
});
|
|
365
|
+
const result = decodeSmileIDToken(token);
|
|
366
|
+
expect(result?.sdkConfig?.selfie?.width).toBe(640);
|
|
367
|
+
expect(result?.partnerId).toBe("002");
|
|
368
|
+
expect(result?.tokenPayload).toBeUndefined();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("parses payload and sdk_config claims side by side", () => {
|
|
372
|
+
const token = makeJwt({
|
|
373
|
+
sdk_config: { livenessImageCount: 5 },
|
|
374
|
+
payload: { given_names: "pii_abc" },
|
|
375
|
+
});
|
|
376
|
+
const result = decodeSmileIDToken(token);
|
|
377
|
+
expect(result?.sdkConfig?.livenessImageCount).toBe(5);
|
|
378
|
+
expect(result?.tokenPayload?.hasGivenNames).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("decodes a minted token that binds names and email but not phone_number", () => {
|
|
382
|
+
// Same deployment, partial binding: given_names, last_name and email
|
|
383
|
+
// only. The contact requirement is satisfied by the bound email alone.
|
|
384
|
+
const mintedToken =
|
|
385
|
+
"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleV8wMWtibWd6bXczZXloOW5jdjlqcG1kNDlqZiIsInR5cCI6IkpXVCJ9." +
|
|
386
|
+
"eyJhcGlfdXJsIjoiaHR0cHM6Ly92My10b2tlbi1jb25zZW50LXBheWxvYWQuYXBpLnNtaWxlaWQuY28vdjMiLCJrZXlfaWQiOiIyY2NjOWMyMy01Y2U3LTRkMGUtYWRkYy1lN2I1NGQ4YjkxZGQiLCJwYXlsb2FkIjp7ImNvbnNlbnQiOnsiZ3JhbnRlZCI6dHJ1ZSwiZ3JhbnRlZF9hdCI6IjIwMjYtMDEtMDFUMDk6MDA6MDAuMDAwWiIsIm5vdGljZV9sYW5ndWFnZSI6IkVOIiwibm90aWNlX3ByaXZhY3lfcG9saWN5X3VybCI6Imh0dHBzOi8vanVtYS5wcml2YWN5LXBvbGljeS5jb20ifSwiZW1haWwiOiJwaWlfMDFrcDNzOXQ3OGZleDkxcWg0NnlwODdyanEiLCJnaXZlbl9uYW1lcyI6InBpaV8wMWt6cm0xNzB0ZjY5cnE5NTM4cnpnZXR5eiIsImxhc3RfbmFtZSI6InBpaV8wMWtwM3M5dDdyZThocjZkZXI5cTNucHo3YyJ9LCJwYXJ0bmVyX2lkIjoiMTQ4MyIsInBvbGljeSI6MTUsImF1ZCI6InNtaWxlaWQtYXBpIiwiZXhwIjoxNzg2NDU5ODcxLCJpYXQiOjE3ODY0NTg5NzEsImlzcyI6InNtaWxlaWQtYXV0aCJ9." +
|
|
387
|
+
"s5BFthU2B1aKxYDYyyWYDmRpLoHibH34r2Lk4OS-wZQC8pmYROjWBiORx6n2_0srmxz_SA2eZo_FHyAr-ew_Zg";
|
|
388
|
+
|
|
389
|
+
const tokenPayload = decodeSmileIDToken(mintedToken)?.tokenPayload;
|
|
390
|
+
|
|
391
|
+
expect(tokenPayload?.hasGivenNames).toBe(true);
|
|
392
|
+
expect(tokenPayload?.hasLastName).toBe(true);
|
|
393
|
+
expect(tokenPayload?.hasEmail).toBe(true);
|
|
394
|
+
expect(tokenPayload?.hasPhoneNumber).toBe(false);
|
|
395
|
+
expect(bindsRequiredUserDetails(tokenPayload)).toBe(true);
|
|
396
|
+
expect(isTokenConsentComplete(tokenPayload?.consent)).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("decodes the payload claim from a token minted by the v3 token endpoint", () => {
|
|
400
|
+
// Captured from the /v3/token deployment: ES256-signed, extra top-level
|
|
401
|
+
// claims (api_url, key_id, aud, iss), every user-detail field bound as an
|
|
402
|
+
// opaque pii_ reference, a complete consent object, and in-claim identity
|
|
403
|
+
// fields (country, id_type, id_number, callback_url) the SDK deliberately
|
|
404
|
+
// ignores. The token expired minutes after minting and carries no raw
|
|
405
|
+
// PII — the decoder never checks exp (the server enforces token
|
|
406
|
+
// validity).
|
|
407
|
+
const mintedToken =
|
|
408
|
+
"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleV8wMWtibWd6bXczZXloOW5jdjlqcG1kNDlqZiIsInR5cCI6IkpXVCJ9." +
|
|
409
|
+
"eyJhcGlfdXJsIjoiaHR0cHM6Ly92My10b2tlbi1jb25zZW50LXBheWxvYWQuYXBpLnNtaWxlaWQuY28vdjMiLCJrZXlfaWQiOiIyY2NjOWMyMy01Y2U3LTRkMGUtYWRkYy1lN2I1NGQ4YjkxZGQiLCJwYXlsb2FkIjp7ImNvbnNlbnQiOnsiZ3JhbnRlZCI6dHJ1ZSwiZ3JhbnRlZF9hdCI6IjIwMjYtMDEtMDFUMDk6MDA6MDAuMDAwWiIsIm5vdGljZV9sYW5ndWFnZSI6IkVOIiwibm90aWNlX3ByaXZhY3lfcG9saWN5X3VybCI6Imh0dHBzOi8vanVtYS5wcml2YWN5LXBvbGljeS5jb20ifSwiY291bnRyeSI6Ik5HIiwiaWRfdHlwZSI6Ik5JTl9WMiIsImVtYWlsIjoicGlpXzAxa3Azczl0NzhmZXg5MXFoNDZ5cDg3cmpxIiwiZ2l2ZW5fbmFtZXMiOiJwaWlfMDFrenJtMTcwdGY2OXJxOTUzOHJ6Z2V0eXoiLCJpZF9udW1iZXIiOiJwaWlfMDFra2dhYTliNWZ2Z2EyMnllZXM1ZjRyNnQiLCJsYXN0X25hbWUiOiJwaWlfMDFrcDNzOXQ3cmU4aHI2ZGVyOXEzbnB6N2MiLCJwaG9uZV9udW1iZXIiOiJwaWlfMDFrenJtdHdkeGZlenJ0dGRmOXQzc3Rzem4iLCJjYWxsYmFja191cmwiOiJjYWxsYmFja18wMWt6cm10d2VmZTV5YWgzbmR2NW03MDc3ZyJ9LCJwYXJ0bmVyX2lkIjoiMTQ4MyIsInBvbGljeSI6MTUsImF1ZCI6InNtaWxlaWQtYXBpIiwiZXhwIjoxNzg2NDYwNzEyLCJpYXQiOjE3ODY0NTk4MTIsImlzcyI6InNtaWxlaWQtYXV0aCJ9." +
|
|
410
|
+
"cuZgo64eEt5vNvND_RvCVpJyTv4rqax9ebkLV4i4uI4raz6fBhazxspbaoErYC0wxwRmn5EhPAdAdDnNKI6tEQ";
|
|
411
|
+
|
|
412
|
+
const result = decodeSmileIDToken(mintedToken);
|
|
413
|
+
|
|
414
|
+
expect(result?.partnerId).toBe("1483");
|
|
415
|
+
expect(result?.tokenPayload).toEqual({
|
|
416
|
+
hasGivenNames: true,
|
|
417
|
+
hasLastName: true,
|
|
418
|
+
hasEmail: true,
|
|
419
|
+
hasPhoneNumber: true,
|
|
420
|
+
consent: {
|
|
421
|
+
granted: true,
|
|
422
|
+
grantedAt: "2026-01-01T09:00:00.000Z",
|
|
423
|
+
noticeLanguage: "EN",
|
|
424
|
+
noticePrivacyPolicyUrl: "https://juma.privacy-policy.com",
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
expect(bindsRequiredUserDetails(result?.tokenPayload)).toBe(true);
|
|
428
|
+
expect(isTokenConsentComplete(result?.tokenPayload?.consent)).toBe(true);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ZodError } from "zod";
|
|
2
2
|
|
|
3
3
|
import { type SdkConfig, sdkConfigSchema } from "../model/sdk_config";
|
|
4
|
+
import { type TokenPayload, parseTokenPayload } from "../model/token_payload";
|
|
4
5
|
|
|
5
6
|
// `atob` is a global available in React Native's Hermes runtime and in
|
|
6
7
|
// modern browsers. The `lib: ["ES2022"]` tsconfig omits DOM types, so the
|
|
@@ -28,20 +29,31 @@ export type DecodedToken = {
|
|
|
28
29
|
* `undefined` when the claim is absent.
|
|
29
30
|
*/
|
|
30
31
|
readonly partnerId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Token-bound user-detail presence flags and consent subfields from the
|
|
34
|
+
* top-level `payload` claim (a sibling of `sdk_config`). `undefined` when
|
|
35
|
+
* the claim is absent or not an object — legacy tokens keep today's strict
|
|
36
|
+
* validation. Same graceful degradation as `sdk_config`: a malformed claim
|
|
37
|
+
* never rejects the token.
|
|
38
|
+
*/
|
|
39
|
+
readonly tokenPayload?: TokenPayload;
|
|
31
40
|
};
|
|
32
41
|
|
|
33
42
|
/**
|
|
34
|
-
* Decode a Smile ID JWT auth token and extract the `sdk_config`
|
|
43
|
+
* Decode a Smile ID JWT auth token and extract the `sdk_config` and
|
|
44
|
+
* `payload` claims.
|
|
35
45
|
*
|
|
36
46
|
* The JWT signature is intentionally NOT verified — the server validates
|
|
37
|
-
* authenticity; the SDK only reads capture settings
|
|
47
|
+
* authenticity; the SDK only reads capture settings and token-bound
|
|
48
|
+
* user-detail/consent presence from the payload.
|
|
38
49
|
*
|
|
39
50
|
* Return semantics:
|
|
40
51
|
* - Structurally invalid token (not exactly three `.`-delimited
|
|
41
52
|
* segments, non-base64url payload, non-object JSON) → `undefined`.
|
|
42
53
|
* - Valid token with no `sdk_config` claim, a non-object claim, or a
|
|
43
54
|
* claim that fails Zod parsing → non-`undefined` `DecodedToken`
|
|
44
|
-
* with `sdkConfig` unset.
|
|
55
|
+
* with `sdkConfig` unset. The `payload` claim degrades the same
|
|
56
|
+
* way into `tokenPayload`.
|
|
45
57
|
* - Valid token with a parseable `sdk_config` → non-`undefined`
|
|
46
58
|
* `DecodedToken` with `sdkConfig` set.
|
|
47
59
|
*
|
|
@@ -58,16 +70,21 @@ export function decodeSmileIDToken(token: string): DecodedToken | undefined {
|
|
|
58
70
|
const partnerIdField =
|
|
59
71
|
typeof rawPartnerId === "string" ? { partnerId: rawPartnerId } : {};
|
|
60
72
|
|
|
73
|
+
// Token-bound user details & consent — a TOP-LEVEL `payload` claim, another
|
|
74
|
+
// sibling of `sdk_config`. Absent/malformed degrades to unset, never rejects.
|
|
75
|
+
const tokenPayload = parseTokenPayload(payloadObject["payload"]);
|
|
76
|
+
const tokenPayloadField = tokenPayload !== undefined ? { tokenPayload } : {};
|
|
77
|
+
|
|
61
78
|
const rawClaim = payloadObject["sdk_config"];
|
|
62
79
|
|
|
63
80
|
if (rawClaim === undefined || rawClaim === null) {
|
|
64
81
|
// No claim present — defaults apply throughout the flow.
|
|
65
|
-
return { ...partnerIdField };
|
|
82
|
+
return { ...partnerIdField, ...tokenPayloadField };
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
if (typeof rawClaim !== "object" || Array.isArray(rawClaim)) {
|
|
69
86
|
// Claim exists but is not an object — treat as absent.
|
|
70
|
-
return { ...partnerIdField };
|
|
87
|
+
return { ...partnerIdField, ...tokenPayloadField };
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
const parseResult = sdkConfigSchema.safeParse(rawClaim);
|
|
@@ -77,12 +94,16 @@ export function decodeSmileIDToken(token: string): DecodedToken | undefined {
|
|
|
77
94
|
// Unreachable in practice (safeParse always gives ZodError on
|
|
78
95
|
// failure), but degrade gracefully to preserve the never-throws
|
|
79
96
|
// contract rather than surfacing an unexpected error type.
|
|
80
|
-
return { ...partnerIdField };
|
|
97
|
+
return { ...partnerIdField, ...tokenPayloadField };
|
|
81
98
|
}
|
|
82
|
-
return { ...partnerIdField };
|
|
99
|
+
return { ...partnerIdField, ...tokenPayloadField };
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
sdkConfig: parseResult.data,
|
|
104
|
+
...partnerIdField,
|
|
105
|
+
...tokenPayloadField,
|
|
106
|
+
};
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
/**
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { UseSmileIDLocalizations } from "../use_smile_id_localizations";
|
|
3
3
|
import { CANONICAL_KEY_COUNT, enStrings } from "../strings_en";
|
|
4
4
|
|
|
5
|
+
// Unicode FIRST STRONG ISOLATE / POP DIRECTIONAL ISOLATE.
|
|
6
|
+
const FSI = "\u2068";
|
|
7
|
+
const PDI = "\u2069";
|
|
8
|
+
|
|
5
9
|
describe("strings_en — canonical key set", () => {
|
|
6
10
|
it("matches the documented canonical key count", () => {
|
|
7
11
|
// Concrete scenario: we silently drop a key during a refactor —
|
|
@@ -55,7 +59,7 @@ describe("UseSmileIDLocalizations", () => {
|
|
|
55
59
|
it("renders {name} placeholders via _t — siConsentTitle parameterized", () => {
|
|
56
60
|
const loc = new UseSmileIDLocalizations();
|
|
57
61
|
expect(loc.siConsentTitle("Acme")).toBe(
|
|
58
|
-
|
|
62
|
+
`${FSI}Acme${PDI} would like to verify your identity using Smile ID.`,
|
|
59
63
|
);
|
|
60
64
|
});
|
|
61
65
|
|
|
@@ -68,7 +72,9 @@ describe("UseSmileIDLocalizations", () => {
|
|
|
68
72
|
const loc = new UseSmileIDLocalizations({
|
|
69
73
|
si_consent_title: "{partnerName} hi {partnerName}",
|
|
70
74
|
});
|
|
71
|
-
expect(loc.siConsentTitle("Acme")).toBe(
|
|
75
|
+
expect(loc.siConsentTitle("Acme")).toBe(
|
|
76
|
+
`${FSI}Acme${PDI} hi ${FSI}Acme${PDI}`,
|
|
77
|
+
);
|
|
72
78
|
});
|
|
73
79
|
|
|
74
80
|
it("uses partner override for parameterized partner-link key", () => {
|
|
@@ -79,13 +85,15 @@ describe("UseSmileIDLocalizations", () => {
|
|
|
79
85
|
si_consent_learn_more_privacy_partner_link: "Politique {partnerName}",
|
|
80
86
|
});
|
|
81
87
|
expect(loc.siConsentLearnMorePrivacyPartnerLink("Acme")).toBe(
|
|
82
|
-
|
|
88
|
+
`Politique ${FSI}Acme${PDI}`,
|
|
83
89
|
);
|
|
84
90
|
});
|
|
85
91
|
|
|
86
92
|
it("renders the document flip title with the {id_type} token", () => {
|
|
87
93
|
const loc = new UseSmileIDLocalizations();
|
|
88
|
-
expect(loc.siDocumentFlipTitle("Green Book")).toBe(
|
|
94
|
+
expect(loc.siDocumentFlipTitle("Green Book")).toBe(
|
|
95
|
+
`Flip your ${FSI}Green Book${PDI}`,
|
|
96
|
+
);
|
|
89
97
|
expect(loc.siDocumentCaptureBack).toBe("Capture Back of ID");
|
|
90
98
|
expect(loc.siDocumentSkipBack).toBe("Skip");
|
|
91
99
|
expect(loc.siDocumentGuidelineGood).toBe("Good");
|
|
@@ -125,3 +133,112 @@ describe("UseSmileIDLocalizations", () => {
|
|
|
125
133
|
});
|
|
126
134
|
});
|
|
127
135
|
});
|
|
136
|
+
|
|
137
|
+
describe("UseSmileIDLocalizations — bidi isolation of interpolated values", () => {
|
|
138
|
+
it("isolates a substituted value so it cannot reorder the sentence", () => {
|
|
139
|
+
const loc = new UseSmileIDLocalizations();
|
|
140
|
+
const rendered = loc.siConsentTitle("شركة");
|
|
141
|
+
|
|
142
|
+
expect(rendered).toContain(`${FSI}شركة${PDI}`);
|
|
143
|
+
expect(rendered.indexOf(FSI)).toBeLessThan(rendered.indexOf(PDI));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("isolates every occurrence of a repeated placeholder", () => {
|
|
147
|
+
const loc = new UseSmileIDLocalizations({
|
|
148
|
+
si_consent_title: "{partnerName} — {partnerName}",
|
|
149
|
+
});
|
|
150
|
+
const rendered = loc.siConsentTitle("Acme");
|
|
151
|
+
|
|
152
|
+
expect(rendered.split(FSI)).toHaveLength(3);
|
|
153
|
+
expect(rendered.split(PDI)).toHaveLength(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("leaves an empty value unwrapped rather than emitting a bare isolate pair", () => {
|
|
157
|
+
const loc = new UseSmileIDLocalizations({
|
|
158
|
+
si_consent_title: "[{partnerName}]",
|
|
159
|
+
});
|
|
160
|
+
expect(loc.siConsentTitle("")).toBe("[]");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("UseSmileIDLocalizations — active-locale identity", () => {
|
|
165
|
+
it("changes when the resolved locale changes", () => {
|
|
166
|
+
const original = UseSmileIDLocalizations.localeResolver;
|
|
167
|
+
try {
|
|
168
|
+
UseSmileIDLocalizations.localeResolver = () => "en";
|
|
169
|
+
const before = UseSmileIDLocalizations.activeLocaleKey;
|
|
170
|
+
UseSmileIDLocalizations.localeResolver = () => "fr";
|
|
171
|
+
expect(UseSmileIDLocalizations.activeLocaleKey).not.toBe(before);
|
|
172
|
+
} finally {
|
|
173
|
+
UseSmileIDLocalizations.localeResolver = original;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("changes when a locale is registered after the first read", () => {
|
|
178
|
+
UseSmileIDLocalizations.clearRegistry();
|
|
179
|
+
const before = UseSmileIDLocalizations.activeLocaleKey;
|
|
180
|
+
UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
|
|
181
|
+
expect(UseSmileIDLocalizations.activeLocaleKey).not.toBe(before);
|
|
182
|
+
UseSmileIDLocalizations.clearRegistry();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("UseSmileIDLocalizations — dev-build fallback warnings", () => {
|
|
187
|
+
const globalWithDev = globalThis as { __DEV__?: boolean };
|
|
188
|
+
|
|
189
|
+
function withDevBuild(body: (warn: ReturnType<typeof vi.fn>) => void): void {
|
|
190
|
+
const hadDev = "__DEV__" in globalWithDev;
|
|
191
|
+
const previousDev = globalWithDev.__DEV__;
|
|
192
|
+
const warn = vi.fn();
|
|
193
|
+
const originalWarn = console.warn;
|
|
194
|
+
globalWithDev.__DEV__ = true;
|
|
195
|
+
console.warn = warn;
|
|
196
|
+
try {
|
|
197
|
+
body(warn);
|
|
198
|
+
} finally {
|
|
199
|
+
console.warn = originalWarn;
|
|
200
|
+
if (hadDev) globalWithDev.__DEV__ = previousDev;
|
|
201
|
+
else delete globalWithDev.__DEV__;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it("warns when the device locale has no registered translations", () => {
|
|
206
|
+
withDevBuild((warn) => {
|
|
207
|
+
UseSmileIDLocalizations.clearRegistry();
|
|
208
|
+
const original = UseSmileIDLocalizations.localeResolver;
|
|
209
|
+
UseSmileIDLocalizations.localeResolver = () => "zu-ZA";
|
|
210
|
+
try {
|
|
211
|
+
UseSmileIDLocalizations.forActiveLocale();
|
|
212
|
+
} finally {
|
|
213
|
+
UseSmileIDLocalizations.localeResolver = original;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("zu-ZA");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("warns once when a supplied translation is missing keys", () => {
|
|
222
|
+
withDevBuild((warn) => {
|
|
223
|
+
const loc = new UseSmileIDLocalizations({
|
|
224
|
+
si_consent_allow: "Autoriser",
|
|
225
|
+
});
|
|
226
|
+
expect(loc.siConsentAllow).toBe("Autoriser");
|
|
227
|
+
expect(warn).not.toHaveBeenCalled();
|
|
228
|
+
|
|
229
|
+
loc.siConsentDeny;
|
|
230
|
+
loc.siLabelContinue;
|
|
231
|
+
|
|
232
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("si_consent_deny");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("stays silent for the English-only default instance", () => {
|
|
238
|
+
withDevBuild((warn) => {
|
|
239
|
+
const loc = new UseSmileIDLocalizations();
|
|
240
|
+
expect(loc.siLabelContinue).toBe("Continue");
|
|
241
|
+
expect(warn).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|