Companies: names that differ only in accented or non-Latin case become duplicates; renaming through the API skips every check #546
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#546
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading every place a company is matched by name and running the matching against the snapshot on SQLite.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atjobs/models.py:312-314(the constraint),jobs/forms.py:408-422(CompanyForm.clean_name) andjobs/forms.py:670-683(ContactForm._save_department); the service, the importer and the API router have not moved.The rule that Acme, acme and ACME are one employer lives only in Python-side
name__iexactlookups; the database constraintunique_company_name_per_owneris case-sensitive. On SQLite, the default database, Django turnsiexactintoLIKE, which folds ASCII letters only, so an accented or non-Latin name typed in another case becomes a second company. The API's rename path has no check at all: it makes case duplicates, saves an empty name, and answers 500 on an exact clash.jobs/models.py:309-311:UniqueConstraint(fields=("owner", "name")), case-sensitive.applications/services.py:324,get_or_create_company:filter(name__iexact=name).first(), thencreate. Used by the intake form, the capture review, the listing form, the CSV import and the API's company, application and listing creation.jobs/forms.py:407-421,CompanyForm.clean_name: the samename__iexactcheck, whose docstring says the constraint "would happily accept "acme" alongside "Acme"".core/importer.py:575and:756-760: the archive importer matches companies and departments withname__iexact.api/routers/companies.py:86-102,patch_company:setattr(company, field, value)for every field sent, thencompany.save(), with no name check.jobs/forms.py:673-686,ContactForm._save_department: departments matched exactly (get_or_create(name=...)), while the importer matches them case-insensitively.get_or_create_company(user, "émile frères")creates a second company; so do Οτε and ΟΤΕ.CompanyFormaccepts ÉMILE FRÈRES as new. Acme/ACME is caught, as intended.PATCH /api/v1/companies/{id}with{"name": "ACME"}answers 200 and leaves ACME and Acme;{"name": ""}answers 200 and saves a company with no name;{"name": "Acme"}answers 500 withIntegrityError: UNIQUE constraint failed: jobs_company.owner_id, jobs_company.name.iexactusesUPPER(), which folds Unicode, but two concurrent creations of the same name both pass the check and the second fails on the constraint with a 500.writescope.jobs/duplicates.pywill later flag some of these as possible duplicates, but only after the fact.Company,casefold()with whitespace collapsed, written insave()asPostalAddress.comparableis, and replace the constraint withUniqueConstraint(owner, key)(a data migration fills the key; existing clashes need a report rather than a silent merge).get_or_create_company,CompanyForm.clean_name, the importer and the CSV import, instead ofname__iexact. (jobs/duplicates.py:foldalso strips accents and punctuation, which is right for a hint and too loose for a constraint.)get_or_create_company, catchIntegrityErrorinside a savepoint and read the existing row again.patch_companythe form's rules: refuse an empty name with 422 and a name whose key another company holds with 409, as problem documents.Done when
tests/test_duplicates.py(or a newtests/test_company_names.py):get_or_create_companyreturns the same company for Émile Frères and émile frères on SQLite, andCompanyFormrefuses ÉMILE FRÈRES beside Émile Frères.tests/test_api.py:PATCH /api/v1/companies/{id}with a case variant of another company's name answers 409, with an empty name 422, and never 500.