chore: bring develop into release sync branch (WealthLedger/main#30) #87

Closed
codex-bot wants to merge 19 commits from develop into chore/30-sync-release
6 changed files with 169 additions and 24 deletions
Showing only changes of commit ebe92684a7 - Show all commits
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { loginSchema, registerSchema } from "./auth.schema";
const email = "user@example.com";
function canLogIn(password: string) {
return loginSchema.safeParse({ email, password }).success;
}
function canRegister(password: string, confirmation = password) {
return registerSchema.safeParse({
email,
password,
passwordConfirmation: confirmation,
}).success;
}
// Register policy: 8..72 printable ASCII (bcrypt reads 72 bytes, so non-ASCII could be
// truncated). Login stays lenient so accounts created under the old policy still sign in.
describe("auth password policy", () => {
it.each([
["7 chars", "a".repeat(7), false],
["8 chars", "a".repeat(8), true],
["72 chars", "a".repeat(72), true],
["73 chars", "a".repeat(73), false],
["printable symbols and space", "Aa1 !~\"#$%&'()*+,-./:;<=>?@", true],
["Cyrillic, 8+ chars", "пароль-пароль", false],
["emoji", "passw😀rd", false],
["tab", "pass\tword1", false],
["newline", "password1\n", false],
["DEL", "password\u{7F}1", false],
])("register: %s", (_, password, accepted) => {
expect(canRegister(password)).toBe(accepted);
});
it("register compares the raw password with its confirmation", () => {
const padded = " spaced pass ";
expect(canRegister(padded)).toBe(true);
expect(canRegister(padded, padded.trim())).toBe(false);
});
it.each([
["legacy Cyrillic password", "пароль-пароль", true],
["short legacy password", "short", true],
["72 bytes", "a".repeat(72), true],
["73 bytes", "a".repeat(73), false],
["36 Cyrillic letters = 72 bytes", "я".repeat(36), true],
["37 Cyrillic letters = 74 bytes", "я".repeat(37), false],
["empty", "", false],
])("login: %s", (_, password, accepted) => {
expect(canLogIn(password)).toBe(accepted);
});
});
+38 -14
View File
@@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254;
const MIN_PASSWORD_BYTES = 12;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72;
// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit.
const ASCII_TILDE = 126;
function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0);
@@ -20,6 +22,14 @@ function isEmailCharacterInvalid(character: string): boolean {
);
}
function isPrintableAscii(value: string): boolean {
return value.split("").every((character) => {
const code = character.codePointAt(0) ?? 0;
return code >= ASCII_SPACE && code <= ASCII_TILDE;
});
}
const email = z
.string()
.trim()
@@ -33,28 +43,42 @@ const email = z
{ error: validationMessage("auth.validation.invalidEmail") },
);
const password = z
// Login stays lenient: accounts created under an older policy must still sign in.
const loginPassword = z
.string()
.min(1)
.refine(
value =>
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.passwordTooLong") },
);
const registerPassword = z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.max(MAX_PASSWORD_BYTES, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.refine(isPrintableAscii, {
error: validationMessage("auth.validation.invalidPasswordLength"),
});
const loginSchema = z.object({
email,
password,
password: loginPassword,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
value => value.password === value.passwordConfirmation,
{
error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"],
},
);
const registerSchema = loginSchema
.extend({ password: registerPassword })
.refine(
value => value.password === value.passwordConfirmation,
{
error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"],
},
);
type AuthFormValues = z.input<typeof loginSchema>;
@@ -70,4 +70,39 @@ describe("AuthForm", () => {
resolve();
await screen.findByRole("link", { name: linkLabel });
});
// Register policy is 8..72 printable ASCII; login accepts legacy passwords.
it.each([
["register", "Регистрация", "пароль-пароль", false],
["register", "Регистрация", "passw😀rd", false],
["register", "Регистрация", "a".repeat(7), false],
["register", "Регистрация", "a".repeat(8), true],
["register", "Регистрация", "a".repeat(72), true],
["register", "Регистрация", "a".repeat(73), false],
["login", "Вход", "пароль-пароль", true],
])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm onSubmit={onSubmit} register={mode === "register"} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } });
if (mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } });
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(
screen.queryByText(/Пароль: от 8 до 72 символов/) === null,
).toBe(submitted));
expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0);
});
});
+16 -4
View File
@@ -746,10 +746,13 @@ export interface components {
detail: string;
}[];
};
CredentialsDto: {
RegisterDto: {
/** @example user@example.com */
email: string;
/** @example a secure password */
/**
* @description Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.
* @example a secure password
*/
password: string;
};
UserDto: {
@@ -760,6 +763,15 @@ export interface components {
accessToken: string;
user: components["schemas"]["UserDto"];
};
LoginDto: {
/** @example user@example.com */
email: string;
/**
* @description Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.
* @example a secure password
*/
password: string;
};
MoneyDto: {
currency: string;
value: string;
@@ -1708,7 +1720,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CredentialsDto"];
"application/json": components["schemas"]["RegisterDto"];
};
};
responses: {
@@ -1776,7 +1788,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CredentialsDto"];
"application/json": components["schemas"]["LoginDto"];
};
};
responses: {
+23 -5
View File
@@ -10,7 +10,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsDto"
"$ref": "#/components/schemas/RegisterDto"
}
}
}
@@ -91,7 +91,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsDto"
"$ref": "#/components/schemas/LoginDto"
}
}
}
@@ -5236,7 +5236,7 @@
"code"
]
},
"CredentialsDto": {
"RegisterDto": {
"type": "object",
"properties": {
"email": {
@@ -5245,8 +5245,8 @@
},
"password": {
"type": "string",
"minLength": 12,
"maxLength": 72,
"description": "Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.",
"pattern": "^[\\x20-\\x7E]{8,72}$",
"example": "a secure password"
}
},
@@ -5285,6 +5285,24 @@
"user"
]
},
"LoginDto": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"password": {
"type": "string",
"description": "Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.",
"example": "a secure password"
}
},
"required": [
"email",
"password"
]
},
"MoneyDto": {
"type": "object",
"properties": {
+2 -1
View File
@@ -55,8 +55,9 @@ const ru = {
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
validation: {
invalidEmail: "Некорректный email",
invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт",
invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
passwordsDoNotMatch: "Пароли не совпадают",
passwordTooLong: "Пароль не должен превышать 72 байта",
},
},
bank: {