fix(auth): add username + passwordHash fields to users collection
The production UsersRepository reads and writes username + passwordHash via the Payload local API, but the users collection never declared them, so production sign-up/sign-in was broken (audit finding B1). passwordHash uses access.read: () => false so credential material never serializes through any Payload API surface; the repository still reads it with overrideAccess: true. A contract-shaped test pins every repo-used field (USERS_REPOSITORY_FIELDS) against the collection config so drift fails at test time without a database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
59
packages/auth/src/integrations/cms/collections/users.test.ts
Normal file
59
packages/auth/src/integrations/cms/collections/users.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { users } from "@/integrations/cms/collections/users";
|
||||
import { USERS_REPOSITORY_FIELDS } from "@/infrastructure/repositories/users.repository";
|
||||
|
||||
type NamedField = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
index?: boolean;
|
||||
admin?: { hidden?: boolean };
|
||||
access?: { read?: (args: unknown) => boolean | Promise<boolean> };
|
||||
};
|
||||
|
||||
function fieldByName(name: string): NamedField | undefined {
|
||||
return (users.fields as NamedField[]).find((f) => f.name === name);
|
||||
}
|
||||
|
||||
// Contract-shaped drift guard (audit finding B1): the production
|
||||
// UsersRepository reads/writes these fields via the Payload local API, so the
|
||||
// collection config must declare every one of them. No database needed —
|
||||
// we parse the collection object directly.
|
||||
describe("users collection <-> UsersRepository field contract", () => {
|
||||
it.each([...USERS_REPOSITORY_FIELDS])(
|
||||
"declares the '%s' field the repository reads/writes",
|
||||
(name) => {
|
||||
expect(fieldByName(name)).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("username is a required, unique, indexed text field", () => {
|
||||
const username = fieldByName("username");
|
||||
expect(username).toMatchObject({
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("passwordHash is required and hidden in the admin UI", () => {
|
||||
const passwordHash = fieldByName("passwordHash");
|
||||
expect(passwordHash).toBeDefined();
|
||||
expect(passwordHash!.type).toBe("text");
|
||||
expect(passwordHash!.required).toBe(true);
|
||||
expect(passwordHash!.admin?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("passwordHash is never readable through the Payload API", async () => {
|
||||
const passwordHash = fieldByName("passwordHash");
|
||||
expect(passwordHash!.access?.read).toBeTypeOf("function");
|
||||
// Field-level read access must deny unconditionally — even for admins —
|
||||
// so the hash never serializes into REST/GraphQL/admin responses. The
|
||||
// repository bypasses this via the local API's overrideAccess: true.
|
||||
await expect(
|
||||
Promise.resolve(passwordHash!.access!.read!({ req: {} })),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,35 @@ export const users: CollectionConfig = {
|
||||
subject: { kind: "self", field: "id" },
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
// Read/written by the production UsersRepository (getUserByUsername,
|
||||
// createUser). Pinned by collections/users.test.ts against
|
||||
// USERS_REPOSITORY_FIELDS so repo <-> collection drift fails fast.
|
||||
name: "username",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
custom: {
|
||||
pii: {
|
||||
category: "identification-username",
|
||||
purpose: ["service-delivery"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Credential material — must never leave the server. `access.read`
|
||||
// returns false unconditionally so the field is stripped from every
|
||||
// REST/GraphQL/admin API response; the auth repository still reads it
|
||||
// through the local API with `overrideAccess: true`.
|
||||
name: "passwordHash",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: { hidden: true },
|
||||
access: { read: () => false },
|
||||
},
|
||||
{
|
||||
name: "displayName",
|
||||
type: "text",
|
||||
|
||||
Reference in New Issue
Block a user