@fuzdev/fuz_app 0.89.0 → 0.90.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.
- package/dist/auth/account_action_specs.d.ts +1 -1
- package/dist/auth/account_route_schema.d.ts +2 -2
- package/dist/auth/account_schema.d.ts +3 -3
- package/dist/auth/admin_action_specs.d.ts +8 -8
- package/dist/auth/audit_log_schema.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts +2 -2
- package/dist/auth/signup_route_schema.d.ts +2 -2
- package/dist/auth/signup_route_schema.d.ts.map +1 -1
- package/dist/auth/signup_route_schema.js +2 -2
- package/dist/primitive_schemas.d.ts +27 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +55 -2
- package/dist/server/env.d.ts +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +15 -3
- package/dist/testing/CLAUDE.md +53 -1
- package/dist/testing/app_server.d.ts +16 -5
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +3 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +6 -0
- package/dist/testing/cross_backend/identity_parity.d.ts +13 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -0
- package/dist/testing/cross_backend/identity_parity.js +465 -0
- package/dist/testing/cross_backend/setup.d.ts +9 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +14 -1
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +7 -0
- package/package.json +1 -1
|
@@ -99,7 +99,7 @@ export declare const account_verify_action_spec: {
|
|
|
99
99
|
output: z.ZodObject<{
|
|
100
100
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
101
101
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
102
|
-
email: z.ZodNullable<z.
|
|
102
|
+
email: z.ZodNullable<z.ZodString>;
|
|
103
103
|
email_verified: z.ZodBoolean;
|
|
104
104
|
created_at: z.ZodString;
|
|
105
105
|
}, z.core.$strict>;
|
|
@@ -21,7 +21,7 @@ export declare const AccountStatusOutput: z.ZodObject<{
|
|
|
21
21
|
account: z.ZodObject<{
|
|
22
22
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
23
23
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
24
|
-
email: z.ZodNullable<z.
|
|
24
|
+
email: z.ZodNullable<z.ZodString>;
|
|
25
25
|
email_verified: z.ZodBoolean;
|
|
26
26
|
created_at: z.ZodString;
|
|
27
27
|
}, z.core.$strict>;
|
|
@@ -108,7 +108,7 @@ export declare const account_status_route_shape: {
|
|
|
108
108
|
account: z.ZodObject<{
|
|
109
109
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
110
110
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
111
|
-
email: z.ZodNullable<z.
|
|
111
|
+
email: z.ZodNullable<z.ZodString>;
|
|
112
112
|
email_verified: z.ZodBoolean;
|
|
113
113
|
created_at: z.ZodString;
|
|
114
114
|
}, z.core.$strict>;
|
|
@@ -124,7 +124,7 @@ export interface ApiToken {
|
|
|
124
124
|
export declare const SessionAccountJson: z.ZodObject<{
|
|
125
125
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
126
126
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
127
|
-
email: z.ZodNullable<z.
|
|
127
|
+
email: z.ZodNullable<z.ZodString>;
|
|
128
128
|
email_verified: z.ZodBoolean;
|
|
129
129
|
created_at: z.ZodString;
|
|
130
130
|
}, z.core.$strict>;
|
|
@@ -170,7 +170,7 @@ export type ActorSummaryJson = z.infer<typeof ActorSummaryJson>;
|
|
|
170
170
|
export declare const AdminAccountJson: z.ZodObject<{
|
|
171
171
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
172
172
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
173
|
-
email: z.ZodNullable<z.
|
|
173
|
+
email: z.ZodNullable<z.ZodString>;
|
|
174
174
|
email_verified: z.ZodBoolean;
|
|
175
175
|
created_at: z.ZodString;
|
|
176
176
|
updated_at: z.ZodString;
|
|
@@ -207,7 +207,7 @@ export declare const AdminAccountEntryJson: z.ZodObject<{
|
|
|
207
207
|
account: z.ZodObject<{
|
|
208
208
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
209
209
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
210
|
-
email: z.ZodNullable<z.
|
|
210
|
+
email: z.ZodNullable<z.ZodString>;
|
|
211
211
|
email_verified: z.ZodBoolean;
|
|
212
212
|
created_at: z.ZodString;
|
|
213
213
|
updated_at: z.ZodString;
|
|
@@ -37,7 +37,7 @@ export declare const AdminAccountListOutput: z.ZodObject<{
|
|
|
37
37
|
account: z.ZodObject<{
|
|
38
38
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
39
39
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
40
|
-
email: z.ZodNullable<z.
|
|
40
|
+
email: z.ZodNullable<z.ZodString>;
|
|
41
41
|
email_verified: z.ZodBoolean;
|
|
42
42
|
created_at: z.ZodString;
|
|
43
43
|
updated_at: z.ZodString;
|
|
@@ -184,7 +184,7 @@ export declare const AuditLogRoleGrantHistoryOutput: z.ZodObject<{
|
|
|
184
184
|
export type AuditLogRoleGrantHistoryOutput = z.infer<typeof AuditLogRoleGrantHistoryOutput>;
|
|
185
185
|
/** Input for `invite_create`. At least one of `email` / `username` must be provided. */
|
|
186
186
|
export declare const InviteCreateInput: z.ZodObject<{
|
|
187
|
-
email: z.ZodOptional<z.ZodNullable<z.
|
|
187
|
+
email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
188
188
|
username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
|
|
189
189
|
acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
190
190
|
}, z.core.$strict>;
|
|
@@ -194,7 +194,7 @@ export declare const InviteCreateOutput: z.ZodObject<{
|
|
|
194
194
|
ok: z.ZodLiteral<true>;
|
|
195
195
|
invite: z.ZodObject<{
|
|
196
196
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
197
|
-
email: z.ZodNullable<z.
|
|
197
|
+
email: z.ZodNullable<z.ZodString>;
|
|
198
198
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
199
199
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
200
200
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -212,7 +212,7 @@ export type InviteListInput = z.infer<typeof InviteListInput>;
|
|
|
212
212
|
export declare const InviteListOutput: z.ZodObject<{
|
|
213
213
|
invites: z.ZodArray<z.ZodObject<{
|
|
214
214
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
215
|
-
email: z.ZodNullable<z.
|
|
215
|
+
email: z.ZodNullable<z.ZodString>;
|
|
216
216
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
217
217
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
218
218
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -368,7 +368,7 @@ export declare const admin_account_list_action_spec: {
|
|
|
368
368
|
account: z.ZodObject<{
|
|
369
369
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
370
370
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
371
|
-
email: z.ZodNullable<z.
|
|
371
|
+
email: z.ZodNullable<z.ZodString>;
|
|
372
372
|
email_verified: z.ZodBoolean;
|
|
373
373
|
created_at: z.ZodString;
|
|
374
374
|
updated_at: z.ZodString;
|
|
@@ -590,7 +590,7 @@ export declare const invite_create_action_spec: {
|
|
|
590
590
|
};
|
|
591
591
|
side_effects: true;
|
|
592
592
|
input: z.ZodObject<{
|
|
593
|
-
email: z.ZodOptional<z.ZodNullable<z.
|
|
593
|
+
email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
594
594
|
username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
|
|
595
595
|
acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
596
596
|
}, z.core.$strict>;
|
|
@@ -598,7 +598,7 @@ export declare const invite_create_action_spec: {
|
|
|
598
598
|
ok: z.ZodLiteral<true>;
|
|
599
599
|
invite: z.ZodObject<{
|
|
600
600
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
601
|
-
email: z.ZodNullable<z.
|
|
601
|
+
email: z.ZodNullable<z.ZodString>;
|
|
602
602
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
603
603
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
604
604
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -632,7 +632,7 @@ export declare const invite_list_action_spec: {
|
|
|
632
632
|
output: z.ZodObject<{
|
|
633
633
|
invites: z.ZodArray<z.ZodObject<{
|
|
634
634
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
635
|
-
email: z.ZodNullable<z.
|
|
635
|
+
email: z.ZodNullable<z.ZodString>;
|
|
636
636
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
637
637
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
638
638
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -83,7 +83,7 @@ export declare const audit_metadata_schemas: Readonly<{
|
|
|
83
83
|
invite_id: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
84
84
|
open_signup: z.ZodOptional<z.ZodBoolean>;
|
|
85
85
|
reason: z.ZodOptional<z.ZodString>;
|
|
86
|
-
email: z.ZodOptional<z.
|
|
86
|
+
email: z.ZodOptional<z.ZodString>;
|
|
87
87
|
}, z.core.$loose>;
|
|
88
88
|
password_change: z.ZodNullable<z.ZodObject<{
|
|
89
89
|
sessions_revoked: z.ZodOptional<z.ZodNumber>;
|
|
@@ -22,7 +22,7 @@ export interface Invite {
|
|
|
22
22
|
/** Zod schema for client-safe invite data. */
|
|
23
23
|
export declare const InviteJson: z.ZodObject<{
|
|
24
24
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
25
|
-
email: z.ZodNullable<z.
|
|
25
|
+
email: z.ZodNullable<z.ZodString>;
|
|
26
26
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
27
27
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
28
28
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -33,7 +33,7 @@ export type InviteJson = z.infer<typeof InviteJson>;
|
|
|
33
33
|
/** Zod schema for invite data with resolved creator/claimer usernames. */
|
|
34
34
|
export declare const InviteWithUsernamesJson: z.ZodObject<{
|
|
35
35
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
36
|
-
email: z.ZodNullable<z.
|
|
36
|
+
email: z.ZodNullable<z.ZodString>;
|
|
37
37
|
username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
38
38
|
claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
39
39
|
claimed_at: z.ZodNullable<z.ZodString>;
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import type { RouteSpec } from '../http/route_spec.js';
|
|
16
|
-
/** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
|
|
16
|
+
/** Input for `POST /signup`. `email` is optional (absent or `null` = no email) and must match any referenced invite. */
|
|
17
17
|
export declare const SignupInput: z.ZodObject<{
|
|
18
18
|
username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
19
19
|
password: z.ZodString;
|
|
20
|
-
email: z.ZodOptional<z.
|
|
20
|
+
email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
21
21
|
}, z.core.$strict>;
|
|
22
22
|
export type SignupInput = z.infer<typeof SignupInput>;
|
|
23
23
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signup_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,
|
|
1
|
+
{"version":3,"file":"signup_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,wHAAwH;AACxH,eAAO,MAAM,WAAW;;;;kBAItB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;GAMG;AACH,eAAO,MAAM,YAAY;;;;;;;;;kBAIvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,4EAA4E;AAC5E,MAAM,WAAW,uBAAuB;IACvC,iFAAiF;IACjF,2BAA2B,EAAE,OAAO,CAAC;CACrC;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,uBAAuB,KAC9B,IAAI,CAAC,SAAS,EAAE,SAAS,CAgB1B,CAAC"}
|
|
@@ -16,11 +16,11 @@ import { Uuid } from '@fuzdev/fuz_util/id.js';
|
|
|
16
16
|
import { Username, Email } from '../primitive_schemas.js';
|
|
17
17
|
import { Password } from './password.js';
|
|
18
18
|
import { ERROR_NO_MATCHING_INVITE, ERROR_SIGNUP_CONFLICT, ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY, } from '../http/error_schemas.js';
|
|
19
|
-
/** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
|
|
19
|
+
/** Input for `POST /signup`. `email` is optional (absent or `null` = no email) and must match any referenced invite. */
|
|
20
20
|
export const SignupInput = z.strictObject({
|
|
21
21
|
username: Username,
|
|
22
22
|
password: Password,
|
|
23
|
-
email: Email.
|
|
23
|
+
email: Email.nullish(),
|
|
24
24
|
});
|
|
25
25
|
/**
|
|
26
26
|
* Output for `POST /signup`.
|
|
@@ -40,16 +40,42 @@ export type Username = z.infer<typeof Username>;
|
|
|
40
40
|
* per-account rate-limit key and DB lookup see a uniform value
|
|
41
41
|
* regardless of casing or surrounding whitespace. Mirrors the storage
|
|
42
42
|
* canonicalization on `Username` so submission and storage agree.
|
|
43
|
+
*
|
|
44
|
+
* The trailing `.refine` rejects a whitespace-only identifier: `.min(1)`
|
|
45
|
+
* runs on the raw string (so `" "` passes it), and without the post-trim
|
|
46
|
+
* check the value would canonicalize to `""` and fall through to a
|
|
47
|
+
* lookup-miss 401 instead of a 400 — an empty identifier is malformed
|
|
48
|
+
* input, not a wrong credential. Keeps the Rust spine's
|
|
49
|
+
* `account_login` (which rejects empty-after-trim) in parity.
|
|
43
50
|
*/
|
|
44
51
|
export declare const UsernameProvided: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
45
52
|
export type UsernameProvided = z.infer<typeof UsernameProvided>;
|
|
53
|
+
/**
|
|
54
|
+
* Maximum email length in **bytes** — RFC 5321 §4.5.3.1.3 path-length limit
|
|
55
|
+
* (the limit is octets). `Email` bounds the UTF-8 byte length, matching the
|
|
56
|
+
* Rust spine's `s.len()` check; for an all-ASCII address bytes == characters.
|
|
57
|
+
*/
|
|
58
|
+
export declare const EMAIL_LENGTH_MAX = 254;
|
|
46
59
|
/**
|
|
47
60
|
* Email validation. Lives here rather than `@fuzdev/fuz_util` because every
|
|
48
61
|
* current consumer pairs it with `Username` (signup, invites, audit log) —
|
|
49
62
|
* keeping the two together avoids a cross-package import for the
|
|
50
63
|
* identity-primitive bundle. Promote to fuz_util if a non-identity consumer
|
|
51
64
|
* surfaces.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately permissive — a structural shape check (`EMAIL_REGEX` plus the
|
|
67
|
+
* `EMAIL_LENGTH_MAX` byte bound), not RFC 5322 conformance or deliverability
|
|
68
|
+
* (real delivery is proven by a confirmation email). Replaces Zod's stricter
|
|
69
|
+
* `z.email()` so the rule is one explicit regex the Rust spine's
|
|
70
|
+
* `is_valid_email` mirrors; `z.email()`'s internal regex (2+ char TLD, no
|
|
71
|
+
* consecutive dots) was brittle to keep in cross-impl parity and rejected
|
|
72
|
+
* addresses like `a@b.c`. The length bound is the UTF-8 **byte** count (RFC
|
|
73
|
+
* 5321 measures octets), so a multibyte address is bounded identically to the
|
|
74
|
+
* Rust spine's `s.len()` rather than diverging on JS's UTF-16 `.length`. No
|
|
75
|
+
* transform: case is preserved and surrounding whitespace is rejected (not
|
|
76
|
+
* trimmed), so storage is verbatim and the case-insensitive lookup rides the
|
|
77
|
+
* DB-side `LOWER(email)`.
|
|
52
78
|
*/
|
|
53
|
-
export declare const Email: z.
|
|
79
|
+
export declare const Email: z.ZodString;
|
|
54
80
|
export type Email = z.infer<typeof Email>;
|
|
55
81
|
//# sourceMappingURL=primitive_schemas.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAKc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD
|
|
1
|
+
{"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAKc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,wDAKmE,CAAC;AACjG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAqBpC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,KAAK,aAYhB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC"}
|
|
@@ -45,17 +45,70 @@ export const Username = z
|
|
|
45
45
|
* per-account rate-limit key and DB lookup see a uniform value
|
|
46
46
|
* regardless of casing or surrounding whitespace. Mirrors the storage
|
|
47
47
|
* canonicalization on `Username` so submission and storage agree.
|
|
48
|
+
*
|
|
49
|
+
* The trailing `.refine` rejects a whitespace-only identifier: `.min(1)`
|
|
50
|
+
* runs on the raw string (so `" "` passes it), and without the post-trim
|
|
51
|
+
* check the value would canonicalize to `""` and fall through to a
|
|
52
|
+
* lookup-miss 401 instead of a 400 — an empty identifier is malformed
|
|
53
|
+
* input, not a wrong credential. Keeps the Rust spine's
|
|
54
|
+
* `account_login` (which rejects empty-after-trim) in parity.
|
|
48
55
|
*/
|
|
49
56
|
export const UsernameProvided = z
|
|
50
57
|
.string()
|
|
51
58
|
.min(1)
|
|
52
59
|
.max(USERNAME_PROVIDED_LENGTH_MAX)
|
|
53
|
-
.transform((s) => s.trim().toLowerCase())
|
|
60
|
+
.transform((s) => s.trim().toLowerCase())
|
|
61
|
+
.refine((s) => s.length > 0, { message: 'Username must not be empty after trimming whitespace' });
|
|
62
|
+
/**
|
|
63
|
+
* Maximum email length in **bytes** — RFC 5321 §4.5.3.1.3 path-length limit
|
|
64
|
+
* (the limit is octets). `Email` bounds the UTF-8 byte length, matching the
|
|
65
|
+
* Rust spine's `s.len()` check; for an all-ASCII address bytes == characters.
|
|
66
|
+
*/
|
|
67
|
+
export const EMAIL_LENGTH_MAX = 254;
|
|
68
|
+
/**
|
|
69
|
+
* Loose email shape `local@domain.tld`: a non-empty local part, exactly one
|
|
70
|
+
* `@`, a domain with an interior dot (a non-empty label on each side), and
|
|
71
|
+
* no whitespace. Accepts `a@b.c` (single-char TLDs are fine) and
|
|
72
|
+
* `user+tag@a.co`; rejects `notanemail`, `@x.com`, `user@`, `user@host`, and
|
|
73
|
+
* any whitespace.
|
|
74
|
+
*
|
|
75
|
+
* The negated class excludes `\s` **plus** U+0085 (NEL): JS's `\s` omits
|
|
76
|
+
* U+0085 but Unicode `White_Space` (Rust's `char::is_whitespace`) includes
|
|
77
|
+
* it, so excluding it keeps the whitespace set identical across the twins.
|
|
78
|
+
* (The Rust side correspondingly also rejects U+FEFF, which JS `\s` treats as
|
|
79
|
+
* whitespace but Unicode `White_Space` does not — so both spines reject
|
|
80
|
+
* exactly `White_Space ∪ {U+FEFF}`.)
|
|
81
|
+
*/
|
|
82
|
+
const EMAIL_REGEX = /^[^\s\u0085@]+@[^\s\u0085@]+\.[^\s\u0085@]+$/;
|
|
83
|
+
/** UTF-8 encoder for the `EMAIL_LENGTH_MAX` byte (octet) bound. */
|
|
84
|
+
const utf8_encoder = new TextEncoder();
|
|
54
85
|
/**
|
|
55
86
|
* Email validation. Lives here rather than `@fuzdev/fuz_util` because every
|
|
56
87
|
* current consumer pairs it with `Username` (signup, invites, audit log) —
|
|
57
88
|
* keeping the two together avoids a cross-package import for the
|
|
58
89
|
* identity-primitive bundle. Promote to fuz_util if a non-identity consumer
|
|
59
90
|
* surfaces.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately permissive — a structural shape check (`EMAIL_REGEX` plus the
|
|
93
|
+
* `EMAIL_LENGTH_MAX` byte bound), not RFC 5322 conformance or deliverability
|
|
94
|
+
* (real delivery is proven by a confirmation email). Replaces Zod's stricter
|
|
95
|
+
* `z.email()` so the rule is one explicit regex the Rust spine's
|
|
96
|
+
* `is_valid_email` mirrors; `z.email()`'s internal regex (2+ char TLD, no
|
|
97
|
+
* consecutive dots) was brittle to keep in cross-impl parity and rejected
|
|
98
|
+
* addresses like `a@b.c`. The length bound is the UTF-8 **byte** count (RFC
|
|
99
|
+
* 5321 measures octets), so a multibyte address is bounded identically to the
|
|
100
|
+
* Rust spine's `s.len()` rather than diverging on JS's UTF-16 `.length`. No
|
|
101
|
+
* transform: case is preserved and surrounding whitespace is rejected (not
|
|
102
|
+
* trimmed), so storage is verbatim and the case-insensitive lookup rides the
|
|
103
|
+
* DB-side `LOWER(email)`.
|
|
60
104
|
*/
|
|
61
|
-
export const Email = z
|
|
105
|
+
export const Email = z
|
|
106
|
+
.string()
|
|
107
|
+
.regex(EMAIL_REGEX)
|
|
108
|
+
// `s.length` (UTF-16 units) is a cheap upper bound that short-circuits the
|
|
109
|
+
// `encode` on absurd inputs: a valid (<= 254-byte) address is necessarily
|
|
110
|
+
// <= 254 code units, so this never rejects one — the byte count is the real
|
|
111
|
+
// RFC-5321 octet bound, matching the Rust spine's `s.len()`.
|
|
112
|
+
.refine((s) => s.length <= EMAIL_LENGTH_MAX && utf8_encoder.encode(s).length <= EMAIL_LENGTH_MAX, {
|
|
113
|
+
message: `Email must be at most ${EMAIL_LENGTH_MAX} bytes`,
|
|
114
|
+
});
|
package/dist/server/env.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare const BaseServerEnv: z.ZodObject<{
|
|
|
33
33
|
PUBLIC_FUZ_CONTACT_EMAIL: z.ZodOptional<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>>;
|
|
34
34
|
FUZ_BOOTSTRAP_TOKEN_PATH: z.ZodOptional<z.ZodString>;
|
|
35
35
|
SMTP_HOST: z.ZodOptional<z.ZodString>;
|
|
36
|
-
SMTP_USER: z.ZodOptional<z.
|
|
36
|
+
SMTP_USER: z.ZodOptional<z.ZodString>;
|
|
37
37
|
SMTP_PASSWORD: z.ZodOptional<z.ZodString>;
|
|
38
38
|
FUZ_FACTS_DIR: z.ZodDefault<z.ZodString>;
|
|
39
39
|
FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX: z.ZodOptional<z.ZodString>;
|
package/dist/server/env.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAoDxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBA4BxD,CAAC"}
|
package/dist/server/env.js
CHANGED
|
@@ -21,7 +21,13 @@ import { parse_allowed_origins } from '../http/origin.js';
|
|
|
21
21
|
*/
|
|
22
22
|
export const BaseServerEnv = z.strictObject({
|
|
23
23
|
NODE_ENV: z.enum(['development', 'production']).meta({ description: 'Runtime environment mode' }),
|
|
24
|
-
PORT: z.coerce
|
|
24
|
+
PORT: z.coerce
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(65535)
|
|
29
|
+
.default(4040)
|
|
30
|
+
.meta({ description: 'HTTP server port' }),
|
|
25
31
|
HOST: z.string().default('localhost').meta({ description: 'HTTP server bind address' }),
|
|
26
32
|
DATABASE_URL: z.string().min(1).meta({
|
|
27
33
|
description: 'Database URL (postgres://, file://, or memory://)',
|
|
@@ -45,10 +51,16 @@ export const BaseServerEnv = z.strictObject({
|
|
|
45
51
|
.optional()
|
|
46
52
|
.meta({ description: 'Path to one-shot admin bootstrap token', sensitivity: 'secret' }),
|
|
47
53
|
SMTP_HOST: z.string().optional().meta({ description: 'SMTP server hostname' }),
|
|
54
|
+
// SMTP usernames are frequently not emails (SendGrid uses "apikey",
|
|
55
|
+
// AWS SES / Postmark use token-style credentials), so validate as a plain
|
|
56
|
+
// string — `z.email()` here would reject valid provider configs. Marked
|
|
57
|
+
// `secret`: those token-style usernames are credentials (Postmark reuses
|
|
58
|
+
// the server token as both user and password), so the value is masked in
|
|
59
|
+
// the startup summary / logs like `SMTP_PASSWORD`.
|
|
48
60
|
SMTP_USER: z
|
|
49
|
-
.
|
|
61
|
+
.string()
|
|
50
62
|
.optional()
|
|
51
|
-
.meta({ description: 'SMTP authentication username' }),
|
|
63
|
+
.meta({ description: 'SMTP authentication username', sensitivity: 'secret' }),
|
|
52
64
|
SMTP_PASSWORD: z
|
|
53
65
|
.string()
|
|
54
66
|
.optional()
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -840,7 +840,12 @@ source of truth for wire-shape conformance.
|
|
|
840
840
|
whose `RoleSpec.grant_paths` don't include `'admin'` reject loudly at
|
|
841
841
|
offer-create time (`ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE`); those
|
|
842
842
|
roles must be seeded via `extra_accounts` at bootstrap-equivalent time
|
|
843
|
-
instead.
|
|
843
|
+
instead. `create_account({email})` threads an email onto the signup body
|
|
844
|
+
(the username-scoped invite matches regardless of email), so the account
|
|
845
|
+
lands with an email set — the hook the username-or-email login parity suite
|
|
846
|
+
uses; the in-process path sets it directly on the inserted row. Must be a
|
|
847
|
+
valid, per-test-unique `Email` (the `LOWER(email)` partial-unique index
|
|
848
|
+
rejects collisions). Caller-supplied `username`s pass through _as-is_ now that the DB
|
|
844
849
|
wipes between tests — hardcoded names like `'user_two'` work and the
|
|
845
850
|
earlier uniquification prefix is gone. Every `TestFixture` also exposes
|
|
846
851
|
`fresh_transport({origin?: string | null})` — cookie-jar-free probe;
|
|
@@ -1247,6 +1252,53 @@ in-process spec-level gate test (`src/test/testing/testing_actions_auth.test.ts`
|
|
|
1247
1252
|
asserting each spec declares `credential_types: ['daemon_token']`) + the
|
|
1248
1253
|
`assert_no_testing_methods` surface invariant.
|
|
1249
1254
|
|
|
1255
|
+
### Identity-primitive parity — `cross_backend/identity_parity.ts`
|
|
1256
|
+
|
|
1257
|
+
`describe_identity_parity_cross_tests({setup_test, login_path?, signup_path?})` —
|
|
1258
|
+
the imperative suite pinning the `primitive_schemas` twins (`Username`,
|
|
1259
|
+
`UsernameProvided`, `Email`) + login/signup input handling end-to-end over real
|
|
1260
|
+
HTTP (flat REST `/login` + `/signup`, so imperative, not a `conformance_table`
|
|
1261
|
+
row). Six facets, each a TS↔Rust reimplementation pair that the spec-derived
|
|
1262
|
+
round-trip / conformance suites never vary:
|
|
1263
|
+
|
|
1264
|
+
- **Login canonicalization** — case-insensitive + whitespace-trim lookup
|
|
1265
|
+
(`UsernameProvided` `.trim().toLowerCase()` + `LOWER(username)`), plus a
|
|
1266
|
+
Turkish-`İ` homograph **no-Unicode-fold-collision** negative.
|
|
1267
|
+
- **Username-or-email login** — an account created with an email logs in via
|
|
1268
|
+
that email, case-insensitively both directions; username login still works;
|
|
1269
|
+
non-existent email → 401. This is the corner that surfaced and closed the Rust
|
|
1270
|
+
spine's username-only divergence (it now mirrors TS's
|
|
1271
|
+
`query_account_by_username_or_email`).
|
|
1272
|
+
- **Login input validation** — malformed login input → 400 `invalid_request_body`
|
|
1273
|
+
on every spine: whitespace-only username (empty after trim), over-long username
|
|
1274
|
+
(> 255), empty password, unknown body key (strict object). TS runs the full
|
|
1275
|
+
`LoginInput` Zod schema; the Rust spine enforces the same shape in
|
|
1276
|
+
`account_login` (closed a cluster where three of these fell through to a
|
|
1277
|
+
lookup-miss 401 on Rust and a whitespace-only identifier 401'd on TS). Asserted
|
|
1278
|
+
by error reason, not just status.
|
|
1279
|
+
- **ASCII-only creation invariant** — non-ASCII username → 400 at signup.
|
|
1280
|
+
- **Length + format regex parity** — both `[USERNAME_LENGTH_MIN,
|
|
1281
|
+
USERNAME_LENGTH_MAX]` = `[3, 39]` boundary edges (just-outside → 400,
|
|
1282
|
+
just-inside → 403 no-matching-invite) and the creation regex shape (leading
|
|
1283
|
+
non-letter / trailing punctuation / embedded disallowed char → 400; mid-string
|
|
1284
|
+
`_`/`-` accepted), catching an off-by-one or position bug in the TS Zod
|
|
1285
|
+
`.min/.max/.regex` vs the Rust byte-scan `is_valid_username_for_creation`.
|
|
1286
|
+
- **Email format (creation)** — the optional signup `email` validated to the
|
|
1287
|
+
loose `local@domain.tld` shape (TS `Email` `^[^\s@]+@[^\s@]+\.[^\s@]+$` + 254-byte
|
|
1288
|
+
bound, Rust `is_valid_email`): malformed (no `@` / no domain dot / empty
|
|
1289
|
+
side / dot at a domain edge / whitespace / over-length) → 400, well-formed →
|
|
1290
|
+
403 no-invite. Accepted siblings include the single-char-TLD `a@b.c` that
|
|
1291
|
+
Zod's `z.email()` rejects — the deliberately-looser shared rule. **Closed a
|
|
1292
|
+
real divergence**: the Rust spine previously length-checked the email only,
|
|
1293
|
+
accepting any non-empty string TS rejected.
|
|
1294
|
+
|
|
1295
|
+
Ungated (login + signup are on every spine). Runs both legs via the shared
|
|
1296
|
+
`{setup_test}` protocol: in-process (`identity_parity.db.test.ts`, plain
|
|
1297
|
+
`gro test`) and cross-process (`identity_parity.cross.test.ts`). `$lib`-free.
|
|
1298
|
+
The accepted-case 403s rely on `create_spine_route_specs` zeroing
|
|
1299
|
+
`signup_fail_floor_ms` (mirroring `login_fail_floor_ms: 0`) so they don't each
|
|
1300
|
+
pay the 250ms denial floor.
|
|
1301
|
+
|
|
1250
1302
|
### Building a TS test-server binary — `testing_server_core.ts` + adapters
|
|
1251
1303
|
|
|
1252
1304
|
The reusable shape for standing up a **spawnable TS** cross-process test
|
|
@@ -48,6 +48,8 @@ export interface CreateTestAccountWithCredentialsOptions {
|
|
|
48
48
|
username?: string;
|
|
49
49
|
password_value?: string;
|
|
50
50
|
roles?: Array<string>;
|
|
51
|
+
/** Optional email stored on the account row — exercises the username-or-email login lookup. */
|
|
52
|
+
email?: string;
|
|
51
53
|
}
|
|
52
54
|
/** Alias for the keeper-flavored call site. Same shape. */
|
|
53
55
|
export type BootstrapTestKeeperOptions = CreateTestAccountWithCredentialsOptions;
|
|
@@ -269,6 +271,19 @@ export interface TestAccount {
|
|
|
269
271
|
/** Build request headers with this account's Bearer token. */
|
|
270
272
|
create_bearer_headers: (extra?: Record<string, string>) => Record<string, string>;
|
|
271
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Args for `TestApp.create_account` — mint an additional account alongside the
|
|
276
|
+
* keeper. Mirrors the cross-process `CreateTestAccountOptions` (the two
|
|
277
|
+
* `create_account` paths share a shape so fixture call sites read identically),
|
|
278
|
+
* kept local to avoid a cross-module cycle with `cross_backend/setup.ts`.
|
|
279
|
+
*/
|
|
280
|
+
export interface CreateTestAppAccountArgs {
|
|
281
|
+
username?: string;
|
|
282
|
+
password_value?: string;
|
|
283
|
+
roles?: Array<string>;
|
|
284
|
+
/** Optional email stored on the account row — exercises the username-or-email login lookup. */
|
|
285
|
+
email?: string;
|
|
286
|
+
}
|
|
272
287
|
/**
|
|
273
288
|
* A fully assembled test app — Hono app + backend + helpers.
|
|
274
289
|
*/
|
|
@@ -285,11 +300,7 @@ export interface TestApp {
|
|
|
285
300
|
/** Build request headers with the daemon token (keeper auth). */
|
|
286
301
|
create_daemon_token_headers: (extra?: Record<string, string>) => Record<string, string>;
|
|
287
302
|
/** Create an additional account with credentials. */
|
|
288
|
-
create_account: (options?:
|
|
289
|
-
username?: string;
|
|
290
|
-
password_value?: string;
|
|
291
|
-
roles?: Array<string>;
|
|
292
|
-
}) => Promise<TestAccount>;
|
|
303
|
+
create_account: (options?: CreateTestAppAccountArgs) => Promise<TestAccount>;
|
|
293
304
|
/** Cleanup resources (delegates to TestAppServer.cleanup). */
|
|
294
305
|
cleanup: () => Promise<void>;
|
|
295
306
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,MAAM,CAAC;AAG/B,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAIjD,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAE1E,OAAO,KAAK,EAAC,EAAE,EAAE,MAAM,EAAC,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAU1D,OAAO,EAA8B,KAAK,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC3F,OAAO,EAAiB,KAAK,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEzE,OAAO,EAAwB,KAAK,UAAU,EAAE,KAAK,YAAY,EAAC,MAAM,0BAA0B,CAAC;AACnG,OAAO,EAEN,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AACtE,OAAO,KAAK,EAAC,UAAU,EAAE,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAOrD,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,kBAAkB,CAAC;AAE9D;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,gBAIhC,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,QAAiB,CAAC;AA0CjD;;;;;GAKG;AACH,MAAM,WAAW,uCAAuC;IACvD,EAAE,EAAE,EAAE,CAAC;IACP,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
|
|
@@ -78,12 +78,13 @@ const resolve_fallback_factory = (migration_namespaces) => {
|
|
|
78
78
|
* `role_grant` (one per role), `api_token`, and `auth_session`.
|
|
79
79
|
*/
|
|
80
80
|
export const create_test_account_with_credentials = async (options) => {
|
|
81
|
-
const { db, keyring, session_options, password, username = 'keeper', password_value = DEFAULT_TEST_PASSWORD, roles = [], } = options;
|
|
81
|
+
const { db, keyring, session_options, password, username = 'keeper', password_value = DEFAULT_TEST_PASSWORD, roles = [], email, } = options;
|
|
82
82
|
const deps = { db };
|
|
83
83
|
const password_hash = await password.hash_password(password_value);
|
|
84
84
|
const { account, actor } = await query_create_account_with_actor(deps, {
|
|
85
85
|
username,
|
|
86
86
|
password_hash,
|
|
87
|
+
...(email !== undefined && { email }),
|
|
87
88
|
});
|
|
88
89
|
// Grant roles
|
|
89
90
|
for (const role of roles) {
|
|
@@ -315,6 +316,7 @@ export const create_test_app = async (options) => {
|
|
|
315
316
|
username: account_options?.username ?? `test_user_${account_counter}`,
|
|
316
317
|
password_value: account_options?.password_value ?? DEFAULT_TEST_PASSWORD,
|
|
317
318
|
roles: account_options?.roles ?? [],
|
|
319
|
+
...(account_options?.email !== undefined && { email: account_options.email }),
|
|
318
320
|
});
|
|
319
321
|
return {
|
|
320
322
|
...bootstrapped,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,
|
|
1
|
+
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAwB/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
|
|
@@ -86,6 +86,12 @@ export const create_spine_route_specs = (ctx) => [
|
|
|
86
86
|
session_options: spine_session_options,
|
|
87
87
|
ip_rate_limiter: null,
|
|
88
88
|
signup_account_rate_limiter: null,
|
|
89
|
+
// Disable the denial floor so the identity-parity suite's accepted
|
|
90
|
+
// cases (valid username/email, no invite → 403 no-matching-invite)
|
|
91
|
+
// don't each pay the 250ms timing floor. Mirrors `login_fail_floor_ms: 0`
|
|
92
|
+
// above — the floor is a production timing-oracle defense, not
|
|
93
|
+
// behavior any cross test asserts.
|
|
94
|
+
signup_fail_floor_ms: 0,
|
|
89
95
|
}),
|
|
90
96
|
]),
|
|
91
97
|
...(ctx.audit_sse
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { SetupTest } from './setup.js';
|
|
3
|
+
/** Options for the identity-primitive parity suite. */
|
|
4
|
+
export interface IdentityParityCrossTestOptions {
|
|
5
|
+
/** Per-test fixture producer (in-process or cross-process). */
|
|
6
|
+
readonly setup_test: SetupTest;
|
|
7
|
+
/** REST login route path. Default `/api/account/login` (the spine convention). */
|
|
8
|
+
readonly login_path?: string;
|
|
9
|
+
/** REST signup route path. Default `/api/account/signup` (the spine convention). */
|
|
10
|
+
readonly signup_path?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const describe_identity_parity_cross_tests: (options: IdentityParityCrossTestOptions) => void;
|
|
13
|
+
//# sourceMappingURL=identity_parity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqG9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAkB1C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AA0JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IA2TF,CAAC"}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend identity-primitive parity for fuz_app's own spine over real
|
|
4
|
+
* HTTP — the `primitive_schemas` twins (`Username`, `UsernameProvided`,
|
|
5
|
+
* `Email`) and the login/signup input handling that enforces them, pinned
|
|
6
|
+
* facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure:
|
|
7
|
+
* how usernames are **canonicalized** on the login lookup, the **ASCII-only
|
|
8
|
+
* creation invariant** that bounds what can ever be stored, and the **email
|
|
9
|
+
* format** rule applied at signup.
|
|
10
|
+
*
|
|
11
|
+
* `UsernameProvided` (login/lookup) canonicalizes the submitted username via
|
|
12
|
+
* `.trim().toLowerCase()`, and the DB matches case-insensitively
|
|
13
|
+
* (`LOWER(username) = LOWER($1)`); `Username` (creation) lowercases at store
|
|
14
|
+
* **and** restricts to ASCII via `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The
|
|
15
|
+
* Rust spine must produce the same canonical form, the same case-insensitive
|
|
16
|
+
* lookup, **and** the same ASCII-only rejection — or a username that logs in on
|
|
17
|
+
* TS silently 401s on Rust, or a non-ASCII username storable on one backend
|
|
18
|
+
* reopens the homograph-collision surface. The spec-derived round-trip +
|
|
19
|
+
* conformance suites never vary username casing or charset, so this corner was
|
|
20
|
+
* unpinned.
|
|
21
|
+
*
|
|
22
|
+
* **Canonicalization (login lookup):**
|
|
23
|
+
*
|
|
24
|
+
* - **case-insensitive login** — an account created `Mixed_Case` logs in via an
|
|
25
|
+
* all-uppercase submission (proves the *lookup* folds case, not merely that
|
|
26
|
+
* the stored form was lowercased).
|
|
27
|
+
* - **whitespace-trim login** — the same shape logs in with surrounding
|
|
28
|
+
* whitespace (proves `.trim()` on the lookup path).
|
|
29
|
+
* - **no Unicode case-fold collision (negative)** — a Turkish-`İ` (U+0130)
|
|
30
|
+
* variant of an existing ASCII username must NOT match. Both JS
|
|
31
|
+
* `.toLowerCase()` and Rust `str::to_lowercase()` map `İ` → `i` + U+0307
|
|
32
|
+
* (combining dot above), never plain `i`, so the cased homograph stays a
|
|
33
|
+
* distinct, non-existent username → 401 on every backend.
|
|
34
|
+
*
|
|
35
|
+
* **Username-or-email login lookup:** the login identifier resolves against
|
|
36
|
+
* **username or email** — TS `query_account_by_username_or_email`, Rust the
|
|
37
|
+
* converged `query_account_with_password_hash` OR-lookup (this was the
|
|
38
|
+
* divergence the suite closed: the Rust spine previously matched username
|
|
39
|
+
* only). An account created with an email logs in via that email,
|
|
40
|
+
* case-insensitively (`Email` stores the original case; folding rides the
|
|
41
|
+
* `LOWER(email) = LOWER($1)` lookup), and username login keeps working when an
|
|
42
|
+
* email is present. A non-existent email → 401.
|
|
43
|
+
*
|
|
44
|
+
* **Login input validation:** malformed login input → 400 `invalid_request_body`
|
|
45
|
+
* on every spine — whitespace-only username (empty after trim), over-long
|
|
46
|
+
* username (> 255), empty password, and an unknown body key (strict object).
|
|
47
|
+
* TS runs the full `LoginInput` Zod schema; the Rust spine enforces the same
|
|
48
|
+
* shape in `account_login` (the convergence the suite closed: the Rust spine
|
|
49
|
+
* previously let three of these fall through to a lookup-miss 401, and TS let a
|
|
50
|
+
* whitespace-only identifier through to a 401). Asserted via the error reason,
|
|
51
|
+
* not just the status, so a same-status-wrong-body backend still fails.
|
|
52
|
+
*
|
|
53
|
+
* **ASCII-only creation invariant:** a non-ASCII username is rejected at signup
|
|
54
|
+
* input validation → 400 on every backend, so no Unicode username is ever
|
|
55
|
+
* stored — the precondition the login no-collision case relies on. (ASCII-only
|
|
56
|
+
* is the intended invariant for both spines, not an accident of the TS regex.)
|
|
57
|
+
*
|
|
58
|
+
* **Length + format creation parity:** the full `Username` shape —
|
|
59
|
+
* `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]` and the regex
|
|
60
|
+
* `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — pinned across both backends. The TS
|
|
61
|
+
* Zod `.min()/.max()/.regex()` and the Rust hand-rolled byte-scan
|
|
62
|
+
* (`is_valid_username_for_creation`) are independent reimplementations of the
|
|
63
|
+
* same rule, so each length boundary is tested just-outside (→ 400) and
|
|
64
|
+
* just-inside (→ 403 no-matching-invite, the settle for a valid username on a
|
|
65
|
+
* spine with `open_signup` at its `false` default), and each format violation
|
|
66
|
+
* (leading non-letter, trailing punctuation, embedded disallowed char) → 400,
|
|
67
|
+
* with mid-string `_`/`-` accepted siblings as the don't-over-reject control.
|
|
68
|
+
* Signup is also strict-object on both spines (TS `z.strictObject`, Rust
|
|
69
|
+
* `#[serde(deny_unknown_fields)]`), so an unknown signup body key → 400.
|
|
70
|
+
*
|
|
71
|
+
* **Email format (creation):** the optional signup `email` is validated to a
|
|
72
|
+
* loose `local@domain.tld` shape on both spines — TS `Email`
|
|
73
|
+
* (`^[^\s@]+@[^\s@]+\.[^\s@]+$` plus a 254-byte (RFC 5321 octet) length bound; whitespace `White_Space ∪ {U+FEFF}`), Rust the
|
|
74
|
+
* hand-rolled `is_valid_email`. A malformed email → 400, a well-formed one →
|
|
75
|
+
* 403 no-matching-invite (the same accept/reject settle the username tables
|
|
76
|
+
* use). The accepted siblings include the single-char-TLD `a@b.c` that Zod's
|
|
77
|
+
* `z.email()` would reject — the deliberately-looser rule both spines now
|
|
78
|
+
* share. This closed a real divergence: the Rust spine previously
|
|
79
|
+
* length-checked the email only, accepting any non-empty string TS rejected.
|
|
80
|
+
*
|
|
81
|
+
* Both surfaces are flat REST on every spine, so this is an imperative suite
|
|
82
|
+
* (not a `conformance_table` row) and ungated. Runs both legs via the shared
|
|
83
|
+
* `{setup_test}` protocol: the in-process leg
|
|
84
|
+
* (`cross_backend/identity_parity.db.test.ts`, plain `gro test`) and the
|
|
85
|
+
* cross-process leg (`cross_backend/identity_parity.cross.test.ts`, the TS spine
|
|
86
|
+
* binaries + Rust `testing_spine_stub` over real HTTP).
|
|
87
|
+
*
|
|
88
|
+
* `$lib`-free by contract (relative specifiers only).
|
|
89
|
+
*
|
|
90
|
+
* @module
|
|
91
|
+
*/
|
|
92
|
+
import { describe, test, assert } from 'vitest';
|
|
93
|
+
import { ERROR_INVALID_CREDENTIALS, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE, } from '../../http/error_schemas.js';
|
|
94
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
95
|
+
/**
|
|
96
|
+
* The flat-REST `{error}` reason each terminal login/signup status carries on
|
|
97
|
+
* every spine — the assertion target that turns a status-only check into a
|
|
98
|
+
* body-shape check. `200` carries no `error` field (`{ok: true}`), so its
|
|
99
|
+
* expected reason is `undefined`. Asserting the reason (not just the status)
|
|
100
|
+
* catches a backend that returns the right status with a wrong, renamed, or
|
|
101
|
+
* info-leaking body — drift the status comparison is blind to. Wire constants,
|
|
102
|
+
* not literals, so a rename in `error_schemas.ts` fails the typecheck here.
|
|
103
|
+
*/
|
|
104
|
+
const REASON_BY_STATUS = {
|
|
105
|
+
200: undefined,
|
|
106
|
+
400: ERROR_INVALID_REQUEST_BODY,
|
|
107
|
+
401: ERROR_INVALID_CREDENTIALS,
|
|
108
|
+
403: ERROR_NO_MATCHING_INVITE,
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE. Its Unicode default lowercase
|
|
112
|
+
* is `i` + U+0307 (combining dot above), never plain `i` — the property the
|
|
113
|
+
* negative case relies on for a stable cross-impl result.
|
|
114
|
+
*/
|
|
115
|
+
const TURKISH_DOTTED_I = 'İ';
|
|
116
|
+
/**
|
|
117
|
+
* Representative non-ASCII usernames spanning distinct scripts — accented Latin
|
|
118
|
+
* (mid-string), Greek (leading), CJK (leading). Each fails the `Username` regex
|
|
119
|
+
* at a different position, so a Rust regex that admitted any one script (but not
|
|
120
|
+
* another) is caught. All → 400 at signup input validation.
|
|
121
|
+
*/
|
|
122
|
+
const USERNAME_NON_ASCII_CASES = [
|
|
123
|
+
{ username: 'café_user', label: 'accented Latin mid-string', status: 400 },
|
|
124
|
+
{ username: 'Ωmega_user', label: 'leading Greek', status: 400 },
|
|
125
|
+
{ username: '日本_user', label: 'leading CJK', status: 400 },
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* Status a signup with a **valid** username (but no matching invite, on a
|
|
129
|
+
* spine whose `open_signup` stays at the production default `false`) settles
|
|
130
|
+
* to: the username cleared input validation and reached the invite gate, which
|
|
131
|
+
* finds no match → 403 `no_matching_invite`. The accepted boundary/format
|
|
132
|
+
* cases below assert this (not 200): the point is that the username was *not*
|
|
133
|
+
* rejected at validation, distinguishing it from the 400 cases.
|
|
134
|
+
*/
|
|
135
|
+
const VALID_USERNAME_NO_INVITE_STATUS = 403;
|
|
136
|
+
/**
|
|
137
|
+
* Username length boundary pairs for the creation schema, pinning both edges of
|
|
138
|
+
* `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]`. Each pair is a
|
|
139
|
+
* just-outside length (rejected at input validation → 400) and its just-inside
|
|
140
|
+
* sibling (cleared validation → 403). Pinning both sides on both backends
|
|
141
|
+
* catches an off-by-one in either impl's min/max check — the TS `.min()/.max()`
|
|
142
|
+
* vs the Rust `s.len() < MIN || s.len() > MAX` byte-length scan. Every value is
|
|
143
|
+
* otherwise valid (leading letter, alphanumeric tail, alphanumeric body) so the
|
|
144
|
+
* only variable is length.
|
|
145
|
+
*/
|
|
146
|
+
const USERNAME_LENGTH_CASES = [
|
|
147
|
+
{ username: 'ab', label: '2 chars — below min', status: 400 },
|
|
148
|
+
{ username: 'abc', label: '3 chars — at min', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
149
|
+
{
|
|
150
|
+
username: `a${'b'.repeat(38)}`,
|
|
151
|
+
label: '39 chars — at max',
|
|
152
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
153
|
+
},
|
|
154
|
+
{ username: `a${'b'.repeat(39)}`, label: '40 chars — above max', status: 400 },
|
|
155
|
+
];
|
|
156
|
+
/**
|
|
157
|
+
* Username format edge cases for the creation regex
|
|
158
|
+
* `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The rejected cases each violate the
|
|
159
|
+
* shape at a different position — leading non-letter, trailing punctuation,
|
|
160
|
+
* embedded disallowed char — so a Rust byte-scan that policed only one position
|
|
161
|
+
* is caught. The two accepted siblings (`_` / `-` *mid-string*) are the
|
|
162
|
+
* non-divergence control: the same characters that are illegal at the end are
|
|
163
|
+
* legal in the body, so they must clear validation (→ 403) on every backend —
|
|
164
|
+
* pinning that neither impl over-rejects them.
|
|
165
|
+
*/
|
|
166
|
+
const USERNAME_FORMAT_CASES = [
|
|
167
|
+
{ username: '1user', label: 'leading digit', status: 400 },
|
|
168
|
+
{ username: '_user', label: 'leading underscore', status: 400 },
|
|
169
|
+
{ username: '-user', label: 'leading dash', status: 400 },
|
|
170
|
+
{ username: 'user_', label: 'trailing underscore', status: 400 },
|
|
171
|
+
{ username: 'user-', label: 'trailing dash', status: 400 },
|
|
172
|
+
{ username: 'us er', label: 'embedded space', status: 400 },
|
|
173
|
+
{ username: 'user@x', label: 'embedded at-sign', status: 400 },
|
|
174
|
+
{ username: 'user.x', label: 'embedded dot', status: 400 },
|
|
175
|
+
{
|
|
176
|
+
username: 'us_er',
|
|
177
|
+
label: 'underscore mid-string (valid)',
|
|
178
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
179
|
+
},
|
|
180
|
+
{ username: 'us-er', label: 'dash mid-string (valid)', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* Signup email-format cases for the loose `Email` shape `local@domain.tld`
|
|
184
|
+
* (TS `^[^\s@]+@[^\s@]+\.[^\s@]+$` plus the 254 length bound; Rust the
|
|
185
|
+
* hand-rolled `is_valid_email`). The rejected cases each violate the shape at
|
|
186
|
+
* a different point — no `@`, no dot in the domain, empty local/domain, a dot
|
|
187
|
+
* at a domain edge, surrounding whitespace, over the length bound — so a
|
|
188
|
+
* backend policing only one of them is caught. The accepted siblings are the
|
|
189
|
+
* don't-over-reject control and include the shapes Zod's `z.email()` rejects
|
|
190
|
+
* but our looser rule accepts — single-char TLD `a@b.c` and consecutive dots
|
|
191
|
+
* `a..b@c.d` — pinning the deliberately-looser shared rule in both directions.
|
|
192
|
+
* Every case rides a valid username so the rejection (or the 403 no-invite
|
|
193
|
+
* settle) isolates the email.
|
|
194
|
+
*/
|
|
195
|
+
const SIGNUP_EMAIL_CASES = [
|
|
196
|
+
{ email: 'not-an-email', label: 'no @ or dot', status: 400 },
|
|
197
|
+
{ email: 'user@host', label: 'no dot in domain', status: 400 },
|
|
198
|
+
{ email: '@example.com', label: 'empty local part', status: 400 },
|
|
199
|
+
{ email: 'user@', label: 'empty domain', status: 400 },
|
|
200
|
+
{ email: 'user@.com', label: 'domain starts with a dot', status: 400 },
|
|
201
|
+
{ email: 'user@com.', label: 'domain ends with a dot', status: 400 },
|
|
202
|
+
{ email: 'a@@b.c', label: 'two @', status: 400 },
|
|
203
|
+
{ email: ' user@example.com', label: 'leading whitespace', status: 400 },
|
|
204
|
+
{ email: 'user@example.com ', label: 'trailing whitespace', status: 400 },
|
|
205
|
+
{ email: 'us er@example.com', label: 'internal whitespace', status: 400 },
|
|
206
|
+
{ email: 'a\u0085b@c.d', label: 'NEL U+0085 (Unicode ws JS \\s omits)', status: 400 },
|
|
207
|
+
{ email: 'a\ufeffb@c.d', label: 'BOM/ZWNBSP U+FEFF', status: 400 },
|
|
208
|
+
// 255 bytes total (243-char local + "@example.com") — one over the 254-byte bound.
|
|
209
|
+
{ email: `${'a'.repeat(243)}@example.com`, label: 'over the 254-byte bound (ASCII)', status: 400 },
|
|
210
|
+
// Multibyte: the bound is UTF-8 BYTES, not code units. `\u00fc` (ü) is 2
|
|
211
|
+
// bytes / 1 code unit, so 121×ü + "@example.com" is 254 bytes (accepted) and
|
|
212
|
+
// 122×ü is 256 bytes (rejected) — both far under 254 code units, so a
|
|
213
|
+
// code-unit `.max(254)` would wrongly accept the over case. Pins the byte
|
|
214
|
+
// bound across both spines (the Rust `is_valid_email` `s.len()`).
|
|
215
|
+
{
|
|
216
|
+
email: `${'\u00fc'.repeat(121)}@example.com`,
|
|
217
|
+
label: 'multibyte at the 254-byte bound',
|
|
218
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
email: `${'\u00fc'.repeat(122)}@example.com`,
|
|
222
|
+
label: 'multibyte over the 254-byte bound',
|
|
223
|
+
status: 400,
|
|
224
|
+
},
|
|
225
|
+
{ email: 'user@example.com', label: 'well-formed', status: VALID_USERNAME_NO_INVITE_STATUS },
|
|
226
|
+
{
|
|
227
|
+
email: 'a@b.c',
|
|
228
|
+
label: 'single-char TLD (looser than z.email)',
|
|
229
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
email: 'a..b@c.d',
|
|
233
|
+
label: 'consecutive dots (looser than z.email)',
|
|
234
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
email: 'user+tag@example.co.uk',
|
|
238
|
+
label: 'plus-tag + multi-label',
|
|
239
|
+
status: VALID_USERNAME_NO_INVITE_STATUS,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
export const describe_identity_parity_cross_tests = (options) => {
|
|
243
|
+
const { setup_test } = options;
|
|
244
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
245
|
+
const signup_path = options.signup_path ?? '/api/account/signup';
|
|
246
|
+
/**
|
|
247
|
+
* POST JSON on a fresh (cookie-jar-free) transport; return the status and the
|
|
248
|
+
* parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
|
|
249
|
+
* bodies). Always reads the body so the cross-process socket releases.
|
|
250
|
+
*/
|
|
251
|
+
const post_json = async (fixture, path, body) => {
|
|
252
|
+
const res = await fixture.fresh_transport()(path, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'content-type': 'application/json' },
|
|
255
|
+
body: JSON.stringify(body),
|
|
256
|
+
});
|
|
257
|
+
const text = await res.text().catch(() => '');
|
|
258
|
+
let error;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = text ? JSON.parse(text) : undefined;
|
|
261
|
+
if (parsed &&
|
|
262
|
+
typeof parsed === 'object' &&
|
|
263
|
+
typeof parsed.error === 'string') {
|
|
264
|
+
error = parsed.error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// non-JSON body — leave `error` undefined.
|
|
269
|
+
}
|
|
270
|
+
return { status: res.status, error };
|
|
271
|
+
};
|
|
272
|
+
/** POST a login with the given identifier (username or email). */
|
|
273
|
+
const login = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, login_path, { username, password });
|
|
274
|
+
/** POST a signup with a valid password and the given username. */
|
|
275
|
+
const signup = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, signup_path, { username, password });
|
|
276
|
+
/**
|
|
277
|
+
* Assert an attempt settled to `status` **and** carried the flat-REST error
|
|
278
|
+
* reason that status implies on every spine (see {@link REASON_BY_STATUS}) —
|
|
279
|
+
* a body-shape check, not just a status check, so a backend can't pass by
|
|
280
|
+
* returning the right status with the wrong/leaky body.
|
|
281
|
+
*/
|
|
282
|
+
const assert_attempt = (actual, status, label) => {
|
|
283
|
+
assert.strictEqual(actual.status, status, `${label} — expected status ${status}, got ${actual.status}`);
|
|
284
|
+
const expected_reason = REASON_BY_STATUS[status];
|
|
285
|
+
assert.strictEqual(actual.error, expected_reason, `${label} — status ${status} must carry error reason ${expected_reason ?? '(none)'}, got ${actual.error ?? '(none)'}`);
|
|
286
|
+
};
|
|
287
|
+
/** Run a table of signup username cases, asserting each settles to its expected `{status, reason}`. */
|
|
288
|
+
const describe_signup_username_cases = (suite_name, cases) => {
|
|
289
|
+
describe(suite_name, () => {
|
|
290
|
+
for (const { username, label, status } of cases) {
|
|
291
|
+
test(`${label} — "${username}" → ${status}`, async () => {
|
|
292
|
+
const fixture = await setup_test();
|
|
293
|
+
assert_attempt(await signup(fixture, username), status, `username "${username}" (${label})`);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
describe('username canonicalization parity', () => {
|
|
299
|
+
test('case-insensitive login — uppercase submission matches a mixed-case account', async () => {
|
|
300
|
+
const fixture = await setup_test();
|
|
301
|
+
// Mixed-case ASCII username (valid per the `Username` regex), stored
|
|
302
|
+
// lowercase; an all-uppercase submission matches only if the lookup
|
|
303
|
+
// folds case on this backend.
|
|
304
|
+
await fixture.create_account({ username: 'Canon_Mixed_User' });
|
|
305
|
+
assert_attempt(await login(fixture, 'CANON_MIXED_USER'), 200, 'uppercase login');
|
|
306
|
+
});
|
|
307
|
+
test('whitespace-trim login — surrounding whitespace is trimmed on lookup', async () => {
|
|
308
|
+
const fixture = await setup_test();
|
|
309
|
+
await fixture.create_account({ username: 'Canon_Trim_User' });
|
|
310
|
+
assert_attempt(await login(fixture, ' canon_trim_user '), 200, 'whitespace-trim login');
|
|
311
|
+
});
|
|
312
|
+
test('no Unicode case-fold collision — Turkish İ variant of an ASCII username → 401', async () => {
|
|
313
|
+
const fixture = await setup_test();
|
|
314
|
+
// `canon_admin_user` carries an ASCII `i`; the probe replaces it with İ
|
|
315
|
+
// (U+0130). A backend that folded İ → plain `i` would match the stored
|
|
316
|
+
// username and authenticate (the bug); both impls fold İ → `i` + U+0307,
|
|
317
|
+
// so the canonical form differs and the lookup misses → 401
|
|
318
|
+
// `invalid_credentials` (asserted, not just the status — a backend that
|
|
319
|
+
// 401'd with a different body would still be wrong).
|
|
320
|
+
await fixture.create_account({ username: 'canon_admin_user' });
|
|
321
|
+
const homograph = `canon_adm${TURKISH_DOTTED_I}n_user`;
|
|
322
|
+
assert_attempt(await login(fixture, homograph), 401, 'Turkish-İ homograph');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('username-or-email login parity', () => {
|
|
326
|
+
// The login identifier matches against **username or email** (TS
|
|
327
|
+
// `query_account_by_username_or_email`, Rust the converged
|
|
328
|
+
// `query_account_with_password_hash` OR-lookup). An account that carries
|
|
329
|
+
// an email logs in via that email, case-insensitively, on every backend —
|
|
330
|
+
// the property that was unpinned (and divergent: the Rust spine matched
|
|
331
|
+
// username only) until this suite. Email is stored with its original case
|
|
332
|
+
// (`Email` does not lowercase), so case folding rides entirely on the
|
|
333
|
+
// `LOWER(email) = LOWER($1)` lookup, exercised by the mixed-case cases.
|
|
334
|
+
test('login by exact email — an account created with an email logs in via that email', async () => {
|
|
335
|
+
const fixture = await setup_test();
|
|
336
|
+
await fixture.create_account({
|
|
337
|
+
username: 'email_exact_user',
|
|
338
|
+
email: 'email-exact@example.com',
|
|
339
|
+
});
|
|
340
|
+
assert_attempt(await login(fixture, 'email-exact@example.com'), 200, 'exact-email login');
|
|
341
|
+
});
|
|
342
|
+
test('email login is case-insensitive — mixed-case stored email, lowercase submission', async () => {
|
|
343
|
+
const fixture = await setup_test();
|
|
344
|
+
await fixture.create_account({
|
|
345
|
+
username: 'email_mixed_user',
|
|
346
|
+
email: 'Email.Mixed@Example.COM',
|
|
347
|
+
});
|
|
348
|
+
assert_attempt(await login(fixture, 'email.mixed@example.com'), 200, 'mixed-case-email login');
|
|
349
|
+
});
|
|
350
|
+
test('email login is case-insensitive — lowercase stored email, uppercase submission', async () => {
|
|
351
|
+
const fixture = await setup_test();
|
|
352
|
+
await fixture.create_account({
|
|
353
|
+
username: 'email_upper_user',
|
|
354
|
+
email: 'email-upper@example.com',
|
|
355
|
+
});
|
|
356
|
+
assert_attempt(await login(fixture, 'EMAIL-UPPER@EXAMPLE.COM'), 200, 'uppercase-email login');
|
|
357
|
+
});
|
|
358
|
+
test('username login still works when the account also has an email', async () => {
|
|
359
|
+
const fixture = await setup_test();
|
|
360
|
+
await fixture.create_account({
|
|
361
|
+
username: 'email_dual_user',
|
|
362
|
+
email: 'email-dual@example.com',
|
|
363
|
+
});
|
|
364
|
+
assert_attempt(await login(fixture, 'email_dual_user'), 200, 'username login with email present');
|
|
365
|
+
});
|
|
366
|
+
test('login by a non-existent email → 401', async () => {
|
|
367
|
+
const fixture = await setup_test();
|
|
368
|
+
await fixture.create_account({
|
|
369
|
+
username: 'email_present_user',
|
|
370
|
+
email: 'email-present@example.com',
|
|
371
|
+
});
|
|
372
|
+
assert_attempt(await login(fixture, 'email-absent@example.com'), 401, 'non-existent-email login');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
describe('login input validation parity', () => {
|
|
376
|
+
// Malformed login input → 400 `invalid_request_body` on every spine. TS
|
|
377
|
+
// runs the full `LoginInput` Zod schema (`UsernameProvided` non-empty /
|
|
378
|
+
// max 255 / non-empty-after-trim, `PasswordProvided` non-empty / max 300,
|
|
379
|
+
// `z.strictObject`); the Rust spine enforces the same shape in
|
|
380
|
+
// `account_login` (an extra key fails `deny_unknown_fields` → 400, not
|
|
381
|
+
// axum's 422). These each previously fell through to a lookup-miss 401 (or
|
|
382
|
+
// a bespoke-string 400) on one backend — a status *and* body divergence the
|
|
383
|
+
// strengthened `assert_attempt` (reason-checked) now pins.
|
|
384
|
+
test('whitespace-only username → 400 (empty after trim, not a lookup miss)', async () => {
|
|
385
|
+
const fixture = await setup_test();
|
|
386
|
+
assert_attempt(await login(fixture, ' '), 400, 'whitespace-only username');
|
|
387
|
+
});
|
|
388
|
+
test('over-long username (>255) → 400', async () => {
|
|
389
|
+
const fixture = await setup_test();
|
|
390
|
+
assert_attempt(await login(fixture, 'a'.repeat(256)), 400, 'over-long username');
|
|
391
|
+
});
|
|
392
|
+
test('empty password → 400 (rejected as malformed, not a wrong credential)', async () => {
|
|
393
|
+
const fixture = await setup_test();
|
|
394
|
+
assert_attempt(await login(fixture, 'some_user', ''), 400, 'empty password');
|
|
395
|
+
});
|
|
396
|
+
test('over-long password (>300) → 400', async () => {
|
|
397
|
+
const fixture = await setup_test();
|
|
398
|
+
assert_attempt(await login(fixture, 'some_user', 'a'.repeat(301)), 400, 'over-long password');
|
|
399
|
+
});
|
|
400
|
+
test('unknown body key → 400 (strict object on both spines)', async () => {
|
|
401
|
+
const fixture = await setup_test();
|
|
402
|
+
assert_attempt(await post_json(fixture, login_path, {
|
|
403
|
+
username: 'some_user',
|
|
404
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
405
|
+
unexpected_field: 'x',
|
|
406
|
+
}), 400, 'unknown body key');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
// The `Username` creation schema is ASCII-only by regex; both spines reject a
|
|
410
|
+
// non-ASCII username at signup input validation → 400, so no Unicode username
|
|
411
|
+
// is ever stored (the precondition the no-collision login case relies on).
|
|
412
|
+
describe_signup_username_cases('username creation ASCII-only invariant parity', USERNAME_NON_ASCII_CASES);
|
|
413
|
+
// Both edges of `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = [3, 39], each
|
|
414
|
+
// side just-outside (→ 400) and just-inside (→ 403). Catches an off-by-one in
|
|
415
|
+
// either backend's length check.
|
|
416
|
+
describe_signup_username_cases('username creation length boundary parity', USERNAME_LENGTH_CASES);
|
|
417
|
+
// The creation regex `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — rejected shapes
|
|
418
|
+
// (→ 400) and the mid-string `_`/`-` controls that must be accepted (→ 403),
|
|
419
|
+
// proving neither backend over- or under-rejects.
|
|
420
|
+
describe_signup_username_cases('username creation format parity', USERNAME_FORMAT_CASES);
|
|
421
|
+
// The optional signup `email` is validated to the loose `local@domain.tld`
|
|
422
|
+
// shape on both spines (TS `Email` regex + 254-byte bound, Rust `is_valid_email`):
|
|
423
|
+
// malformed → 400, well-formed → 403 no-invite. The username is fixed-valid so
|
|
424
|
+
// each case isolates the email; the `a@b.c` accept pins the deliberately
|
|
425
|
+
// looser-than-`z.email()` rule the validator port converged on.
|
|
426
|
+
describe('signup email format parity', () => {
|
|
427
|
+
for (const { email, label, status } of SIGNUP_EMAIL_CASES) {
|
|
428
|
+
test(`${label} → ${status}`, async () => {
|
|
429
|
+
const fixture = await setup_test();
|
|
430
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
431
|
+
username: 'email_fmt_user',
|
|
432
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
433
|
+
email,
|
|
434
|
+
}), status, `email "${email}" (${label})`);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// `null` email (not just absent) — TS `Email.nullish()` + the Rust
|
|
438
|
+
// `Option<String>` (serde maps JSON null → None) both treat it as
|
|
439
|
+
// "no email", so signup reaches the invite gate → 403, not a 400.
|
|
440
|
+
// Pins the null-vs-absent convergence the `.nullish()` loosening made.
|
|
441
|
+
test('null email is treated as absent (403, not 400)', async () => {
|
|
442
|
+
const fixture = await setup_test();
|
|
443
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
444
|
+
username: 'email_null_user',
|
|
445
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
446
|
+
email: null,
|
|
447
|
+
}), VALID_USERNAME_NO_INVITE_STATUS, 'null email');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
describe('signup input validation parity', () => {
|
|
451
|
+
// Signup is `z.strictObject` on TS and `#[serde(deny_unknown_fields)]` on
|
|
452
|
+
// the Rust spine — an unknown body key → 400 `invalid_request_body` on
|
|
453
|
+
// both (the Rust spine previously ignored it via serde). The username is
|
|
454
|
+
// valid so the rejection isolates the unknown key (a valid body would
|
|
455
|
+
// otherwise reach the invite gate → 403).
|
|
456
|
+
test('unknown body key → 400 (strict object on both spines)', async () => {
|
|
457
|
+
const fixture = await setup_test();
|
|
458
|
+
assert_attempt(await post_json(fixture, signup_path, {
|
|
459
|
+
username: 'strict_signup_user',
|
|
460
|
+
password: DEFAULT_TEST_PASSWORD,
|
|
461
|
+
unexpected_field: 'x',
|
|
462
|
+
}), 400, 'signup unknown body key');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
};
|
|
@@ -15,6 +15,15 @@ export interface CreateTestAccountOptions {
|
|
|
15
15
|
readonly username?: string;
|
|
16
16
|
readonly password_value?: string;
|
|
17
17
|
readonly roles?: Array<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Optional email to store on the account, exercising the username-or-email
|
|
20
|
+
* login lookup. Cross-process this rides the production signup body
|
|
21
|
+
* (claimed against the username-scoped invite, which matches regardless of
|
|
22
|
+
* the email); in-process it lands directly on the inserted row. Must be a
|
|
23
|
+
* valid `Email` (the cross-process signup validates it) and unique per test
|
|
24
|
+
* (the `LOWER(email)` partial-unique index rejects collisions).
|
|
25
|
+
*/
|
|
26
|
+
readonly email?: string;
|
|
18
27
|
}
|
|
19
28
|
/**
|
|
20
29
|
* Shape returned by `TestFixture.create_account`. Aliased to the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0B9B,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAK5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAC3D,OAAO,EAAyB,KAAK,cAAc,EAAC,MAAM,kCAAkC,CAAC;AAC7F,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,yBAAyB,CAAC;AAC5D,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"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"}
|
|
@@ -279,10 +279,22 @@ const mint_account = async (handle, options) => {
|
|
|
279
279
|
throw new Error(`invite_create(${handle.config.name}, username=${username}) returned unexpected result: ` +
|
|
280
280
|
JSON.stringify(invite_result));
|
|
281
281
|
}
|
|
282
|
+
// Thread the optional email onto the production signup body — the
|
|
283
|
+
// username-scoped invite above matches on username regardless of the
|
|
284
|
+
// email, so the account lands with the email set (exercising the
|
|
285
|
+
// username-or-email login lookup). Guard on `!== undefined` (not truthy)
|
|
286
|
+
// to match the other three email guards (`create_account` here +
|
|
287
|
+
// `app_server.ts`): "requested" is `undefined` vs present, so a
|
|
288
|
+
// caller-supplied value is forwarded verbatim and signup validates it,
|
|
289
|
+
// rather than the truthy form silently dropping an empty string.
|
|
282
290
|
const signup_response = await transport('/api/account/signup', {
|
|
283
291
|
method: 'POST',
|
|
284
292
|
headers: { 'Content-Type': 'application/json' },
|
|
285
|
-
body: JSON.stringify({
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
username,
|
|
295
|
+
password,
|
|
296
|
+
...(options.email !== undefined && { email: options.email }),
|
|
297
|
+
}),
|
|
286
298
|
});
|
|
287
299
|
if (!signup_response.ok) {
|
|
288
300
|
const body = await signup_response.text().catch(() => '<unreadable>');
|
|
@@ -422,6 +434,7 @@ export const default_cross_process_setup = (handle, options) => {
|
|
|
422
434
|
...(account_options?.password_value !== undefined && {
|
|
423
435
|
password_value: account_options.password_value,
|
|
424
436
|
}),
|
|
437
|
+
...(account_options?.email !== undefined && { email: account_options.email }),
|
|
425
438
|
});
|
|
426
439
|
if (account_options?.roles && account_options.roles.length > 0) {
|
|
427
440
|
await grant_roles_via_offer_accept(refreshed_handle, other, account_options.roles);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAkBhE,CAAC;
|
|
1
|
+
{"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAkBhE,CAAC;AAsGF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OAgGnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SA6B5B,CAAC"}
|
|
@@ -127,6 +127,13 @@ const generate_valid_string = (field_schema) => {
|
|
|
127
127
|
if (slug !== null && field_schema.safeParse(slug).success)
|
|
128
128
|
return slug;
|
|
129
129
|
}
|
|
130
|
+
// Email-shaped refinement (the `Email` primitive: loose `local@domain.tld`).
|
|
131
|
+
// The base `x`-fill has no `@`, so an email-pattern field needs a shaped
|
|
132
|
+
// candidate — the `z.string().regex()` form exposes a `pattern`, not the
|
|
133
|
+
// `format: 'email'` the `case 'email'` switch arm keys on.
|
|
134
|
+
const as_email = 'test@example.com';
|
|
135
|
+
if (field_schema.safeParse(as_email).success)
|
|
136
|
+
return as_email;
|
|
130
137
|
// Absolute path refinement (e.g. DiskfilePath)
|
|
131
138
|
const with_slash = '/' + base;
|
|
132
139
|
if (field_schema.safeParse(with_slash).success)
|