@structured-id/ui-core 1.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +22 -10
  2. package/package.json +25 -43
  3. package/src/components/SidStatusBadge.vue +1 -5
  4. package/src/composables/useAccountTransport.test.ts +2 -1
  5. package/src/composables/useGrpc.test.ts +17 -14
  6. package/src/composables/useGrpc.ts +4 -2
  7. package/src/index.ts +2 -120
  8. package/src/profile/components/ConsentManager.vue +5 -1
  9. package/src/profile/components/ProfileEditor.vue +1 -1
  10. package/src/profile/components/SecuritySettings.vue +5 -1
  11. package/src/profile/components/SessionList.vue +2 -1
  12. package/src/profile/components/SidRegistrationForm.test.ts +173 -1
  13. package/src/profile/components/SidRegistrationForm.vue +88 -32
  14. package/src/profile/components/index.ts +1 -4
  15. package/src/profile/composables/index.ts +1 -0
  16. package/src/profile/composables/operationPlan.test.ts +155 -0
  17. package/src/profile/composables/operationPlan.ts +218 -0
  18. package/src/profile/composables/useAccountApi.ts +4 -4
  19. package/src/profile/composables/useAuth.test.ts +312 -31
  20. package/src/profile/composables/useAuth.ts +176 -30
  21. package/src/profile/composables/useAuthApi.opaque.test.ts +13 -6
  22. package/src/profile/composables/useAuthApi.ts +40 -18
  23. package/src/profile/composables/useCurrentPasswordRequirement.test.ts +196 -0
  24. package/src/profile/composables/useCurrentPasswordRequirement.ts +95 -0
  25. package/src/profile/composables/useEnrollmentApi.test.ts +7 -3
  26. package/src/profile/composables/useEnrollmentApi.ts +3 -5
  27. package/src/profile/composables/useIdentityApi.ts +4 -4
  28. package/src/profile/composables/useSecurityLevel.test.ts +1 -1
  29. package/src/profile/composables/useSecurityLevel.ts +1 -1
  30. package/src/quasar/SidPasswordCeremony.test.ts +253 -0
  31. package/src/quasar/SidPasswordCeremony.vue +456 -0
  32. package/src/quasar/index.ts +2 -0
  33. package/src/utils/rpcRefusal.test.ts +4 -4
  34. package/src/utils/rpcRefusal.ts +8 -4
  35. package/dist/browser-vp/index.cjs +0 -349
  36. package/dist/browser-vp/index.d.cts +0 -276
  37. package/dist/browser-vp/index.d.ts +0 -276
  38. package/dist/browser-vp/index.js +0 -349
  39. package/dist/chunk-C3Q766CY.cjs +0 -125
  40. package/dist/chunk-J5YXRXB5.js +0 -125
  41. package/dist/index.cjs +0 -23581
  42. package/dist/index.d.cts +0 -11539
  43. package/dist/index.d.ts +0 -11539
  44. package/dist/index.js +0 -23581
  45. package/dist/storage-4CB2JTLW.cjs +0 -22
  46. package/dist/storage-THRWREZP.js +0 -22
  47. package/src/browser-vp/crypto.ts +0 -253
  48. package/src/browser-vp/index.test.ts +0 -10
  49. package/src/browser-vp/index.ts +0 -116
  50. package/src/browser-vp/prf.ts +0 -179
  51. package/src/browser-vp/storage.test.ts +0 -9
  52. package/src/browser-vp/storage.ts +0 -163
  53. package/src/browser-vp/types.ts +0 -95
  54. package/src/browser-vp/vp.test.ts +0 -43
  55. package/src/browser-vp/vp.ts +0 -111
  56. package/src/generated/google/api/http.ts +0 -687
  57. package/src/generated/google/api/httpbody.ts +0 -159
  58. package/src/generated/google/protobuf/any.ts +0 -326
  59. package/src/generated/google/protobuf/descriptor.ts +0 -4865
  60. package/src/generated/google/protobuf/duration.ts +0 -230
  61. package/src/generated/google/protobuf/empty.ts +0 -94
  62. package/src/generated/google/protobuf/struct.ts +0 -484
  63. package/src/generated/google/protobuf/timestamp.ts +0 -287
  64. package/src/generated/google/rpc/error_details.ts +0 -1386
  65. package/src/generated/google/rpc/status.ts +0 -127
  66. package/src/generated/sid/v1/account/account.client.ts +0 -527
  67. package/src/generated/sid/v1/account/account.ts +0 -3261
  68. package/src/generated/sid/v1/admin/admin.client.ts +0 -139
  69. package/src/generated/sid/v1/admin/admin.ts +0 -781
  70. package/src/generated/sid/v1/admin/admin_roles.client.ts +0 -181
  71. package/src/generated/sid/v1/admin/admin_roles.ts +0 -1606
  72. package/src/generated/sid/v1/admin/analytics.client.ts +0 -106
  73. package/src/generated/sid/v1/admin/analytics.ts +0 -1017
  74. package/src/generated/sid/v1/admin/branding.client.ts +0 -166
  75. package/src/generated/sid/v1/admin/branding.ts +0 -1906
  76. package/src/generated/sid/v1/admin/enrollment.client.ts +0 -177
  77. package/src/generated/sid/v1/admin/enrollment.ts +0 -1766
  78. package/src/generated/sid/v1/admin/events.client.ts +0 -195
  79. package/src/generated/sid/v1/admin/events.ts +0 -1900
  80. package/src/generated/sid/v1/admin/flow.client.ts +0 -183
  81. package/src/generated/sid/v1/admin/flow.ts +0 -1552
  82. package/src/generated/sid/v1/admin/keys.client.ts +0 -107
  83. package/src/generated/sid/v1/admin/keys.ts +0 -931
  84. package/src/generated/sid/v1/admin/login_experience.client.ts +0 -160
  85. package/src/generated/sid/v1/admin/login_experience.ts +0 -1061
  86. package/src/generated/sid/v1/admin/migration.client.ts +0 -127
  87. package/src/generated/sid/v1/admin/migration.ts +0 -1948
  88. package/src/generated/sid/v1/admin/notifications.client.ts +0 -134
  89. package/src/generated/sid/v1/admin/notifications.ts +0 -1273
  90. package/src/generated/sid/v1/admin/operations.client.ts +0 -241
  91. package/src/generated/sid/v1/admin/operations.ts +0 -2633
  92. package/src/generated/sid/v1/admin/organizations.client.ts +0 -57
  93. package/src/generated/sid/v1/admin/organizations.ts +0 -332
  94. package/src/generated/sid/v1/admin/provisioning.client.ts +0 -232
  95. package/src/generated/sid/v1/admin/provisioning.ts +0 -1383
  96. package/src/generated/sid/v1/admin/scopes.client.ts +0 -171
  97. package/src/generated/sid/v1/admin/scopes.ts +0 -1324
  98. package/src/generated/sid/v1/admin/security.client.ts +0 -410
  99. package/src/generated/sid/v1/admin/security.ts +0 -4077
  100. package/src/generated/sid/v1/admin/sessions.client.ts +0 -114
  101. package/src/generated/sid/v1/admin/sessions.ts +0 -923
  102. package/src/generated/sid/v1/admin/settings.client.ts +0 -351
  103. package/src/generated/sid/v1/admin/settings.ts +0 -2458
  104. package/src/generated/sid/v1/admin/user_profile.client.ts +0 -57
  105. package/src/generated/sid/v1/admin/user_profile.ts +0 -730
  106. package/src/generated/sid/v1/admin/webhooks.client.ts +0 -269
  107. package/src/generated/sid/v1/admin/webhooks.ts +0 -2804
  108. package/src/generated/sid/v1/attestation/attestation.client.ts +0 -115
  109. package/src/generated/sid/v1/attestation/attestation.ts +0 -813
  110. package/src/generated/sid/v1/authn/auth.client.ts +0 -943
  111. package/src/generated/sid/v1/authn/auth.ts +0 -9719
  112. package/src/generated/sid/v1/authn/flows.client.ts +0 -413
  113. package/src/generated/sid/v1/authn/flows.ts +0 -3205
  114. package/src/generated/sid/v1/authn/issuer.client.ts +0 -489
  115. package/src/generated/sid/v1/authn/issuer.ts +0 -1600
  116. package/src/generated/sid/v1/authn/password_history.client.ts +0 -57
  117. package/src/generated/sid/v1/authn/password_history.ts +0 -578
  118. package/src/generated/sid/v1/authn/system_integration.client.ts +0 -73
  119. package/src/generated/sid/v1/authn/system_integration.ts +0 -218
  120. package/src/generated/sid/v1/authz/authz.client.ts +0 -459
  121. package/src/generated/sid/v1/authz/authz.ts +0 -6524
  122. package/src/generated/sid/v1/authz/forward_auth.client.ts +0 -97
  123. package/src/generated/sid/v1/authz/forward_auth.ts +0 -79
  124. package/src/generated/sid/v1/authz/governance.client.ts +0 -102
  125. package/src/generated/sid/v1/authz/governance.ts +0 -875
  126. package/src/generated/sid/v1/common/errors.ts +0 -710
  127. package/src/generated/sid/v1/common/pagination.ts +0 -152
  128. package/src/generated/sid/v1/common/transport.ts +0 -227
  129. package/src/generated/sid/v1/common/types.ts +0 -151
  130. package/src/generated/sid/v1/events/event_stream.client.ts +0 -67
  131. package/src/generated/sid/v1/events/event_stream.ts +0 -375
  132. package/src/generated/sid/v1/federation/upstream.client.ts +0 -142
  133. package/src/generated/sid/v1/federation/upstream.ts +0 -1736
  134. package/src/generated/sid/v1/identity/identity.client.ts +0 -467
  135. package/src/generated/sid/v1/identity/identity.ts +0 -5676
  136. package/src/generated/sid/v1/ids/ids.ts +0 -395
  137. package/src/generated/sid/v1/machine/machine_user.client.ts +0 -219
  138. package/src/generated/sid/v1/machine/machine_user.ts +0 -2364
  139. package/src/generated/sid/v1/machine/pat.client.ts +0 -130
  140. package/src/generated/sid/v1/machine/pat.ts +0 -1083
  141. package/src/generated/sid/v1/projects/projects.client.ts +0 -404
  142. package/src/generated/sid/v1/projects/projects.ts +0 -4446
  143. package/src/generated/sid/v1/scim/scim.client.ts +0 -520
  144. package/src/generated/sid/v1/scim/scim.ts +0 -3182
  145. package/src/generated/sid/v1/test/test.client.ts +0 -232
  146. package/src/generated/sid/v1/test/test.ts +0 -1136
  147. package/src/transport/adaptive.test.ts +0 -728
  148. package/src/transport/adaptive.ts +0 -308
  149. package/src/transport/framing.test.ts +0 -227
  150. package/src/transport/framing.ts +0 -98
  151. package/src/transport/webtransport.test.ts +0 -728
  152. package/src/transport/webtransport.ts +0 -323
