API: names the forms require may be blank, and renaming a company onto a taken name answers 500 #438

Closed
opened 2026-10-01 10:46:42 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, comparing the API's input schemas with the forms that write the same records, and sending blank and clashing names through the test client.

Related to #328.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The schemas, get_or_create_company and patch_company have not moved; the constraint unique_company_name_per_owner is now at jobs/models.py:313 and the form's check at jobs/forms.py:418-422.

The API's input schemas give the required names a maximum length but no minimum, and nothing strips them before the check, although every form requires them. So the API makes companies, listings, applications, contacts, reminders and letters whose name is empty or only spaces. get_or_create_company strips a blank company name to '' and then matches or makes a company called '', so every later call with a blank company_name joins that one nameless company. Renaming a company through PATCH skips the other check the company form makes, that no other company of the owner has the name.

1. Blank names are accepted

  • Where: api/schemas.py:101 (CompanyIn.name), :120 (CompanyPatch.name), :132 (ContactIn.name), :265 and :271 (ListingIn.company_name, title, which the one-step application schema inherits), :513 (ReminderIn.summary), :531 (ReminderPatch.summary), :577 (LetterIn.name); applications/services.py:319-326, get_or_create_company.
  • What happens (each 201 or 200 against the snapshot): POST /api/v1/companies with name: " " makes a company named ''; POST /api/v1/listings with company_name: " " and title: "" makes an untitled listing at that company, and a second blank company_name lands on the same one; POST /api/v1/applications with both blank does the same; POST /api/v1/companies/{id}/contacts with name: "", POST /api/v1/reminders with summary: "" and POST /api/v1/letters with name: "" all succeed; PATCH /api/v1/companies/{id} with name: " " stores the two spaces.
  • Why it matters: a client that loses a field makes records that appear in the lists and pickers with nothing to identify them, and unrelated postings are filed under one nameless employer. The web forms cannot make any of these, so the interface does not expect them.

2. A rename onto a taken name answers 500, or makes a duplicate

  • Where: api/routers/companies.py:89-97, patch_company; the constraint unique_company_name_per_owner at jobs/models.py:310; the form's check at jobs/forms.py:417-421, CompanyForm.clean_name.
  • What happens: with companies Other and Mine, PATCH /api/v1/companies/{Mine} with name: "Other" raises IntegrityError and answers Django's HTML 500 page; with name: "other" it succeeds and leaves two companies that the rest of Postulo, which matches names case-insensitively (get_or_create_company, CompanyForm), treats as one.
  • Why it matters: a crash for an ordinary mistake, and a duplicate that POST /api/v1/companies ("no second Acme") exists to prevent.

Fix

Declare one stripped, non-empty string type in api/schemas.py, for example Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)], and use it for the fields listed in point 1, keeping their maximum lengths; the patch schemas take the same type as | None. In patch_company, refuse a name another of the owner's companies already has, case-insensitively, with a 422, as CompanyForm.clean_name does.

Done when

  • tests/test_api.py sends each call in point 1 with a blank or all-space name and asserts a 422 problem document naming the field, and no row made or changed.
  • tests/test_api.py renames a company to another company's name, in the same and in a different case, and asserts a 422 with both companies unchanged.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, comparing the API's input schemas with the forms that write the same records, and sending blank and clashing names through the test client. Related to #328. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The schemas, `get_or_create_company` and `patch_company` have not moved; the constraint `unique_company_name_per_owner` is now at `jobs/models.py:313` and the form's check at `jobs/forms.py:418-422`. The API's input schemas give the required names a maximum length but no minimum, and nothing strips them before the check, although every form requires them. So the API makes companies, listings, applications, contacts, reminders and letters whose name is empty or only spaces. `get_or_create_company` strips a blank company name to `''` and then matches or makes a company called `''`, so every later call with a blank `company_name` joins that one nameless company. Renaming a company through `PATCH` skips the other check the company form makes, that no other company of the owner has the name. ## 1. Blank names are accepted - **Where:** `api/schemas.py:101` (`CompanyIn.name`), `:120` (`CompanyPatch.name`), `:132` (`ContactIn.name`), `:265` and `:271` (`ListingIn.company_name`, `title`, which the one-step application schema inherits), `:513` (`ReminderIn.summary`), `:531` (`ReminderPatch.summary`), `:577` (`LetterIn.name`); `applications/services.py:319-326`, `get_or_create_company`. - **What happens** (each 201 or 200 against the snapshot): `POST /api/v1/companies` with `name: " "` makes a company named `''`; `POST /api/v1/listings` with `company_name: " "` and `title: ""` makes an untitled listing at that company, and a second blank `company_name` lands on the same one; `POST /api/v1/applications` with both blank does the same; `POST /api/v1/companies/{id}/contacts` with `name: ""`, `POST /api/v1/reminders` with `summary: ""` and `POST /api/v1/letters` with `name: ""` all succeed; `PATCH /api/v1/companies/{id}` with `name: " "` stores the two spaces. - **Why it matters:** a client that loses a field makes records that appear in the lists and pickers with nothing to identify them, and unrelated postings are filed under one nameless employer. The web forms cannot make any of these, so the interface does not expect them. ## 2. A rename onto a taken name answers 500, or makes a duplicate - **Where:** `api/routers/companies.py:89-97`, `patch_company`; the constraint `unique_company_name_per_owner` at `jobs/models.py:310`; the form's check at `jobs/forms.py:417-421`, `CompanyForm.clean_name`. - **What happens:** with companies `Other` and `Mine`, `PATCH /api/v1/companies/{Mine}` with `name: "Other"` raises `IntegrityError` and answers Django's HTML 500 page; with `name: "other"` it succeeds and leaves two companies that the rest of Postulo, which matches names case-insensitively (`get_or_create_company`, `CompanyForm`), treats as one. - **Why it matters:** a crash for an ordinary mistake, and a duplicate that `POST /api/v1/companies` ("no second Acme") exists to prevent. ## Fix Declare one stripped, non-empty string type in `api/schemas.py`, for example `Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]`, and use it for the fields listed in point 1, keeping their maximum lengths; the patch schemas take the same type as `| None`. In `patch_company`, refuse a name another of the owner's companies already has, case-insensitively, with a 422, as `CompanyForm.clean_name` does. ## Done when - `tests/test_api.py` sends each call in point 1 with a blank or all-space name and asserts a 422 problem document naming the field, and no row made or changed. - `tests/test_api.py` renames a company to another company's name, in the same and in a different case, and asserts a 422 with both companies unchanged.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:42 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#438
No description provided.