@@ -0,0 +1,196 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { effectScope } from "vue";
3
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
4
+ import { base64encode } from "@protobuf-ts/runtime";
5
+ import { Any } from "@structured-id/proto/google/protobuf/any";
6
+ import { Status } from "@structured-id/proto/google/rpc/status";
7
+ import { ErrorInfo } from "@structured-id/proto/google/rpc/error_details";
8
+ import {
9
+ isCurrentPasswordRequired,
10
+ isWrongCurrentPassword,
11
+ useCurrentPasswordRequirement,
12
+ } from "./useCurrentPasswordRequirement";
13
+
14
+ /** A refusal with `reason` and `metadata` in its ErrorInfo. */
15
+ function refused(reason: string, metadata: Record<string, string>): RpcError {
16
+ const info = Any.pack(
17
+ ErrorInfo.create({ reason, domain: "structured.id", metadata }),
18
+ ErrorInfo,
19
+ );
20
+ const status = Status.create({ code: 9, message: "", details: [info] });
21
+ return new RpcError("", "FAILED_PRECONDITION", {
22
+ "grpc-status-details-bin": base64encode(Status.toBinary(status)),
23
+ });
24
+ }
25
+
26
+ /** The composable inside a scope the test can end, with the server's answer given. */
27
+ function requirement(ms: number) {
28
+ const scope = effectScope();
29
+ const r = scope.run(() => useCurrentPasswordRequirement(async () => ms))!;
30
+ return { ...r, scope };
31
+ }
32
+
33
+ describe("useCurrentPasswordRequirement", () => {
34
+ beforeEach(() => vi.useFakeTimers());
35
+ afterEach(() => vi.useRealTimers());
36
+
37
+ // Until the server answers, the current password is asked for: a change
38
+ // that proves it is never refused.
39
+ it("requires the current password until the server answers", () => {
40
+ const r = requirement(180_000);
41
+ expect(r.required.value).toBe(true);
42
+ expect(r.known.value).toBe(false);
43
+ });
44
+
45
+ // The field appears exactly when the server's countdown ends, with no
46
+ // further request.
47
+ it("turns on when the server's countdown ends", async () => {
48
+ const r = requirement(180_000);
49
+ await r.refresh();
50
+ expect(r.required.value).toBe(false);
51
+ expect(r.known.value).toBe(true);
52
+ vi.advanceTimersByTime(179_999);
53
+ expect(r.required.value).toBe(false);
54
+ vi.advanceTimersByTime(1);
55
+ expect(r.required.value).toBe(true);
56
+ });
57
+
58
+ // The server counted from when it answered; the answer took time to
59
+ // arrive, so the timer is shortened by the request's own time and never
60
+ // fires after the server already requires the password.
61
+ it("subtracts the request's time from the countdown", async () => {
62
+ const scope = effectScope();
63
+ const r = scope.run(() =>
64
+ useCurrentPasswordRequirement(
65
+ () =>
66
+ new Promise<number>((resolve) =>
67
+ setTimeout(() => resolve(10_000), 400),
68
+ ),
69
+ ),
70
+ )!;
71
+ const refreshed = r.refresh();
72
+ await vi.advanceTimersByTimeAsync(400);
73
+ await refreshed;
74
+ expect(r.required.value).toBe(false);
75
+ await vi.advanceTimersByTimeAsync(9_599);
76
+ expect(r.required.value).toBe(false);
77
+ await vi.advanceTimersByTimeAsync(1);
78
+ expect(r.required.value).toBe(true);
79
+ scope.stop();
80
+ });
81
+
82
+ // The request's time is measured on a monotonic clock: a wall clock set
83
+ // forward while the answer was on its way (NTP, a user) does not shorten
84
+ // the countdown.
85
+ it("ignores wall clock changes while the answer is on its way", async () => {
86
+ let answer!: (ms: number) => void;
87
+ const scope = effectScope();
88
+ const r = scope.run(() =>
89
+ useCurrentPasswordRequirement(
90
+ () => new Promise<number>((resolve) => (answer = resolve)),
91
+ ),
92
+ )!;
93
+ const refreshed = r.refresh();
94
+ vi.setSystemTime(Date.now() + 3_600_000);
95
+ answer(10_000);
96
+ await refreshed;
97
+ expect(r.required.value).toBe(false);
98
+ await vi.advanceTimersByTimeAsync(9_999);
99
+ expect(r.required.value).toBe(false);
100
+ scope.stop();
101
+ });
102
+
103
+ it("is required at once when the server says zero", async () => {
104
+ const r = requirement(0);
105
+ await r.refresh();
106
+ expect(r.required.value).toBe(true);
107
+ });
108
+
109
+ // A refusal for lack of it wins over a countdown that has not ended (the
110
+ // clocks disagreed): the timer is dropped and the field stays.
111
+ it("requires it after a refusal, dropping the countdown", async () => {
112
+ const r = requirement(180_000);
113
+ await r.refresh();
114
+ r.requireNow();
115
+ expect(r.required.value).toBe(true);
116
+ expect(vi.getTimerCount()).toBe(0);
117
+ });
118
+
119
+ // A refusal that arrives while an answer is still on its way wins: the
120
+ // late answer was counted before the refusal and is stale.
121
+ it("keeps a refusal over an answer still on its way", async () => {
122
+ let answer!: (ms: number) => void;
123
+ const scope = effectScope();
124
+ const r = scope.run(() =>
125
+ useCurrentPasswordRequirement(
126
+ () => new Promise<number>((resolve) => (answer = resolve)),
127
+ ),
128
+ )!;
129
+ const refreshed = r.refresh();
130
+ r.requireNow();
131
+ answer(180_000);
132
+ await refreshed;
133
+ expect(r.required.value).toBe(true);
134
+ expect(vi.getTimerCount()).toBe(0);
135
+ scope.stop();
136
+ });
137
+
138
+ // An answer that arrives after the owner went away changes nothing and
139
+ // arms no timer that would outlive it.
140
+ it("drops an answer that arrives after its owner went away", async () => {
141
+ let answer!: (ms: number) => void;
142
+ const scope = effectScope();
143
+ const r = scope.run(() =>
144
+ useCurrentPasswordRequirement(
145
+ () => new Promise<number>((resolve) => (answer = resolve)),
146
+ ),
147
+ )!;
148
+ const refreshed = r.refresh();
149
+ scope.stop();
150
+ answer(180_000);
151
+ await refreshed;
152
+ expect(vi.getTimerCount()).toBe(0);
153
+ expect(r.known.value).toBe(false);
154
+ });
155
+
156
+ it("stops its timer when its owner goes away", async () => {
157
+ const r = requirement(180_000);
158
+ await r.refresh();
159
+ r.scope.stop();
160
+ expect(vi.getTimerCount()).toBe(0);
161
+ });
162
+ });
163
+
164
+ describe("isCurrentPasswordRequired", () => {
165
+ // Only the step-up whose continuation is the current password counts; a
166
+ // step-up for a second factor needs another prompt.
167
+ it("recognizes only the current-password continuation", () => {
168
+ expect(
169
+ isCurrentPasswordRequired(
170
+ refused("STEP_UP_REQUIRED", { continuation: "current_password" }),
171
+ ),
172
+ ).toBe(true);
173
+ expect(
174
+ isCurrentPasswordRequired(
175
+ refused("STEP_UP_REQUIRED", { continuation: "step_up" }),
176
+ ),
177
+ ).toBe(false);
178
+ expect(
179
+ isCurrentPasswordRequired(
180
+ refused("PASSWORD_REUSED", { continuation: "current_password" }),
181
+ ),
182
+ ).toBe(false);
183
+ expect(isCurrentPasswordRequired(new Error("offline"))).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe("isWrongCurrentPassword", () => {
188
+ // A change that proved a wrong current password is refused as a wrong
189
+ // sign-in; any other refusal is not that.
190
+ it("recognizes the refused sign-in", () => {
191
+ expect(isWrongCurrentPassword(refused("AUTHENTICATION_FAILED", {}))).toBe(
192
+ true,
193
+ );
194
+ expect(isWrongCurrentPassword(refused("PASSWORD_REUSED", {}))).toBe(false);
195
+ });
196
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Whether a password change of this session must prove the current password,
3
+ * kept current without polling: the server says how long until it must, by
4
+ * its own clock, and a timer of exactly that length turns it on. Comparing
5
+ * wall clocks would be wrong whenever the device's clock is off.
6
+ */
7
+ import { onScopeDispose, readonly, ref } from "vue";
8
+ import { passwordChangeRequirementMs } from "./useAuthApi";
9
+ import { ErrorReason } from "@structured-id/proto/sid/v1/common/errors";
10
+ import { rpcRefusal } from "../../utils/rpcRefusal";
11
+ import { WrongCurrentPasswordError } from "./useAuth";
12
+
13
+ /** Whether `err` refused a change because it did not prove the current password. */
14
+ export function isCurrentPasswordRequired(err: unknown): boolean {
15
+ const refusal = rpcRefusal(err);
16
+ return (
17
+ refusal?.reasonCode === ErrorReason.STEP_UP_REQUIRED &&
18
+ refusal.metadata.continuation === "current_password"
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Whether `err` refused a change for a wrong current password: on this
24
+ * device, or by the server for a finalization that did not verify.
25
+ */
26
+ export function isWrongCurrentPassword(err: unknown): boolean {
27
+ return (
28
+ err instanceof WrongCurrentPasswordError ||
29
+ rpcRefusal(err)?.reasonCode === ErrorReason.AUTHENTICATION_FAILED
30
+ );
31
+ }
32
+
33
+ /**
34
+ * The requirement of the signed-in session. `required` starts true: until
35
+ * the server answers, asking for the current password is the safe side,
36
+ * since a change that proves it is always accepted.
37
+ */
38
+ export function useCurrentPasswordRequirement(
39
+ requiredInMs: () => Promise<number> = passwordChangeRequirementMs,
40
+ ) {
41
+ const required = ref(true);
42
+ const known = ref(false);
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ /** Bumped by every question and refusal; an older answer is dropped. */
45
+ let generations = 0;
46
+
47
+ function stop(): void {
48
+ if (timer !== undefined) clearTimeout(timer);
49
+ timer = undefined;
50
+ }
51
+
52
+ /** Ask the server and arm the timer for the moment it becomes required. */
53
+ async function refresh(): Promise<void> {
54
+ // The server counted from its answer, which took part of the request's
55
+ // time to arrive: the whole request time is taken off, so the timer
56
+ // fires early rather than late. Measured on the monotonic clock, which a
57
+ // wall clock change does not move.
58
+ const asked = performance.now();
59
+ const generation = ++generations;
60
+ const ms = (await requiredInMs()) - (performance.now() - asked);
61
+ // A refusal, or a newer answer, came while this one was on its way.
62
+ if (generation !== generations) return;
63
+ stop();
64
+ known.value = true;
65
+ required.value = ms <= 0;
66
+ if (ms > 0) {
67
+ timer = setTimeout(() => {
68
+ timer = undefined;
69
+ required.value = true;
70
+ }, ms);
71
+ }
72
+ }
73
+
74
+ /** The server refused a change for lack of it: required from now on. */
75
+ function requireNow(): void {
76
+ generations++;
77
+ stop();
78
+ known.value = true;
79
+ required.value = true;
80
+ }
81
+
82
+ // An answer still on its way when the owner goes away is dropped like a
83
+ // stale one, so it arms no timer that would outlive the owner.
84
+ onScopeDispose(() => {
85
+ generations++;
86
+ stop();
87
+ });
88
+
89
+ return {
90
+ required: readonly(required),
91
+ known: readonly(known),
92
+ refresh,
93
+ requireNow,
94
+ };
95
+ }
@@ -5,9 +5,13 @@ const mockGetInstanceStatus = vi.fn();
5
5
 
6
6
  vi.mock("../../index", () => ({
7
7
  getTransport: vi.fn(),
8
- EnrollmentServiceClient: vi.fn().mockImplementation(() => ({
9
- getInstanceStatus: mockGetInstanceStatus,
10
- })),
8
+ }));
9
+
10
+ vi.mock("@structured-id/proto/sid/v1/admin/enrollment.client", () => ({
11
+ // Called with `new`: a constructor mock must be a `function`, not an arrow.
12
+ EnrollmentServiceClient: vi.fn().mockImplementation(function () {
13
+ return { getInstanceStatus: mockGetInstanceStatus };
14
+ }),
11
15
  }));
12
16
 
13
17
  describe("getInstanceStatus", () => {
@@ -4,11 +4,9 @@
4
4
  * Wraps the public EnrollmentServiceClient calls the registration page makes
5
5
  * before anyone signs in.
6
6
  */
7
- import {
8
- getTransport,
9
- EnrollmentServiceClient,
10
- type InstanceStatus,
11
- } from "../../index";
7
+ import { getTransport } from "../../index";
8
+ import { EnrollmentServiceClient } from "@structured-id/proto/sid/v1/admin/enrollment.client";
9
+ import type { InstanceStatus } from "@structured-id/proto/sid/v1/admin/enrollment";
12
10
 
13
11
  function client(): EnrollmentServiceClient {
14
12
  return new EnrollmentServiceClient(getTransport());
@@ -6,9 +6,9 @@
6
6
  * frontend.
7
7
  */
8
8
  import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
9
+ import { getAccountTransport } from "../../index";
10
+ import { IdentityServiceClient } from "@structured-id/proto/sid/v1/identity/identity.client";
9
11
  import {
10
- getAccountTransport,
11
- IdentityServiceClient,
12
12
  type Profile,
13
13
  type Session,
14
14
  type Credential,
@@ -17,8 +17,8 @@ import {
17
17
  PrincipalType,
18
18
  CredentialType,
19
19
  AuthenticatorAttachment,
20
- Timestamp,
21
- } from "../../index";
20
+ } from "@structured-id/proto/sid/v1/identity/identity";
21
+ import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
22
22
  import { authMeta } from "../auth";
23
23
 
24
24
  function client(transport?: RpcTransport): IdentityServiceClient {
@@ -11,7 +11,7 @@ import {
11
11
  PrincipalType,
12
12
  type Credential,
13
13
  type Principal,
14
- } from "../../index";
14
+ } from "@structured-id/proto/sid/v1/identity/identity";
15
15
 
16
16
  // ── Helpers ──
17
17
 
@@ -11,7 +11,7 @@ import {
11
11
  CredentialType,
12
12
  AuthenticatorAttachment,
13
13
  PrincipalType,
14
- } from "../../index";
14
+ } from "@structured-id/proto/sid/v1/identity/identity";
15
15
 
16
16
  // ── Security Level ──
17
17
 
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import SidPasswordCeremony from "./SidPasswordCeremony.vue";
4
+ import type { PasswordOperationProgress } from "../profile/composables/useAuth";
5
+
6
+ const stubs = {
7
+ "q-icon": {
8
+ props: ["name"],
9
+ template: '<i class="icon" :data-name="name" />',
10
+ },
11
+ "q-banner": { template: '<div class="reason"><slot /></div>' },
12
+ };
13
+
14
+ const at = (
15
+ step: PasswordOperationProgress["step"],
16
+ fraction: number,
17
+ label: string,
18
+ ): PasswordOperationProgress => ({ step, fraction, label });
19
+
20
+ function mountCeremony(props: Record<string, unknown> = {}) {
21
+ return mount(SidPasswordCeremony, {
22
+ props: { progress: null, outcome: "running", length: 12, ...props },
23
+ global: { stubs },
24
+ });
25
+ }
26
+
27
+ type Ceremony = ReturnType<typeof mountCeremony>;
28
+ const rows = (w: Ceremony) => w.findAll(".sid-ceremony__row");
29
+ const glyphs = (w: Ceremony) =>
30
+ rows(w)[0]
31
+ .findAll(".sid-ceremony__glyph")
32
+ .map((g) => g.text());
33
+ const label = (w: Ceremony) => w.find(".sid-ceremony__label").text();
34
+ const phase = (w: Ceremony) =>
35
+ w
36
+ .classes()
37
+ .find((c) => c.startsWith("sid-ceremony--") && c !== "sid-ceremony--still");
38
+
39
+ /** Past the opening: the rows have turned into digits and merged. */
40
+ async function merged() {
41
+ await vi.advanceTimersByTimeAsync(1300);
42
+ }
43
+
44
+ describe("SidPasswordCeremony", () => {
45
+ beforeEach(() => vi.useFakeTimers());
46
+ afterEach(() => {
47
+ vi.useRealTimers();
48
+ vi.unstubAllGlobals();
49
+ });
50
+
51
+ // One row per password field, each as long as the password (capped);
52
+ // the rows are digits, never the password's characters.
53
+ it("turns each field into a row of digits", async () => {
54
+ const w = mountCeremony({ fields: 2, length: 15 });
55
+ await vi.advanceTimersByTimeAsync(320);
56
+ expect(rows(w)).toHaveLength(2);
57
+ expect(glyphs(w)).toHaveLength(15);
58
+ expect(glyphs(w).some((g) => /^[0-9]$/.test(g))).toBe(true);
59
+ expect(glyphs(w).every((g) => /^[0-9●]$/.test(g))).toBe(true);
60
+ expect(glyphs(mountCeremony({ length: 80 }))).toHaveLength(20);
61
+ });
62
+
63
+ // The rows start in the fields' places and merge into one centre line.
64
+ it("merges the rows into one line", async () => {
65
+ const w = mountCeremony({ fields: 2 });
66
+ expect(phase(w)).toBe("sid-ceremony--split");
67
+ expect(rows(w)[1].attributes("style")).toContain("translateY(72px)");
68
+ await merged();
69
+ expect(phase(w)).toBe("sid-ceremony--line");
70
+ expect(rows(w)[0].attributes("style")).toContain("translateY(36px)");
71
+ expect(rows(w)[1].attributes("style")).toContain("translateY(36px)");
72
+ });
73
+
74
+ // The digits settle into dots from the left as far as the operation went.
75
+ it("settles digits into dots as the operation advances", async () => {
76
+ const w = mountCeremony({ length: 10 });
77
+ await merged();
78
+ await w.setProps({ progress: at("prove", 0.5, "Building the proof") });
79
+ await vi.advanceTimersByTimeAsync(200);
80
+ expect(glyphs(w).slice(0, 5)).toEqual(["●", "●", "●", "●", "●"]);
81
+ expect(w.find("[role=progressbar]").attributes("aria-valuenow")).toBe("50");
82
+ });
83
+
84
+ // Between reports the line moves at the pace the report expects for the
85
+ // rest of its step, so it shows the time left; it stops short of the
86
+ // step's end until the step reports done, and never moves back.
87
+ it("moves at the expected pace between reports", async () => {
88
+ const w = mountCeremony();
89
+ const now = () =>
90
+ Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
91
+ await w.setProps({
92
+ progress: {
93
+ step: "prove",
94
+ fraction: 0.2,
95
+ label: "Building the proof",
96
+ until: 0.6,
97
+ remainingMs: 2000,
98
+ },
99
+ });
100
+ await vi.advanceTimersByTimeAsync(1000);
101
+ expect(now()).toBeGreaterThanOrEqual(39);
102
+ expect(now()).toBeLessThanOrEqual(41);
103
+ await vi.advanceTimersByTimeAsync(10_000);
104
+ expect(now(), "short of the step's end").toBe(58);
105
+ await w.setProps({
106
+ progress: {
107
+ step: "prove",
108
+ fraction: 0.3,
109
+ label: "Building the proof",
110
+ until: 0.6,
111
+ remainingMs: 1500,
112
+ },
113
+ });
114
+ await vi.advanceTimersByTimeAsync(40);
115
+ expect(now(), "a late report never moves it back").toBe(58);
116
+ });
117
+
118
+ // The pace is measured on a monotonic clock: the device's wall clock moved
119
+ // forward by an hour mid-step does not jump the line to the step's end.
120
+ it("keeps its pace when the wall clock is changed", async () => {
121
+ const w = mountCeremony();
122
+ const now = () =>
123
+ Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
124
+ await w.setProps({
125
+ progress: {
126
+ step: "prove",
127
+ fraction: 0.2,
128
+ label: "Building the proof",
129
+ until: 0.6,
130
+ remainingMs: 2000,
131
+ },
132
+ });
133
+ vi.setSystemTime(Date.now() + 3_600_000);
134
+ await vi.advanceTimersByTimeAsync(40);
135
+ expect(now()).toBeLessThanOrEqual(22);
136
+ });
137
+
138
+ // A report present when the ceremony mounts counts as much as a later one.
139
+ it("starts from the progress it is mounted with", async () => {
140
+ const w = mountCeremony({
141
+ progress: at("prove", 0.5, "Building the proof"),
142
+ });
143
+ await vi.advanceTimersByTimeAsync(40);
144
+ expect(w.find("[role=progressbar]").attributes("aria-valuenow")).toBe("50");
145
+ });
146
+
147
+ // A prover still at its first stage keeps reporting the step's start; the
148
+ // line goes on at the expected pace instead of restarting from it.
149
+ it("is not held back by reports that lag behind it", async () => {
150
+ const w = mountCeremony();
151
+ const now = () =>
152
+ Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
153
+ const lagging = {
154
+ step: "prove" as const,
155
+ fraction: 0.2,
156
+ label: "Building the proof",
157
+ until: 0.6,
158
+ remainingMs: 4000,
159
+ };
160
+ await w.setProps({ progress: lagging });
161
+ for (let i = 0; i < 10; i++) {
162
+ await vi.advanceTimersByTimeAsync(200);
163
+ await w.setProps({ progress: { ...lagging } });
164
+ }
165
+ // 2 s of a 4 s step: about halfway from 0.2 to 0.6.
166
+ expect(now()).toBeGreaterThanOrEqual(36);
167
+ });
168
+
169
+ // A short step never replaces the label on screen; a lasting one does.
170
+ it("shows a step's label only once the step has lasted", async () => {
171
+ const w = mountCeremony();
172
+ expect(label(w)).toBe("Protecting your password on this device");
173
+ await w.setProps({ progress: at("protect", 0, "Protecting") });
174
+ expect(label(w)).toBe("Protecting");
175
+ await w.setProps({ progress: at("compare", 0.08, "Comparing") });
176
+ await vi.advanceTimersByTimeAsync(100);
177
+ await w.setProps({ progress: at("prove", 0.2, "Building the proof") });
178
+ await vi.advanceTimersByTimeAsync(100);
179
+ expect(label(w)).toBe("Protecting");
180
+ await vi.advanceTimersByTimeAsync(400);
181
+ expect(label(w)).toBe("Building the proof");
182
+ });
183
+
184
+ // Success: the line closes into the orb, the lock appears, then the
185
+ // check; only then is the form told.
186
+ it("closes into the orb, then the lock, then the check", async () => {
187
+ const w = mountCeremony({ acceptedText: "Password changed" });
188
+ await merged();
189
+ await w.setProps({ outcome: "accepted" });
190
+ expect(phase(w)).toBe("sid-ceremony--orb");
191
+ expect(
192
+ w.find("[role=progressbar]").attributes("aria-valuenow"),
193
+ "an accepted operation is complete",
194
+ ).toBe("100");
195
+ expect(w.find(".sid-ceremony__orb").exists()).toBe(true);
196
+ expect(w.find(".icon").exists()).toBe(false);
197
+ await vi.advanceTimersByTimeAsync(650);
198
+ expect(w.find(".icon").attributes("data-name")).toBe("sym_o_lock");
199
+ expect(label(w)).toBe("Password changed");
200
+ await vi.advanceTimersByTimeAsync(450);
201
+ expect(w.find(".icon").attributes("data-name")).toBe("sym_o_check");
202
+ expect(w.emitted("settled")).toBeUndefined();
203
+ await vi.advanceTimersByTimeAsync(700);
204
+ expect(w.emitted("settled")).toEqual([["accepted"]]);
205
+ });
206
+
207
+ // Refusal: no orb ever forms; the line unfolds back into the fields as
208
+ // dots, the reason shows above them, then the form takes over.
209
+ it("unfolds back into the fields after a refusal", async () => {
210
+ const w = mountCeremony({ fields: 2, refusedText: "Used before" });
211
+ await merged();
212
+ await w.setProps({ outcome: "refused" });
213
+ expect(w.find(".sid-ceremony__orb").exists()).toBe(false);
214
+ expect(phase(w)).toBe("sid-ceremony--unfolding");
215
+ expect(rows(w)[1].attributes("style")).toContain("translateY(72px)");
216
+ expect(glyphs(w).every((g) => g === "●")).toBe(true);
217
+ await vi.advanceTimersByTimeAsync(650);
218
+ expect(w.find(".reason").text()).toBe("Used before");
219
+ expect(w.emitted("settled")).toBeUndefined();
220
+ await vi.advanceTimersByTimeAsync(1600);
221
+ expect(w.emitted("settled")).toEqual([["refused"]]);
222
+ expect(w.find(".sid-ceremony__orb").exists()).toBe(false);
223
+ });
224
+
225
+ // An outcome that arrives while the rows are still merging waits for the
226
+ // line, so the choreography is never cut short, and runs once.
227
+ it("lets the rows merge before an early outcome", async () => {
228
+ const w = mountCeremony();
229
+ await w.setProps({ outcome: "refused" });
230
+ expect(phase(w)).toBe("sid-ceremony--split");
231
+ await merged();
232
+ expect(phase(w)).toBe("sid-ceremony--unfolding");
233
+ await vi.advanceTimersByTimeAsync(5000);
234
+ expect(w.emitted("settled")).toEqual([["refused"]]);
235
+ });
236
+
237
+ // With reduced motion nothing churns and every step lands at once.
238
+ it("does not animate under reduced motion", async () => {
239
+ // jsdom has no matchMedia: the browser's answer is given here.
240
+ vi.stubGlobal(
241
+ "matchMedia",
242
+ (query: string) =>
243
+ ({ matches: query.includes("reduce") }) as MediaQueryList,
244
+ );
245
+ const w = mountCeremony();
246
+ expect(w.classes()).toContain("sid-ceremony--still");
247
+ await vi.advanceTimersByTimeAsync(0);
248
+ expect(glyphs(w).every((g) => g === "●")).toBe(true);
249
+ await w.setProps({ outcome: "accepted" });
250
+ await vi.runAllTimersAsync();
251
+ expect(w.emitted("settled")).toEqual([["accepted"]]);
252
+ });
253
+ });